Files
agent-desktop/tests/unit/harnesses/test_codex_adapter.py
T

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