Files
mattzh72--articraft/tests/agent/test_codex_cli_provider.py
wehub-resource-sync 2c632336aa
CI / Viewer CI (push) Successful in 13m37s
CI / Core CI (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:32:38 +08:00

702 lines
22 KiB
Python

from __future__ import annotations
import asyncio
import json
from pathlib import Path
import pytest
from agent.payload_preview import build_provider_payload_preview
from agent.prompts import (
CODEX_CLI_DESIGNER_PROMPT_NAME,
DESIGNER_PROMPT_NAME,
resolve_system_prompt_path,
)
from agent.providers.codex_cli import CodexCliExecResult, CodexCliLLM, _redacted_command
from agent.providers.factory import ProviderConfig, create_provider_client, default_model_id
def test_codex_cli_requires_explicit_model(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("ARTICRAFT_CODEX_MODEL", raising=False)
with pytest.raises(ValueError, match="requires an explicit model"):
default_model_id(ProviderConfig(provider="codex-cli"))
def test_codex_cli_uses_env_model_when_configured(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("ARTICRAFT_CODEX_MODEL", "codex/gpt-5.5")
assert default_model_id(ProviderConfig(provider="codex-cli")) == "codex/gpt-5.5"
def test_codex_cli_prompt_resolution_and_payload_preview() -> None:
repo_root = Path(__file__).resolve().parents[2]
codex_cli_resolved = resolve_system_prompt_path(
DESIGNER_PROMPT_NAME,
provider="codex-cli",
repo_root=repo_root,
)
assert codex_cli_resolved.name == CODEX_CLI_DESIGNER_PROMPT_NAME
payload = build_provider_payload_preview(
"a pair of scissors",
provider="codex-cli",
model_id="codex/gpt-5.5",
thinking_level="high",
system_prompt_path=DESIGNER_PROMPT_NAME,
)
assert payload["transport"] == "codex-cli"
assert "Codex CLI behind Articraft's internal harness" in payload["prompt"]
assert (
"Available tools: `read_file`, `apply_patch`, `replace`, `write_file`, `compile_model`, `probe_model`, and `find_examples`."
in payload["prompt"]
)
def test_codex_cli_request_preview_describes_subprocess_transport() -> None:
provider = CodexCliLLM(dry_run=True)
preview = provider.build_request_preview(
system_prompt="system",
messages=[
{
"role": "user",
"content": [
{"type": "input_text", "text": "make a hinge"},
{"type": "input_image", "image_path": "/tmp/reference.png"},
],
}
],
tools=[
{
"type": "function",
"function": {
"name": "compile_model",
"description": "Compile current model",
"parameters": {"type": "object", "properties": {}},
},
}
],
)
assert preview["transport"] == "codex-cli"
assert preview["model"] == "codex-cli-default"
assert preview["command"][:2] == ["codex", "exec"]
assert "--ask-for-approval" not in preview["command"]
assert "--image" in preview["command"]
assert "/tmp/reference.png" in preview["command"]
assert "--output-schema" in preview["command"]
assert '"name": "compile_model"' in preview["prompt"]
assert "JSON-encoded object string" in preview["prompt"]
assert "make a hinge" in preview["prompt"]
def test_codex_cli_redacts_local_paths_from_persisted_command_metadata() -> None:
assert _redacted_command(
[
"codex",
"exec",
"--output-schema",
"/var/folders/private/schema.json",
"--image",
"/Users/example/private/reference.png",
"--output-last-message",
"/var/folders/private/assistant.json",
"-",
]
) == [
"codex",
"exec",
"--output-schema",
"<path>",
"--image",
"<path>",
"--output-last-message",
"<path>",
"-",
]
def test_codex_cli_generate_converts_schema_payload_to_tool_calls() -> None:
captured: dict[str, object] = {}
async def fake_runner(
command: list[str],
prompt: str,
timeout_seconds: float,
output_path: Path,
) -> CodexCliExecResult:
captured["command"] = command
captured["prompt"] = prompt
captured["timeout_seconds"] = timeout_seconds
captured["output_path"] = output_path
return CodexCliExecResult(
returncode=0,
stdout=" Tokens used: 1,234\n",
stderr="",
last_message=json.dumps(
{
"content": "",
"thought_summary": "Need to inspect current model.",
"tool_calls": [
{
"name": "read_file",
"arguments": json.dumps({"path": "model.py", "offset": 1}),
}
],
}
),
)
provider = CodexCliLLM(
model_id="gpt-5.5",
thinking_level="med",
runner=fake_runner,
)
response = asyncio.run(
provider.generate_with_tools(
system_prompt="system",
messages=[{"role": "user", "content": "make a hinge"}],
tools=[
{
"type": "function",
"function": {
"name": "read_file",
"description": "Read a file",
"parameters": {"type": "object", "properties": {}},
},
}
],
)
)
assert captured["command"][:2] == ["codex", "exec"]
assert "--ask-for-approval" not in captured["command"]
assert "--model" in captured["command"]
assert "gpt-5.5" in captured["command"]
assert 'model_reasoning_effort="medium"' in captured["command"]
assert "Do not edit files, run shell commands" in str(captured["prompt"])
assert response["thought_summary"] == "Need to inspect current model."
assert response["usage"] == {"total_tokens": 1234}
assert response["tool_calls"][0]["function"]["name"] == "read_file"
assert json.loads(response["tool_calls"][0]["function"]["arguments"]) == {
"path": "model.py",
"offset": 1,
}
assert response["extra_content"]["codex_cli"]["raw_response"]["tool_calls"]
def test_codex_cli_output_schema_enumerates_available_tool_names() -> None:
captured: dict[str, object] = {}
async def fake_runner(
command: list[str],
prompt: str,
timeout_seconds: float,
output_path: Path,
) -> CodexCliExecResult:
schema_path = Path(command[command.index("--output-schema") + 1])
captured["schema"] = json.loads(schema_path.read_text(encoding="utf-8"))
return CodexCliExecResult(
returncode=0,
stdout="",
stderr="",
last_message=json.dumps(
{
"content": "",
"thought_summary": "",
"tool_calls": [
{
"name": "compile_model",
"arguments": "{}",
}
],
}
),
)
provider = CodexCliLLM(model_id="codex/gpt-5.5", runner=fake_runner)
asyncio.run(
provider.generate_with_tools(
system_prompt="system",
messages=[{"role": "user", "content": "make a hinge"}],
tools=[
{
"type": "function",
"function": {
"name": "compile_model",
"description": "Compile current model",
"parameters": {"type": "object", "properties": {}},
},
}
],
)
)
name_schema = captured["schema"]["properties"]["tool_calls"]["items"]["properties"]["name"]
arguments_schema = captured["schema"]["properties"]["tool_calls"]["items"]["properties"][
"arguments"
]
assert name_schema["enum"] == ["compile_model"]
assert arguments_schema["type"] == "string"
def test_codex_cli_rejects_invalid_tool_call_schema() -> None:
async def fake_runner(
command: list[str],
prompt: str,
timeout_seconds: float,
output_path: Path,
) -> CodexCliExecResult:
return CodexCliExecResult(
returncode=0,
stdout="",
stderr="",
last_message=json.dumps(
{
"content": "",
"thought_summary": "",
"tool_calls": [
{
"name": "compile_model",
"arguments": "{not json}",
}
],
}
),
)
provider = CodexCliLLM(model_id="codex/gpt-5.5", runner=fake_runner)
with pytest.raises(RuntimeError, match=r"tool_call\[1\] field 'arguments' must be valid JSON"):
asyncio.run(
provider.generate_with_tools(
system_prompt="system",
messages=[{"role": "user", "content": "make a hinge"}],
tools=[
{
"type": "function",
"function": {
"name": "compile_model",
"description": "Compile current model",
"parameters": {"type": "object", "properties": {}},
},
}
],
)
)
def test_codex_cli_rejects_non_object_tool_call() -> None:
async def fake_runner(
command: list[str],
prompt: str,
timeout_seconds: float,
output_path: Path,
) -> CodexCliExecResult:
return CodexCliExecResult(
returncode=0,
stdout="",
stderr="",
last_message=json.dumps(
{
"content": "",
"thought_summary": "",
"tool_calls": ["bad"],
}
),
)
provider = CodexCliLLM(model_id="codex/gpt-5.5", runner=fake_runner)
with pytest.raises(RuntimeError, match=r"tool_call\[1\] must be a JSON object"):
asyncio.run(
provider.generate_with_tools(
system_prompt="system",
messages=[{"role": "user", "content": "make a hinge"}],
tools=[
{
"type": "function",
"function": {
"name": "compile_model",
"description": "Compile current model",
"parameters": {"type": "object", "properties": {}},
},
}
],
)
)
def test_codex_cli_generate_reuses_seen_images_on_later_turns() -> None:
commands: list[list[str]] = []
async def fake_runner(
command: list[str],
prompt: str,
timeout_seconds: float,
output_path: Path,
) -> CodexCliExecResult:
commands.append(command)
return CodexCliExecResult(
returncode=0,
stdout="",
stderr="",
last_message=json.dumps(
{
"content": "Done.",
"thought_summary": "",
"tool_calls": [],
}
),
)
provider = CodexCliLLM(runner=fake_runner)
tools: list[dict[str, object]] = []
asyncio.run(
provider.generate_with_tools(
system_prompt="system",
messages=[
{
"role": "user",
"content": [
{"type": "input_text", "text": "make a hinge"},
{"type": "input_image", "image_path": "/tmp/reference.png"},
],
}
],
tools=tools,
)
)
asyncio.run(
provider.generate_with_tools(
system_prompt="system",
messages=[{"role": "user", "content": "continue"}],
tools=tools,
)
)
assert commands[0].count("--image") == 1
assert commands[1].count("--image") == 1
assert "/tmp/reference.png" in commands[1]
def test_codex_cli_prepare_next_request_reuses_seen_images_in_trace_metadata() -> None:
provider = CodexCliLLM(dry_run=True)
asyncio.run(
provider.prepare_next_request(
system_prompt="system",
messages=[
{
"role": "user",
"content": [
{"type": "input_text", "text": "make a hinge"},
{"type": "input_image", "image_path": "/tmp/reference.png"},
],
}
],
tools=[],
completed_turns=0,
)
)
result = asyncio.run(
provider.prepare_next_request(
system_prompt="system",
messages=[{"role": "user", "content": "continue"}],
tools=[],
completed_turns=1,
)
)
assert result.trace_events[0].payload["image_paths"] == ["/tmp/reference.png"]
def test_codex_cli_prepare_next_request_emits_trace_metadata() -> None:
provider = CodexCliLLM(dry_run=True)
result = asyncio.run(
provider.prepare_next_request(
system_prompt="system",
messages=[{"role": "user", "content": "make a hinge"}],
tools=[
{
"type": "function",
"function": {
"name": "compile_model",
"description": "Compile current model",
"parameters": {"type": "object", "properties": {}},
},
}
],
completed_turns=3,
consecutive_compile_failure_count=1,
last_compile_failure_sig="abc123",
)
)
event = result.trace_events[0]
assert event.event_type == "codex_cli_request"
assert event.payload["provider"] == "codex-cli"
assert event.payload["completed_turns"] == 3
assert event.payload["tool_names"] == ["compile_model"]
assert event.payload["prompt_chars"] > 0
assert len(event.payload["prompt_sha256"]) == 64
assert event.payload["consecutive_compile_failure_count"] == 1
assert event.payload["last_compile_failure_sig"] == "abc123"
def test_codex_cli_compacts_old_history_before_later_request() -> None:
prompts: list[str] = []
async def fake_runner(
command: list[str],
prompt: str,
timeout_seconds: float,
output_path: Path,
) -> CodexCliExecResult:
prompts.append(prompt)
if "Summarize Articraft Codex CLI harness history" in prompt:
return CodexCliExecResult(
returncode=0,
stdout="tokens used\n321\n",
stderr="",
last_message=json.dumps(
{
"summary": (
"The object is a folding chair. Earlier attempts fixed the hinge axis; "
"continue from the current model.py and compile before finishing."
)
}
),
)
return CodexCliExecResult(
returncode=0,
stdout="",
stderr="",
last_message=json.dumps(
{
"content": "Done.",
"thought_summary": "",
"tool_calls": [],
}
),
)
provider = CodexCliLLM(runner=fake_runner)
provider.compaction_threshold_chars = 100
provider.compaction_tail_messages = 2
messages = [
{"role": "user", "content": "SDK docs"},
{"role": "user", "content": "make a folding chair"},
*[
{
"role": "tool",
"tool_call_id": f"call_{index}",
"name": "compile_model",
"content": f"old compile failure {index}: hinge axis wrong",
}
for index in range(8)
],
]
prepare_result = asyncio.run(
provider.prepare_next_request(
system_prompt="system",
messages=messages,
tools=[],
completed_turns=5,
)
)
response = asyncio.run(
provider.generate_with_tools(
system_prompt="system",
messages=messages,
tools=[],
)
)
compaction_events = [
event for event in prepare_result.trace_events if event.event_type == "codex_cli_compaction"
]
assert compaction_events
assert compaction_events[0].payload["usage"] == {"total_tokens": 321}
assert response["content"] == "Done."
assert len(prompts) == 2
assert "old compile failure 0" in prompts[0]
assert "<codex_cli_compacted_history>" in prompts[1]
assert "Earlier attempts fixed the hinge axis" in prompts[1]
assert "old compile failure 0" not in prompts[1]
assert "old compile failure 7" in prompts[1]
def test_codex_cli_generate_surfaces_subprocess_failure() -> None:
async def fake_runner(
command: list[str],
prompt: str,
timeout_seconds: float,
output_path: Path,
) -> CodexCliExecResult:
return CodexCliExecResult(
returncode=2,
stdout="",
stderr="not logged in",
last_message="",
)
provider = CodexCliLLM(runner=fake_runner)
with pytest.raises(RuntimeError, match="not logged in"):
asyncio.run(
provider.generate_with_tools(
system_prompt="system",
messages=[{"role": "user", "content": "make a hinge"}],
tools=[],
)
)
def test_codex_cli_generate_rejects_invalid_last_message_json() -> None:
async def fake_runner(
command: list[str],
prompt: str,
timeout_seconds: float,
output_path: Path,
) -> CodexCliExecResult:
return CodexCliExecResult(
returncode=0,
stdout="",
stderr="",
last_message="not json",
)
provider = CodexCliLLM(runner=fake_runner)
with pytest.raises(RuntimeError, match="not valid JSON"):
asyncio.run(
provider.generate_with_tools(
system_prompt="system",
messages=[{"role": "user", "content": "make a hinge"}],
tools=[],
)
)
def test_codex_cli_generate_rejects_missing_required_response_field() -> None:
async def fake_runner(
command: list[str],
prompt: str,
timeout_seconds: float,
output_path: Path,
) -> CodexCliExecResult:
return CodexCliExecResult(
returncode=0,
stdout="",
stderr="",
last_message=json.dumps({"content": "", "tool_calls": []}),
)
provider = CodexCliLLM(runner=fake_runner)
with pytest.raises(RuntimeError, match="missing required field\\(s\\): thought_summary"):
asyncio.run(
provider.generate_with_tools(
system_prompt="system",
messages=[{"role": "user", "content": "make a hinge"}],
tools=[],
)
)
def test_codex_cli_generate_rejects_non_list_tool_calls() -> None:
async def fake_runner(
command: list[str],
prompt: str,
timeout_seconds: float,
output_path: Path,
) -> CodexCliExecResult:
return CodexCliExecResult(
returncode=0,
stdout="",
stderr="",
last_message=json.dumps(
{
"content": "",
"thought_summary": "",
"tool_calls": {"name": "read_file", "arguments": "{}"},
}
),
)
provider = CodexCliLLM(runner=fake_runner)
with pytest.raises(RuntimeError, match="'tool_calls' must be a list"):
asyncio.run(
provider.generate_with_tools(
system_prompt="system",
messages=[{"role": "user", "content": "make a hinge"}],
tools=[],
)
)
def test_codex_cli_generate_passes_tool_call_payloads_like_openai_codec() -> None:
async def fake_runner(
command: list[str],
prompt: str,
timeout_seconds: float,
output_path: Path,
) -> CodexCliExecResult:
return CodexCliExecResult(
returncode=0,
stdout="",
stderr="",
last_message=json.dumps(
{
"content": "",
"thought_summary": "",
"tool_calls": [
{
"name": "delete_everything",
"arguments": "{not json",
"extra": "preserved in raw response only",
}
],
}
),
)
provider = CodexCliLLM(runner=fake_runner)
with pytest.raises(RuntimeError, match=r"tool_call\[1\] field 'arguments' must be valid JSON"):
asyncio.run(
provider.generate_with_tools(
system_prompt="system",
messages=[{"role": "user", "content": "make a hinge"}],
tools=[
{
"type": "function",
"function": {
"name": "read_file",
"description": "Read a file",
"parameters": {"type": "object", "properties": {}},
},
}
],
)
)
def test_factory_creates_codex_cli_provider() -> None:
provider = create_provider_client(
ProviderConfig(provider="codex-cli", model_id="codex/gpt-5.5"),
dry_run=True,
)
assert isinstance(provider, CodexCliLLM)