825 lines
25 KiB
Python
825 lines
25 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Tests for the Codex third-party agent adapter."""
|
|
|
|
# pylint: disable=protected-access
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from typing import Any
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from pineagents.harnesses.codex.adapter import CodexAdapter
|
|
from pineagents.harnesses.capabilities import (
|
|
HarnessRuntimeCapabilities,
|
|
HarnessSkillDefinition,
|
|
)
|
|
from pineagents.harnesses.events import (
|
|
HarnessAttachment,
|
|
HarnessAttachmentKind,
|
|
HarnessEventKind,
|
|
)
|
|
from pineagents.security.tool_guard.approval import ApprovalDecision
|
|
from pineagents.utils.io_utils import write_json_atomic
|
|
|
|
|
|
class FakeCodexClient:
|
|
"""Small app-server double that preserves request ordering."""
|
|
|
|
installed = True
|
|
account_type = "chatgpt"
|
|
|
|
def __init__(self) -> None:
|
|
self.requests: list[tuple[str, dict[str, Any]]] = []
|
|
self.queue: asyncio.Queue[dict[str, Any]] | None = None
|
|
self.stopped = False
|
|
self.server_request_handler = None
|
|
|
|
def set_server_request_handler(self, handler) -> None:
|
|
"""Store the app-server request callback."""
|
|
self.server_request_handler = handler
|
|
|
|
async def start(self) -> None:
|
|
"""Record no state; the fake is always started."""
|
|
|
|
async def request( # pylint: disable=too-many-return-statements
|
|
self,
|
|
method: str,
|
|
params: dict[str, Any],
|
|
) -> Any:
|
|
self.requests.append((method, params))
|
|
if method == "account/read":
|
|
return {
|
|
"account": {
|
|
"type": self.account_type,
|
|
"email": "person@example.com",
|
|
"planType": "plus",
|
|
"futureCredential": "must-not-leak",
|
|
},
|
|
}
|
|
if method == "account/login/start":
|
|
return {"type": "chatgpt", "authUrl": "https://example.com"}
|
|
if method == "model/list":
|
|
return {
|
|
"data": [
|
|
{
|
|
"id": "catalog-id",
|
|
"model": "gpt-test-codex",
|
|
"displayName": "GPT Test Codex",
|
|
"description": "Test model",
|
|
"isDefault": True,
|
|
"defaultReasoningEffort": "medium",
|
|
"supportedReasoningEfforts": [
|
|
{"reasoningEffort": "low"},
|
|
{"reasoningEffort": "medium"},
|
|
],
|
|
},
|
|
],
|
|
"nextCursor": None,
|
|
}
|
|
if method == "skills/list":
|
|
return {
|
|
"data": [
|
|
{
|
|
"cwd": params["cwds"][0],
|
|
"errors": [],
|
|
"skills": [
|
|
{
|
|
"name": "openai-docs",
|
|
"description": "Read OpenAI documentation",
|
|
"enabled": True,
|
|
"path": "/provider/openai-docs/SKILL.md",
|
|
"scope": "user",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
}
|
|
if method == "thread/start":
|
|
return {"thread": {"id": "thread-1"}}
|
|
if method == "turn/start":
|
|
assert self.queue is not None
|
|
await self.queue.put(
|
|
{
|
|
"method": "item/agentMessage/delta",
|
|
"params": {
|
|
"threadId": "thread-1",
|
|
"turnId": "turn-1",
|
|
"itemId": "item-1",
|
|
"delta": "done",
|
|
},
|
|
},
|
|
)
|
|
await self.queue.put(
|
|
{
|
|
"method": "turn/completed",
|
|
"params": {
|
|
"threadId": "thread-1",
|
|
"turn": {"id": "turn-1", "status": "completed"},
|
|
},
|
|
},
|
|
)
|
|
return {"turn": {"id": "turn-1"}}
|
|
if method == "thread/read":
|
|
return {
|
|
"thread": {
|
|
"turns": [
|
|
{
|
|
"items": [
|
|
{
|
|
"id": "user-1",
|
|
"type": "userMessage",
|
|
"content": [
|
|
{
|
|
"type": "text",
|
|
"text": "Fix it",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
"id": "reason-1",
|
|
"type": "reasoning",
|
|
"summary": ["Checking"],
|
|
"content": ["private details"],
|
|
},
|
|
{
|
|
"id": "answer-1",
|
|
"type": "agentMessage",
|
|
"text": "Done",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
}
|
|
return {}
|
|
|
|
def subscribe(self) -> asyncio.Queue[dict[str, Any]]:
|
|
self.queue = asyncio.Queue()
|
|
return self.queue
|
|
|
|
def unsubscribe(self, queue: asyncio.Queue[dict[str, Any]]) -> None:
|
|
assert queue is self.queue
|
|
self.queue = None
|
|
|
|
async def stop(self) -> None:
|
|
self.stopped = True
|
|
|
|
|
|
class BlockingCodexClient(FakeCodexClient):
|
|
"""Fake a turn that remains active until the stream is cancelled."""
|
|
|
|
def __init__(self) -> None:
|
|
super().__init__()
|
|
self.turn_started = asyncio.Event()
|
|
|
|
async def request(self, method: str, params: dict[str, Any]) -> Any:
|
|
self.requests.append((method, params))
|
|
if method == "thread/start":
|
|
return {"thread": {"id": "thread-1"}}
|
|
if method == "turn/start":
|
|
self.turn_started.set()
|
|
return {"turn": {"id": "turn-1"}}
|
|
return {}
|
|
|
|
|
|
def test_loads_persisted_threads_with_io_utils(tmp_path: Path) -> None:
|
|
write_json_atomic(
|
|
tmp_path / "codex_sessions.json",
|
|
{"chat-1": "thread-1"},
|
|
)
|
|
|
|
adapter = CodexAdapter(
|
|
tmp_path,
|
|
client=FakeCodexClient(), # type: ignore[arg-type]
|
|
)
|
|
|
|
assert adapter._threads == {"chat-1": "thread-1"}
|
|
|
|
|
|
def test_passes_manual_binary_to_app_server(tmp_path: Path) -> None:
|
|
with patch(
|
|
"pineagents.harnesses.codex.adapter.CodexAppServerClient",
|
|
) as client_class:
|
|
CodexAdapter(tmp_path, binary="/custom/bin/codex")
|
|
|
|
client_class.assert_called_once_with(binary="/custom/bin/codex")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_status_and_login_use_app_server(tmp_path: Path) -> None:
|
|
client = FakeCodexClient()
|
|
adapter = CodexAdapter(tmp_path, client=client) # type: ignore[arg-type]
|
|
|
|
status = await adapter.status()
|
|
login = await adapter.start_login()
|
|
|
|
assert status.authenticated is True
|
|
assert status.account is not None
|
|
assert status.account["email"] == "person@example.com"
|
|
assert "futureCredential" not in status.account
|
|
assert login["authUrl"] == "https://example.com"
|
|
assert client.requests[1][0] == "account/login/start"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_api_key_account_is_authenticated(tmp_path: Path) -> None:
|
|
client = FakeCodexClient()
|
|
client.account_type = "apiKey"
|
|
adapter = CodexAdapter(tmp_path, client=client) # type: ignore[arg-type]
|
|
|
|
status = await adapter.status()
|
|
|
|
assert status.authenticated is True
|
|
assert status.account is not None
|
|
assert status.account["type"] == "apiKey"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_models_are_normalized_from_app_server(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
adapter = CodexAdapter(
|
|
tmp_path,
|
|
client=FakeCodexClient(), # type: ignore[arg-type]
|
|
)
|
|
|
|
models = await adapter.models()
|
|
|
|
assert len(models) == 1
|
|
assert models[0].id == "gpt-test-codex"
|
|
assert models[0].reasoning_efforts == ["low", "medium"]
|
|
assert models[0].default_reasoning_effort == "medium"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_discovers_codex_owned_mcp_as_read_only(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
client = FakeCodexClient()
|
|
client.binary_resolution = SimpleNamespace(
|
|
path=tmp_path / "codex",
|
|
)
|
|
adapter = CodexAdapter(tmp_path, client=client) # type: ignore[arg-type]
|
|
process = AsyncMock()
|
|
process.returncode = 0
|
|
process.communicate.return_value = (
|
|
json.dumps(
|
|
[
|
|
{
|
|
"name": "local-docs",
|
|
"enabled": True,
|
|
"transport": {"type": "streamable_http"},
|
|
"auth_status": "authenticated",
|
|
},
|
|
],
|
|
).encode("utf-8"),
|
|
b"",
|
|
)
|
|
|
|
with patch(
|
|
"pineagents.harnesses.codex.adapter.asyncio.create_subprocess_exec",
|
|
return_value=process,
|
|
) as create_process:
|
|
servers = await adapter.discover_mcp(tmp_path)
|
|
|
|
assert servers[0].name == "local-docs"
|
|
assert servers[0].provider_id == "codex"
|
|
assert servers[0].read_only is True
|
|
assert servers[0].scope == "provider"
|
|
create_process.assert_awaited_once_with(
|
|
str(tmp_path / "codex"),
|
|
"mcp",
|
|
"list",
|
|
"--json",
|
|
cwd=str(tmp_path),
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_discovers_codex_owned_skills_as_read_only(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
client = FakeCodexClient()
|
|
adapter = CodexAdapter(tmp_path, client=client) # type: ignore[arg-type]
|
|
|
|
skills = await adapter.discover_skills(tmp_path)
|
|
|
|
assert [skill.name for skill in skills] == ["openai-docs"]
|
|
assert skills[0].provider_id == "codex"
|
|
assert skills[0].source == "user"
|
|
assert skills[0].read_only is True
|
|
assert skills[0].scope == "provider"
|
|
assert (
|
|
"skills/list",
|
|
{"cwds": [str(tmp_path)], "forceReload": False},
|
|
) in client.requests
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_history_prefers_reasoning_summary(tmp_path: Path) -> None:
|
|
adapter = CodexAdapter(
|
|
tmp_path,
|
|
client=FakeCodexClient(), # type: ignore[arg-type]
|
|
)
|
|
adapter._threads["chat-1"] = "thread-1"
|
|
|
|
history = await adapter.history("chat-1")
|
|
|
|
assert [item.kind.value for item in history] == [
|
|
"user",
|
|
"reasoning",
|
|
"message",
|
|
]
|
|
assert history[1].text == "Checking"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_turn_persists_and_reuses_thread(tmp_path: Path) -> None:
|
|
client = FakeCodexClient()
|
|
adapter = CodexAdapter(tmp_path, client=client) # type: ignore[arg-type]
|
|
|
|
events = [
|
|
event
|
|
async for event in adapter.run_turn(
|
|
session_id="chat-1",
|
|
prompt="Fix the test",
|
|
cwd=tmp_path,
|
|
settings={
|
|
"model": "gpt-test-codex",
|
|
"reasoning_effort": "high",
|
|
},
|
|
)
|
|
]
|
|
|
|
assert [event.kind for event in events] == [
|
|
HarnessEventKind.TEXT_DELTA,
|
|
HarnessEventKind.COMPLETED,
|
|
]
|
|
assert events[0].text == "done"
|
|
assert (tmp_path / "codex_sessions.json").is_file()
|
|
assert [method for method, _ in client.requests].count("thread/start") == 1
|
|
thread_params = next(
|
|
params
|
|
for method, params in client.requests
|
|
if method == "thread/start"
|
|
)
|
|
turn_params = next(
|
|
params for method, params in client.requests if method == "turn/start"
|
|
)
|
|
assert thread_params["model"] == "gpt-test-codex"
|
|
assert turn_params["model"] == "gpt-test-codex"
|
|
assert turn_params["effort"] == "high"
|
|
assert turn_params["summary"] == "auto"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_turn_projects_qwenpaw_skill_roots(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
client = FakeCodexClient()
|
|
adapter = CodexAdapter(tmp_path, client=client) # type: ignore[arg-type]
|
|
skill_dir = tmp_path / "skills" / "review"
|
|
|
|
_ = [
|
|
event
|
|
async for event in adapter.run_turn(
|
|
session_id="chat-1",
|
|
prompt="Review it",
|
|
cwd=tmp_path,
|
|
settings={
|
|
"_runtime_capabilities": HarnessRuntimeCapabilities(
|
|
skills=[
|
|
HarnessSkillDefinition(
|
|
name="review",
|
|
directory=skill_dir,
|
|
),
|
|
],
|
|
),
|
|
},
|
|
)
|
|
]
|
|
|
|
assert (
|
|
"skills/extraRoots/set",
|
|
{"extraRoots": [str(skill_dir)]},
|
|
) in client.requests
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_runtime_clients_are_isolated_by_capability_fingerprint(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
base_client = FakeCodexClient()
|
|
first_client = FakeCodexClient()
|
|
second_client = FakeCodexClient()
|
|
with patch(
|
|
"pineagents.harnesses.codex.adapter.CodexAppServerClient",
|
|
side_effect=[base_client, first_client, second_client],
|
|
):
|
|
adapter = CodexAdapter(tmp_path, binary="/custom/codex")
|
|
first = await adapter._prepare_runtime(
|
|
"chat-1",
|
|
{
|
|
"_runtime_capabilities": HarnessRuntimeCapabilities(
|
|
skills=[
|
|
HarnessSkillDefinition(
|
|
name="first",
|
|
directory=tmp_path / "skills" / "first",
|
|
),
|
|
],
|
|
),
|
|
},
|
|
)
|
|
second = await adapter._prepare_runtime(
|
|
"chat-2",
|
|
{
|
|
"_runtime_capabilities": HarnessRuntimeCapabilities(
|
|
skills=[
|
|
HarnessSkillDefinition(
|
|
name="second",
|
|
directory=tmp_path / "skills" / "second",
|
|
),
|
|
],
|
|
),
|
|
},
|
|
)
|
|
|
|
assert first is first_client
|
|
assert second is second_client
|
|
assert first is not second
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_session_switches_runtime_and_resumes_thread(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
base_client = FakeCodexClient()
|
|
first_client = FakeCodexClient()
|
|
second_client = FakeCodexClient()
|
|
with patch(
|
|
"pineagents.harnesses.codex.adapter.CodexAppServerClient",
|
|
side_effect=[base_client, first_client, second_client],
|
|
):
|
|
adapter = CodexAdapter(tmp_path, binary="/custom/codex")
|
|
for revision in ("first", "second"):
|
|
_ = [
|
|
event
|
|
async for event in adapter.run_turn(
|
|
session_id="chat-1",
|
|
prompt="Continue",
|
|
cwd=tmp_path,
|
|
settings={
|
|
"_runtime_capabilities": HarnessRuntimeCapabilities(
|
|
skills=[
|
|
HarnessSkillDefinition(
|
|
name="review",
|
|
directory=tmp_path / "skills",
|
|
revision=revision,
|
|
),
|
|
],
|
|
),
|
|
},
|
|
)
|
|
]
|
|
|
|
assert adapter._session_clients["chat-1"] is second_client
|
|
assert (
|
|
"thread/resume",
|
|
{"threadId": "thread-1"},
|
|
) in second_client.requests
|
|
assert not any(
|
|
method == "thread/start" for method, _ in second_client.requests
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_turn_sends_images_and_files_to_app_server(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
client = FakeCodexClient()
|
|
adapter = CodexAdapter(tmp_path, client=client) # type: ignore[arg-type]
|
|
image_path = tmp_path / "screen.png"
|
|
file_path = tmp_path / "notes.txt"
|
|
|
|
async for _ in adapter.run_turn(
|
|
session_id="chat-1",
|
|
prompt="Inspect both attachments",
|
|
cwd=tmp_path,
|
|
settings={},
|
|
attachments=[
|
|
HarnessAttachment(
|
|
kind=HarnessAttachmentKind.IMAGE,
|
|
path=image_path,
|
|
name="screen.png",
|
|
),
|
|
HarnessAttachment(
|
|
kind=HarnessAttachmentKind.FILE,
|
|
path=file_path,
|
|
name="notes.txt",
|
|
),
|
|
],
|
|
):
|
|
pass
|
|
|
|
turn_params = next(
|
|
params for method, params in client.requests if method == "turn/start"
|
|
)
|
|
assert turn_params["input"] == [
|
|
{"type": "text", "text": "Inspect both attachments"},
|
|
{"type": "localImage", "path": str(image_path)},
|
|
{
|
|
"type": "text",
|
|
"text": f"Attached file notes.txt: {file_path}",
|
|
},
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_turn_applies_provider_approval_controls(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
client = FakeCodexClient()
|
|
adapter = CodexAdapter(tmp_path, client=client) # type: ignore[arg-type]
|
|
|
|
async for _ in adapter.run_turn(
|
|
session_id="chat-1",
|
|
prompt="Fix the test",
|
|
cwd=tmp_path,
|
|
settings={
|
|
"sandbox": "read-only",
|
|
"approval_policy": "on-request",
|
|
},
|
|
):
|
|
pass
|
|
|
|
thread_params = next(
|
|
params
|
|
for method, params in client.requests
|
|
if method == "thread/start"
|
|
)
|
|
turn_params = next(
|
|
params for method, params in client.requests if method == "turn/start"
|
|
)
|
|
assert thread_params["sandbox"] == "read-only"
|
|
assert thread_params["approvalPolicy"] == "on-request"
|
|
assert turn_params["sandboxPolicy"] == {"type": "readOnly"}
|
|
assert turn_params["approvalPolicy"] == "on-request"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_codex_approval_uses_qwenpaw_service(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
adapter = CodexAdapter(
|
|
tmp_path,
|
|
client=FakeCodexClient(), # type: ignore[arg-type]
|
|
)
|
|
adapter._thread_contexts["thread-1"] = {
|
|
"session_id": "chat-1",
|
|
"agent_id": "agent-1",
|
|
"user_id": "user-1",
|
|
"channel": "console",
|
|
}
|
|
pending = MagicMock(request_id="approval-1", timeout_seconds=30)
|
|
service = MagicMock()
|
|
service.create_pending_summary = AsyncMock(return_value=pending)
|
|
service.wait_for_approval = AsyncMock(
|
|
return_value=ApprovalDecision.APPROVED,
|
|
)
|
|
|
|
with patch(
|
|
"pineagents.harnesses.codex.adapter.get_approval_service",
|
|
return_value=service,
|
|
):
|
|
result = await adapter._handle_server_request(
|
|
{
|
|
"method": "item/commandExecution/requestApproval",
|
|
"params": {
|
|
"threadId": "thread-1",
|
|
"itemId": "item-1",
|
|
"command": "pytest -q",
|
|
},
|
|
},
|
|
)
|
|
|
|
assert result == {"decision": "accept"}
|
|
create_call = service.create_pending_summary.await_args.kwargs
|
|
assert create_call["session_id"] == "chat-1"
|
|
assert create_call["agent_id"] == "agent-1"
|
|
assert create_call["summary"].payload["command"] == "pytest -q"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
("decision", "expected_permissions"),
|
|
[
|
|
(
|
|
ApprovalDecision.APPROVED,
|
|
{"network": {"enabled": True}},
|
|
),
|
|
(ApprovalDecision.DENIED, {}),
|
|
],
|
|
)
|
|
async def test_codex_permission_approval_uses_requested_permissions(
|
|
tmp_path: Path,
|
|
decision: ApprovalDecision,
|
|
expected_permissions: dict[str, object],
|
|
) -> None:
|
|
adapter = CodexAdapter(
|
|
tmp_path,
|
|
client=FakeCodexClient(), # type: ignore[arg-type]
|
|
)
|
|
adapter._thread_contexts["thread-1"] = {
|
|
"session_id": "chat-1",
|
|
"agent_id": "agent-1",
|
|
"user_id": "user-1",
|
|
"channel": "console",
|
|
}
|
|
pending = MagicMock(request_id="approval-1", timeout_seconds=30)
|
|
service = MagicMock()
|
|
service.create_pending_summary = AsyncMock(return_value=pending)
|
|
service.wait_for_approval = AsyncMock(return_value=decision)
|
|
permissions = {"network": {"enabled": True}}
|
|
|
|
with patch(
|
|
"pineagents.harnesses.codex.adapter.get_approval_service",
|
|
return_value=service,
|
|
):
|
|
result = await adapter._handle_server_request(
|
|
{
|
|
"method": "item/permissions/requestApproval",
|
|
"params": {
|
|
"threadId": "thread-1",
|
|
"itemId": "item-1",
|
|
"permissions": permissions,
|
|
"reason": "Allow the MCP server to access the network",
|
|
},
|
|
},
|
|
)
|
|
|
|
assert result == {
|
|
"permissions": expected_permissions,
|
|
"scope": "turn",
|
|
}
|
|
create_call = service.create_pending_summary.await_args.kwargs
|
|
assert create_call["summary"].payload["permissions"] == permissions
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancelled_stream_interrupts_active_turn(tmp_path: Path) -> None:
|
|
client = BlockingCodexClient()
|
|
adapter = CodexAdapter(tmp_path, client=client) # type: ignore[arg-type]
|
|
|
|
async def consume() -> None:
|
|
async for _ in adapter.run_turn(
|
|
session_id="chat-1",
|
|
prompt="Keep working",
|
|
cwd=tmp_path,
|
|
settings={},
|
|
):
|
|
pass
|
|
|
|
task = asyncio.create_task(consume())
|
|
await asyncio.wait_for(client.turn_started.wait(), timeout=1)
|
|
task.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await task
|
|
|
|
assert client.requests[-1] == (
|
|
"turn/interrupt",
|
|
{"threadId": "thread-1", "turnId": "turn-1"},
|
|
)
|
|
|
|
|
|
def test_notification_conversion_covers_reasoning_and_tools() -> None:
|
|
reasoning = CodexAdapter._convert_notification(
|
|
{
|
|
"method": "item/reasoning/summaryTextDelta",
|
|
"params": {"itemId": "reason-1", "delta": "Checking"},
|
|
},
|
|
)
|
|
plan = CodexAdapter._convert_notification(
|
|
{
|
|
"method": "item/plan/delta",
|
|
"params": {"itemId": "plan-1", "delta": "Run tests"},
|
|
},
|
|
)
|
|
started = CodexAdapter._convert_notification(
|
|
{
|
|
"method": "item/started",
|
|
"params": {
|
|
"item": {
|
|
"id": "tool-1",
|
|
"type": "commandExecution",
|
|
"command": "pytest -q",
|
|
"cwd": "/repo",
|
|
"status": "inProgress",
|
|
},
|
|
},
|
|
},
|
|
)
|
|
progress = CodexAdapter._convert_notification(
|
|
{
|
|
"method": "item/commandExecution/outputDelta",
|
|
"params": {"itemId": "tool-1", "delta": "1 passed"},
|
|
},
|
|
)
|
|
completed = CodexAdapter._convert_notification(
|
|
{
|
|
"method": "item/completed",
|
|
"params": {
|
|
"item": {
|
|
"id": "tool-1",
|
|
"type": "commandExecution",
|
|
"command": "pytest -q",
|
|
"cwd": "/repo",
|
|
"status": "completed",
|
|
"aggregatedOutput": "1 passed",
|
|
"exitCode": 0,
|
|
},
|
|
},
|
|
},
|
|
)
|
|
|
|
assert reasoning is not None
|
|
assert reasoning.kind == HarnessEventKind.REASONING_DELTA
|
|
assert reasoning.data["source"] == "summary"
|
|
assert plan is not None
|
|
assert plan.data["source"] == "plan"
|
|
assert started is not None
|
|
assert started.kind == HarnessEventKind.TOOL_STARTED
|
|
assert started.tool_name == "shell"
|
|
assert started.data["arguments"]["command"] == "pytest -q"
|
|
assert progress is not None
|
|
assert progress.kind == HarnessEventKind.TOOL_PROGRESS
|
|
assert progress.text == "1 passed"
|
|
assert completed is not None
|
|
assert completed.kind == HarnessEventKind.TOOL_COMPLETED
|
|
assert completed.text == "1 passed"
|
|
assert completed.data["exit_code"] == 0
|
|
|
|
|
|
def test_mcp_tool_uses_server_qualified_name_and_result() -> None:
|
|
event = CodexAdapter._convert_notification(
|
|
{
|
|
"method": "item/completed",
|
|
"params": {
|
|
"item": {
|
|
"id": "mcp-1",
|
|
"type": "mcpToolCall",
|
|
"server": "github",
|
|
"tool": "search",
|
|
"arguments": {"query": "bug"},
|
|
"status": "completed",
|
|
"result": {"content": "found"},
|
|
},
|
|
},
|
|
},
|
|
)
|
|
|
|
assert event is not None
|
|
assert event.tool_name == "github.search"
|
|
assert event.data["arguments"] == {"query": "bug"}
|
|
assert '"content": "found"' in event.text
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("item", "expected"),
|
|
[
|
|
({"type": "commandExecution"}, "shell"),
|
|
({"type": "fileChange"}, "apply_patch"),
|
|
(
|
|
{"type": "mcpToolCall", "server": "git", "tool": "status"},
|
|
"git.status",
|
|
),
|
|
(
|
|
{
|
|
"type": "dynamicToolCall",
|
|
"namespace": "docs",
|
|
"tool": "search",
|
|
},
|
|
"docs.search",
|
|
),
|
|
(
|
|
{"type": "collabAgentToolCall", "tool": "spawnAgent"},
|
|
"agent.spawnAgent",
|
|
),
|
|
({"type": "webSearch"}, "web_search"),
|
|
({"type": "imageView"}, "view_image"),
|
|
({"type": "imageGeneration"}, "image_generation"),
|
|
],
|
|
)
|
|
def test_tool_names_are_provider_neutral(
|
|
item: dict[str, Any],
|
|
expected: str,
|
|
) -> None:
|
|
assert CodexAdapter._tool_name(item) == expected
|