"""Tests for the generic ACP executor (:mod:`omnigent.inner.acp_executor`). Two layers: * **Unit** — construction/argv, both ``session/new`` shapes, tool-call → event mapping, permission-outcome mapping, interrupt via ``session/cancel``, and the harness-wrap env parsing — all with a mocked transport. * **Hermetic e2e** — a tiny fake ACP agent (a Python script speaking ACP over stdio, written to a temp file) that the executor spawns for real and drives through a full turn: initialize → session/new → session/prompt → streaming (thought + text + tool card) → request_permission → completion. No real vendor binary is required. """ from __future__ import annotations import asyncio import shlex import sys from pathlib import Path from unittest.mock import AsyncMock import pytest from omnigent.inner._acp_omnigent_mcp import OmnigentAcpMcp, _to_acp_mcp_servers from omnigent.inner.acp_executor import AcpAgentConfig, AcpExecutor from omnigent.inner.executor import ( ReasoningChunk, TextChunk, ToolCallComplete, ToolCallRequest, ToolCallStatus, TurnComplete, ) # --------------------------------------------------------------------------- # Construction / argv # --------------------------------------------------------------------------- def test_command_is_shlex_split_into_argv() -> None: ex = AcpExecutor(AcpAgentConfig(command="gemini --experimental-acp --model gemini-2.5-pro")) assert ex._argv == ["gemini", "--experimental-acp", "--model", "gemini-2.5-pro"] def test_quoted_command_argv() -> None: ex = AcpExecutor(AcpAgentConfig(command='npx -y "@zed-industries/claude-code-acp"')) assert ex._argv == ["npx", "-y", "@zed-industries/claude-code-acp"] def test_empty_command_rejected() -> None: with pytest.raises(ValueError): AcpExecutor(AcpAgentConfig(command=" ")) def test_handles_tools_internally_and_streaming() -> None: ex = AcpExecutor(AcpAgentConfig(command="x")) assert ex.handles_tools_internally() is True assert ex.supports_streaming() is True # --------------------------------------------------------------------------- # session/new shapes (server- vs client-assigned id, optional model) # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_session_new_server_mode_adopts_returned_id() -> None: ex = AcpExecutor(AcpAgentConfig(command="x", session_id_mode="server")) captured: dict = {} async def fake_rpc(method, params, timeout=30.0): captured["method"] = method captured["params"] = params return {"result": {"sessionId": "srv-42"}} ex._rpc = fake_rpc # type: ignore[assignment] sid = await ex._ensure_session() assert sid == "srv-42" assert "sessionId" not in captured["params"] # server assigns it assert captured["params"]["cwd"] == ex._cwd assert captured["params"]["mcpServers"] == [] @pytest.mark.asyncio async def test_session_new_client_mode_generates_and_sends_id() -> None: ex = AcpExecutor( AcpAgentConfig( command="x", session_id_mode="client", model="m1", send_model_in_session_new=True ) ) sent: dict = {} async def capture_rpc(method, params, timeout=30.0): sent.update(params) return {"result": {}} ex._rpc = capture_rpc # type: ignore[assignment] sid = await ex._ensure_session() assert sid == sent["sessionId"] and sid # our generated id is used assert sent["model"] == "m1" # model sent because send_model_in_session_new # --------------------------------------------------------------------------- # Tool-call extraction + permission outcome # --------------------------------------------------------------------------- def test_extract_tool_call_prefers_title() -> None: name, args = AcpExecutor._extract_tool_call( {"toolCall": {"title": "shell", "kind": "execute", "rawInput": {"command": "ls"}}} ) assert name == "shell" assert args == {"command": "ls"} def test_extract_tool_call_falls_back_to_kind() -> None: name, args = AcpExecutor._extract_tool_call({"toolCall": {"kind": "read"}}) assert name == "read" assert args == {} def test_permission_outcome_allow_prefers_once() -> None: params = { "options": [ {"optionId": "a1", "kind": "allow_always"}, {"optionId": "a2", "kind": "allow_once"}, {"optionId": "r1", "kind": "reject_once"}, ] } out = AcpExecutor._permission_outcome(params, allow=True) assert out == {"outcome": {"outcome": "selected", "optionId": "a2"}} def test_permission_outcome_deny_picks_reject() -> None: params = {"options": [{"optionId": "r", "kind": "reject_once"}]} out = AcpExecutor._permission_outcome(params, allow=False) assert out == {"outcome": {"outcome": "selected", "optionId": "r"}} def test_permission_outcome_cancelled_when_no_option() -> None: out = AcpExecutor._permission_outcome({"options": []}, allow=True) assert out == {"outcome": {"outcome": "cancelled"}} # --------------------------------------------------------------------------- # session/update → ExecutorEvent mapping # --------------------------------------------------------------------------- def test_update_agent_message_chunk_to_text() -> None: ex = AcpExecutor(AcpAgentConfig(command="x")) events = ex._handle_session_update( {"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": "hi"}} ) assert len(events) == 1 and isinstance(events[0], TextChunk) and events[0].text == "hi" def test_update_thought_chunk_to_reasoning() -> None: ex = AcpExecutor(AcpAgentConfig(command="x")) events = ex._handle_session_update( {"sessionUpdate": "agent_thought_chunk", "content": {"type": "text", "text": "hmm"}} ) assert len(events) == 1 and isinstance(events[0], ReasoningChunk) and events[0].delta == "hmm" def test_tool_call_and_update_emit_cards() -> None: ex = AcpExecutor(AcpAgentConfig(command="x")) started = ex._handle_session_update( { "sessionUpdate": "tool_call", "toolCallId": "c1", "title": "shell", "rawInput": {"command": "ls"}, } ) assert len(started) == 1 req = started[0] assert isinstance(req, ToolCallRequest) assert req.name == "shell" and req.metadata == {"call_id": "c1"} assert ex._tool_names["c1"] == "shell" done = ex._handle_session_update( {"sessionUpdate": "tool_call_update", "toolCallId": "c1", "status": "completed"} ) assert len(done) == 1 comp = done[0] assert isinstance(comp, ToolCallComplete) assert comp.name == "shell" and comp.status is ToolCallStatus.SUCCESS assert comp.metadata == {"call_id": "c1"} assert "c1" not in ex._tool_names # popped def test_tool_call_update_failed_maps_to_error() -> None: ex = AcpExecutor(AcpAgentConfig(command="x")) ex._handle_session_update({"sessionUpdate": "tool_call", "toolCallId": "c2", "title": "t"}) done = ex._handle_session_update( {"sessionUpdate": "tool_call_update", "toolCallId": "c2", "status": "failed"} ) assert done[0].status is ToolCallStatus.ERROR def test_usage_update_sets_context_window() -> None: ex = AcpExecutor(AcpAgentConfig(command="x")) assert ex.max_context_tokens() is None ex._handle_session_update({"sessionUpdate": "usage_update", "size": 200000}) assert ex.max_context_tokens() == 200000 def test_in_progress_tool_update_emits_nothing() -> None: ex = AcpExecutor(AcpAgentConfig(command="x")) ex._handle_session_update({"sessionUpdate": "tool_call", "toolCallId": "c3", "title": "t"}) assert ( ex._handle_session_update( {"sessionUpdate": "tool_call_update", "toolCallId": "c3", "status": "in_progress"} ) == [] ) # --------------------------------------------------------------------------- # Permission decision (policy + elicitation gates) # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_decide_permission_allows_with_no_gates() -> None: ex = AcpExecutor(AcpAgentConfig(command="x")) assert await ex._decide_permission({"toolCall": {"title": "shell"}}) is True @pytest.mark.asyncio async def test_decide_permission_denies_on_policy_deny() -> None: ex = AcpExecutor(AcpAgentConfig(command="x")) class _V: action = "POLICY_ACTION_DENY" ex._policy_evaluator = AsyncMock(return_value=_V()) assert await ex._decide_permission({"toolCall": {"title": "shell"}}) is False @pytest.mark.asyncio async def test_decide_permission_ask_defers_to_elicitation() -> None: ex = AcpExecutor(AcpAgentConfig(command="x")) class _V: action = "POLICY_ACTION_ASK" ex._policy_evaluator = AsyncMock(return_value=_V()) ex._elicitation_handler = AsyncMock(return_value=True) assert await ex._decide_permission({"toolCall": {"title": "shell"}}) is True ex._elicitation_handler.assert_awaited_once() @pytest.mark.asyncio async def test_decide_permission_ask_without_handler_fails_closed() -> None: ex = AcpExecutor(AcpAgentConfig(command="x")) class _V: action = "POLICY_ACTION_ASK" ex._policy_evaluator = AsyncMock(return_value=_V()) assert await ex._decide_permission({"toolCall": {"title": "shell"}}) is False # --------------------------------------------------------------------------- # interrupt → session/cancel # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_interrupt_sends_session_cancel() -> None: ex = AcpExecutor(AcpAgentConfig(command="x")) ex._session_id = "s1" ex._proc = type("P", (), {"returncode": None})() # type: ignore[assignment] sent: list[dict] = [] ex._send = AsyncMock(side_effect=lambda m: sent.append(m)) # type: ignore[method-assign] assert await ex.interrupt_session("ignored") is True assert sent == [{"jsonrpc": "2.0", "method": "session/cancel", "params": {"sessionId": "s1"}}] @pytest.mark.asyncio async def test_interrupt_noop_without_session() -> None: ex = AcpExecutor(AcpAgentConfig(command="x")) assert await ex.interrupt_session("ignored") is False # --------------------------------------------------------------------------- # Harness wrap env parsing # --------------------------------------------------------------------------- def test_harness_wrap_requires_command(monkeypatch: pytest.MonkeyPatch) -> None: from omnigent.inner import acp_harness monkeypatch.delenv("HARNESS_ACP_COMMAND", raising=False) with pytest.raises(RuntimeError, match="HARNESS_ACP_COMMAND"): acp_harness._build_acp_executor() def test_harness_wrap_builds_executor(monkeypatch: pytest.MonkeyPatch) -> None: from omnigent.inner import acp_harness monkeypatch.setenv("HARNESS_ACP_COMMAND", "goose acp") monkeypatch.setenv("HARNESS_ACP_NAME", "Goose") monkeypatch.setenv("HARNESS_ACP_SESSION_ID_MODE", "client") monkeypatch.setenv("HARNESS_ACP_SEND_MODEL", "1") monkeypatch.setenv("HARNESS_ACP_MODEL", "gpt-5.3") ex = acp_harness._build_acp_executor() assert isinstance(ex, AcpExecutor) assert ex._config.command == "goose acp" assert ex._config.name == "Goose" assert ex._config.session_id_mode == "client" assert ex._config.send_model_in_session_new is True assert ex._config.model == "gpt-5.3" # --------------------------------------------------------------------------- # Hermetic end-to-end: drive a real fake ACP agent over stdio # --------------------------------------------------------------------------- # A minimal ACP agent: JSON-RPC 2.0 over newline-delimited stdio. It answers the # handshake, then on session/prompt streams a thought + text + a tool card, asks # the client for permission, and — once the client answers — finishes the tool # card, streams closing text, and returns the prompt with a stop reason + usage. _FAKE_ACP_AGENT = r""" import sys, json def send(obj): sys.stdout.write(json.dumps(obj) + "\n") sys.stdout.flush() def update(sid, upd): send({"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": sid, "update": upd}}) def chunk(sid, kind, text): update(sid, {"sessionUpdate": kind, "content": {"type": "text", "text": text}}) pending_prompt_id = None pending_sid = None for line in sys.stdin: line = line.strip() if not line: continue msg = json.loads(line) mid, method = msg.get("id"), msg.get("method") if method == "initialize": send({"jsonrpc": "2.0", "id": mid, "result": { "protocolVersion": 1, "agentCapabilities": {"promptCapabilities": {"image": False}}, }}) elif method == "session/new": send({"jsonrpc": "2.0", "id": mid, "result": {"sessionId": "fake-session-1"}}) elif method == "session/prompt": sid = msg["params"]["sessionId"] chunk(sid, "agent_thought_chunk", "planning") chunk(sid, "agent_message_chunk", "Hello ") update(sid, {"sessionUpdate": "tool_call", "toolCallId": "t1", "title": "shell", "kind": "execute", "status": "pending", "rawInput": {"command": "echo hi"}}) send({"jsonrpc": "2.0", "id": 900, "method": "session/request_permission", "params": { "sessionId": sid, "toolCall": {"title": "shell", "kind": "execute", "rawInput": {"command": "echo hi"}}, "options": [{"optionId": "ok", "kind": "allow_once"}, {"optionId": "no", "kind": "reject_once"}], }}) pending_prompt_id, pending_sid = mid, sid elif mid == 900 and method is None: # The client's permission reply — finish the turn. update(pending_sid, {"sessionUpdate": "tool_call_update", "toolCallId": "t1", "status": "completed"}) chunk(pending_sid, "agent_message_chunk", "done") send({"jsonrpc": "2.0", "id": pending_prompt_id, "result": { "stopReason": "end_turn", "usage": {"inputTokens": 10, "outputTokens": 5, "totalTokens": 15}, }}) """ @pytest.mark.asyncio async def test_end_to_end_against_fake_acp_agent(tmp_path: Path) -> None: agent_path = tmp_path / "fake_acp_agent.py" agent_path.write_text(_FAKE_ACP_AGENT) command = shlex.join([sys.executable, str(agent_path)]) ex = AcpExecutor(AcpAgentConfig(command=command, name="Fake")) approvals: list[tuple[str, dict]] = [] async def elicit(tool_name: str, tool_input: dict) -> bool: approvals.append((tool_name, tool_input)) return True ex._elicitation_handler = elicit # type: ignore[assignment] events = [] try: async for ev in ex.run_turn([{"role": "user", "content": "hi"}], [], "you are a bot"): events.append(ev) finally: await ex.close() # The permission request surfaced through the elicitation handler. assert approvals == [("shell", {"command": "echo hi"})] kinds = [type(e).__name__ for e in events] assert "ReasoningChunk" in kinds assert "ToolCallRequest" in kinds assert "ToolCallComplete" in kinds text = "".join(e.text for e in events if isinstance(e, TextChunk)) assert text == "Hello done" completions = [e for e in events if isinstance(e, TurnComplete)] assert len(completions) == 1 assert completions[0].usage == {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15} tool_reqs = [e for e in events if isinstance(e, ToolCallRequest)] assert tool_reqs[0].name == "shell" and tool_reqs[0].args == {"command": "echo hi"} tool_done = [e for e in events if isinstance(e, ToolCallComplete)] assert tool_done[0].status is ToolCallStatus.SUCCESS # --------------------------------------------------------------------------- # Omnigent MCP bridge (session/new.mcpServers via the shared serve-mcp relay) # --------------------------------------------------------------------------- def test_mcp_to_acp_servers_flattens_env_to_array() -> None: """ACP wants env as [{name,value}], not a dict; command/args pass through.""" out = _to_acp_mcp_servers( {"mcpServers": {"omnigent": {"command": "/py", "args": ["-Im", "x"], "env": {"A": "1"}}}} ) assert out == [ { "name": "omnigent", "command": "/py", "args": ["-Im", "x"], "env": [{"name": "A", "value": "1"}], } ] def test_mcp_disabled_returns_empty() -> None: m = OmnigentAcpMcp("t") assert ( m.session_new_servers( tools=[{"name": "x"}], tool_executor=lambda *a: None, loop=None, enabled=False ) == [] ) def test_mcp_no_executor_returns_empty() -> None: m = OmnigentAcpMcp("t") assert m.session_new_servers(tools=[{"name": "x"}], tool_executor=None, loop=None) == [] def test_mcp_kill_switch(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("OMNIGENT_ACP_MCP", "0") m = OmnigentAcpMcp("t") assert ( m.session_new_servers(tools=[{"name": "x"}], tool_executor=lambda *a: None, loop=None) == [] ) @pytest.mark.asyncio async def test_mcp_relay_starts_and_builds_serve_mcp_entry() -> None: """A real relay boots (writes bridge.json + tool_relay.json + HTTP server) and yields one ACP stdio server pointing at the shared serve-mcp.""" m = OmnigentAcpMcp("t") async def fake_exec(name: str, args: dict) -> dict: return {"ok": True} loop = asyncio.get_event_loop() servers = m.session_new_servers( tools=[{"name": "sys_agent_list", "parameters": {"type": "object", "properties": {}}}], tool_executor=fake_exec, loop=loop, ) try: assert len(servers) == 1 entry = servers[0] assert entry["name"] == "omnigent" assert "serve-mcp" in entry["args"] assert "omnigent.claude_native_bridge" in entry["args"] assert all("name" in e and "value" in e for e in entry["env"]) # Idempotent: a second call returns the cached relay, not a new one. assert m.session_new_servers(tools=[], tool_executor=fake_exec, loop=loop) is servers finally: m.close() @pytest.mark.asyncio async def test_acp_session_new_carries_mcp_servers() -> None: """When a tool executor + tools are present, session/new carries mcpServers.""" ex = AcpExecutor(AcpAgentConfig(command="x")) ex._tool_executor = lambda n, a: None # type: ignore[assignment] ex._omnigent_tools = [{"name": "sys_agent_list"}] sentinel = [{"name": "omnigent", "command": "/py", "args": [], "env": []}] ex._mcp.session_new_servers = lambda **kw: sentinel # type: ignore[method-assign] captured: dict = {} async def fake_rpc(method, params, timeout=30.0): captured["params"] = params return {"result": {"sessionId": "s1"}} ex._rpc = fake_rpc # type: ignore[assignment] await ex._ensure_session() assert captured["params"]["mcpServers"] is sentinel @pytest.mark.asyncio async def test_acp_session_new_omnigent_mcp_disabled_per_agent() -> None: """`omnigent_mcp=False` on the agent config → no mcpServers in session/new.""" ex = AcpExecutor(AcpAgentConfig(command="x", omnigent_mcp=False)) ex._tool_executor = lambda n, a: None # type: ignore[assignment] ex._omnigent_tools = [{"name": "sys_agent_list"}] captured: dict = {} async def fake_rpc(method, params, timeout=30.0): captured["params"] = params return {"result": {"sessionId": "s1"}} ex._rpc = fake_rpc # type: ignore[assignment] await ex._ensure_session() assert captured["params"]["mcpServers"] == [] @pytest.mark.asyncio async def test_end_to_end_denied_permission(tmp_path: Path) -> None: """A denied elicitation still completes the turn (the agent gets a reject).""" agent_path = tmp_path / "fake_acp_agent.py" agent_path.write_text(_FAKE_ACP_AGENT) command = shlex.join([sys.executable, str(agent_path)]) ex = AcpExecutor(AcpAgentConfig(command=command, name="Fake")) async def deny(tool_name: str, tool_input: dict) -> bool: return False ex._elicitation_handler = deny # type: ignore[assignment] events = [] try: async for ev in ex.run_turn([{"role": "user", "content": "hi"}], [], ""): events.append(ev) finally: await ex.close() # Turn still completes even though the tool was rejected. assert any(isinstance(e, TurnComplete) for e in events)