158 lines
6.4 KiB
Python
158 lines
6.4 KiB
Python
"""End-to-end tests: the headless ``goose`` harness drives ``goose acp``.
|
|
|
|
The chat-first sibling of ``test_goose_native_cli_e2e``. The ``goose`` harness
|
|
runs Block's Goose over the Agent Client Protocol (``goose acp``):
|
|
:class:`omnigent.inner.goose_executor.GooseExecutor` spawns the subprocess,
|
|
streams ``agent_message_chunk`` updates as chat text, and routes Goose's mid-turn
|
|
``session/request_permission`` through Omnigent's TOOL_CALL policy + human-consent
|
|
elicitation (the same bridges the runner's ExecutorAdapter installs). This test
|
|
drives the executor directly against a *real* ``goose acp`` process and asserts
|
|
the full round-trip: streaming text, a tool-call permission surfaced to the
|
|
elicitation handler, the tool running on approval, and token usage on completion.
|
|
|
|
Environment requirements (why this is opt-in, not pure-CI)
|
|
----------------------------------------------------------
|
|
* **Opt-in only**: set ``OMNIGENT_E2E_GOOSE=1`` to run. Needs a configured Goose
|
|
provider — ``GOOSE_PROVIDER`` + ``GOOSE_MODEL`` and the provider's key in the
|
|
environment (e.g. ``ANTHROPIC_API_KEY``) — plus the ``goose`` binary on PATH.
|
|
The test runs in an isolated ``$HOME`` so it never reads or writes the
|
|
developer's real ``~/.config/goose`` (provider comes from env; the per-tool
|
|
grant Goose may persist lands in the temp HOME and is discarded).
|
|
|
|
OMNIGENT_E2E_GOOSE=1 \
|
|
GOOSE_PROVIDER=anthropic GOOSE_MODEL=claude-haiku-4-5-20251001 \
|
|
ANTHROPIC_API_KEY=... \
|
|
.venv/bin/python -m pytest tests/e2e/test_goose_acp_e2e.py -v
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import shutil
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from omnigent.inner.executor import ExecutorError, TextChunk, TurnComplete
|
|
from omnigent.inner.goose_executor import GooseExecutor
|
|
|
|
pytestmark = pytest.mark.skipif(
|
|
os.environ.get("OMNIGENT_E2E_GOOSE") != "1"
|
|
or shutil.which("goose") is None
|
|
or not os.environ.get("GOOSE_PROVIDER")
|
|
or not os.environ.get("GOOSE_MODEL"),
|
|
reason=(
|
|
"headless goose ACP e2e is opt-in: set OMNIGENT_E2E_GOOSE=1 with a "
|
|
"configured Goose provider (GOOSE_PROVIDER + GOOSE_MODEL + a provider key "
|
|
"in the env) and the `goose` binary on PATH."
|
|
),
|
|
)
|
|
|
|
|
|
class _AskVerdict:
|
|
"""A TOOL_CALL policy verdict that always defers to elicitation."""
|
|
|
|
action = "POLICY_ACTION_ASK"
|
|
|
|
|
|
@pytest.fixture
|
|
def isolated_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
|
"""Point ``$HOME``/``$XDG_CONFIG_HOME`` at a temp dir for goose isolation.
|
|
|
|
Goose resolves its provider from the inherited ``GOOSE_PROVIDER`` env + the
|
|
provider key, so a fresh HOME still authenticates; any ``permission.yaml``
|
|
Goose persists from an ``allow_*`` grant lands here and is discarded.
|
|
"""
|
|
monkeypatch.setenv("HOME", str(tmp_path))
|
|
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / ".config"))
|
|
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / ".local" / "share"))
|
|
# Force per-tool approval so session/request_permission fires.
|
|
monkeypatch.setenv("GOOSE_MODE", "approve")
|
|
return tmp_path
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_goose_acp_streams_and_completes(isolated_home: Path) -> None:
|
|
"""A plain prose turn streams agent text and completes with token usage."""
|
|
executor = GooseExecutor(
|
|
provider=os.environ["GOOSE_PROVIDER"],
|
|
model=os.environ["GOOSE_MODEL"],
|
|
builtins=("developer",),
|
|
)
|
|
chunks: list[str] = []
|
|
final: TurnComplete | None = None
|
|
try:
|
|
async for ev in executor.run_turn(
|
|
[{"role": "user", "content": "Reply with exactly the word PONG and nothing else."}],
|
|
tools=[],
|
|
system_prompt="You are a terse assistant.",
|
|
):
|
|
if isinstance(ev, TextChunk):
|
|
chunks.append(ev.text)
|
|
elif isinstance(ev, TurnComplete):
|
|
final = ev
|
|
elif isinstance(ev, ExecutorError):
|
|
pytest.fail(f"executor error: {ev.message}")
|
|
finally:
|
|
await executor.close()
|
|
|
|
assert final is not None, "expected a TurnComplete"
|
|
assert "PONG" in ("".join(chunks) + (final.response or ""))
|
|
# Goose reports usage; the executor maps it onto TurnComplete.usage.
|
|
assert final.usage is not None and final.usage.get("total_tokens", 0) > 0
|
|
# The context window is learned from usage_update during the turn.
|
|
assert executor.max_context_tokens() is not None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_goose_acp_tool_call_surfaces_elicitation(isolated_home: Path) -> None:
|
|
"""A shell tool call routes through policy(ASK) -> elicitation; on approval
|
|
the tool runs and its output reaches the conversation."""
|
|
elicited: list[tuple[str, dict]] = []
|
|
|
|
async def _policy(phase: str, tool: dict) -> _AskVerdict:
|
|
assert phase == "PHASE_TOOL_CALL"
|
|
return _AskVerdict()
|
|
|
|
async def _elicit(tool_name: str, tool_input: dict) -> bool:
|
|
elicited.append((tool_name, tool_input))
|
|
return True # user approves
|
|
|
|
executor = GooseExecutor(
|
|
provider=os.environ["GOOSE_PROVIDER"],
|
|
model=os.environ["GOOSE_MODEL"],
|
|
builtins=("developer",),
|
|
)
|
|
executor._policy_evaluator = _policy # type: ignore[attr-defined]
|
|
executor._elicitation_handler = _elicit # type: ignore[attr-defined]
|
|
|
|
chunks: list[str] = []
|
|
final: TurnComplete | None = None
|
|
try:
|
|
async for ev in executor.run_turn(
|
|
[
|
|
{
|
|
"role": "user",
|
|
"content": "Run the shell command: echo GOOSE_ACP_MARKER. Use the shell tool.",
|
|
}
|
|
],
|
|
tools=[],
|
|
system_prompt="You are a helpful coding agent.",
|
|
):
|
|
if isinstance(ev, TextChunk):
|
|
chunks.append(ev.text)
|
|
elif isinstance(ev, TurnComplete):
|
|
final = ev
|
|
elif isinstance(ev, ExecutorError):
|
|
pytest.fail(f"executor error: {ev.message}")
|
|
finally:
|
|
await executor.close()
|
|
|
|
# The shell tool call was surfaced to the elicitation handler (web card path).
|
|
assert elicited, "expected session/request_permission -> elicitation"
|
|
names = [name for name, _ in elicited]
|
|
assert any("shell" in n for n in names), f"expected a shell tool, got {names}"
|
|
# On approval the tool ran and its marker reached the streamed transcript.
|
|
assert final is not None
|
|
assert "GOOSE_ACP_MARKER" in ("".join(chunks) + (final.response or ""))
|