iop/scripts/agent_benchmark/claude_iop_test.py
toki de4d8f4ff8 feat(benchmark): IOP Agent 연결 경로를 추가한다
세 Agent의 direct route를 동일한 fail-closed preflight와 격리 실행 경계에서 비교하고, 관측되지 않은 preset 셀이 실행되는 것을 막기 위해 연결 계약과 증거 수집 흐름을 고정한다.
2026-08-10 08:11:04 +09:00

284 lines
13 KiB
Python

"""Hermetic contract tests for the Claude Code IOP benchmark adapter."""
from __future__ import annotations
import json
import os
import tempfile
import textwrap
import unittest
from pathlib import Path
from scripts.agent_benchmark.claude_iop import (
ClaudeIopAdapter,
ClaudeIopProtocolError,
ClaudeIopRuntime,
ClaudeIopValidationError,
ClaudeStreamParser,
parse_preflight_binding,
redact_claude_event,
)
from scripts.agent_benchmark.lifecycle import REASON_PARSER_ERROR, REASON_SUCCESS, run_invocation
from scripts.agent_benchmark.manifest import ExpectedBinding, IopCell, MatrixCell, Timeout
from scripts.agent_benchmark.workspace import (
AttemptIdentity,
PreparedWorkspace,
TestbedProvenance,
)
SENTINELS = ("prompt-secret-sentinel", "https://private.iop.invalid", "api-secret-sentinel")
ARBITRARY_SENTINELS = (
"tool-secret-sentinel", "result-secret-sentinel", "error-secret-sentinel",
)
def _cell(route_kind: str = "direct") -> MatrixCell:
bindings = (ExpectedBinding("request", "claude-sonnet", "high"),)
if route_kind == "execution_preset":
bindings = (
ExpectedBinding("selector", "claude-sonnet", "high"),
ExpectedBinding("plan", "claude-sonnet", "high"),
ExpectedBinding("work", "claude-sonnet", "high"),
ExpectedBinding("review", "claude-sonnet", "high"),
)
return MatrixCell("claude-direct", "claude", IopCell(
"claude-sonnet", "high", route_kind, "iop-route", bindings,
))
def _workspace(root: Path, session_id: str = "session-fixture") -> PreparedWorkspace:
workspace = root / "workspace"
workspace.mkdir()
return PreparedWorkspace(
AttemptIdentity("run-20260102T030405Z-abcdef123456", "claude-direct", 1, 1),
str(root), str(workspace), str(root / "session"), session_id, True,
"sha256:" + "0" * 64, "isolated",
TestbedProvenance("/testbed", "main", "0" * 40, "sha256:" + "1" * 64, True),
"2026-01-02T03:04:05Z",
)
def _binding_event(cell: MatrixCell) -> str:
return json.dumps({
"type": "system", "subtype": "iop_binding", "binding": {
"cell_id": cell.id, "caller": cell.caller,
"requested_route_kind": cell.iop.route_kind, "requested_route_id": cell.iop.route_id,
"requested_model": cell.iop.request_model, "requested_effort": cell.iop.requested_effort,
"effective_route_kind": cell.iop.route_kind, "effective_route_id": cell.iop.route_id,
"effective_model": cell.iop.request_model, "effective_effort": cell.iop.requested_effort,
"effective_bindings": [
{"stage": item.stage, "model": item.model, "effort": item.effort}
for item in cell.iop.expected_bindings
],
},
})
class ClaudeIopTest(unittest.TestCase):
def setUp(self) -> None:
# The lifecycle intentionally requires an executable fake CLI. Some
# CI hosts mount /tmp noexec, so keep this short-lived directory under
# the repository worktree instead.
self.temp = tempfile.TemporaryDirectory(dir=Path.cwd(), prefix=".claude-iop-test-")
self.root = Path(self.temp.name)
self.cell = _cell()
self.workspace = _workspace(self.root)
self.binary = self.root / "claude"
self.binary.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
self.binary.chmod(0o700)
self.runtime = ClaudeIopRuntime(str(self.binary), SENTINELS[1], SENTINELS[2])
def tearDown(self) -> None:
self.temp.cleanup()
def _adapter(self, *, cell: MatrixCell | None = None) -> ClaudeIopAdapter:
return ClaudeIopAdapter(cell or self.cell, self.workspace, self.runtime)
def _fixture_lines(self) -> list[str]:
fixture = Path("scripts/fixtures/agent-comparison-benchmark/claude-iop-stream.jsonl")
return fixture.read_text(encoding="utf-8").splitlines()
def _run_fake(self, lines: list[str]):
self.binary.write_text(textwrap.dedent(f"""\
#!/usr/bin/env python3
import os, sys
assert sys.stdin.read() == {SENTINELS[0]!r}
assert os.environ["ANTHROPIC_BASE_URL"] == {SENTINELS[1]!r}
assert os.environ["ANTHROPIC_API_KEY"] == {SENTINELS[2]!r}
for line in {lines!r}:
print(line)
"""), encoding="utf-8")
evidence = self.root / f"evidence-{len(tuple(self.root.glob('evidence-*')))}"
evidence.mkdir()
adapter = self._adapter()
result = run_invocation(
adapter.invocation(SENTINELS[0], evidence, Timeout(5, 1, 1, 1)),
parse_event=adapter.parser(), redact=adapter.redactor(SENTINELS[0]),
on_started=lambda _: None,
)
durable = "\n".join(
path.read_text(encoding="utf-8") for path in sorted(evidence.iterdir())
)
return result, durable
def test_exact_iop_only_invocation_and_fresh_workspace(self) -> None:
adapter = self._adapter()
spec = adapter.invocation(SENTINELS[0], self.root / "evidence", Timeout(5, 1, 1, 1))
self.assertEqual(spec.cwd, self.workspace.workspace_dir)
self.assertEqual(spec.submission_mode, "stdin_once")
self.assertEqual(spec.task_payload, SENTINELS[0].encode())
self.assertEqual(spec.argv[1:], (
"--bare", "--print", "--verbose", "--input-format", "text",
"--output-format", "stream-json", "--model", "claude-sonnet", "--effort", "high",
"--no-session-persistence", "--permission-mode", "dontAsk", "--tools=",
))
env = dict(spec.env)
self.assertEqual(env["ANTHROPIC_BASE_URL"], SENTINELS[1])
self.assertEqual(env["ANTHROPIC_API_KEY"], SENTINELS[2])
self.assertNotIn("ANTHROPIC_AUTH_TOKEN", env)
self.assertNotIn("CLAUDE_CONFIG_DIR", env)
(self.root / "other").mkdir()
not_fresh = _workspace(self.root / "other", "session-other")
object.__setattr__(not_fresh, "session_is_fresh", False)
with self.assertRaises(ClaudeIopValidationError):
ClaudeIopAdapter(self.cell, not_fresh, self.runtime)
def test_direct_and_preset_preflight_are_exact_without_substitution(self) -> None:
for route_kind in ("direct", "execution_preset"):
cell = _cell(route_kind)
event = _binding_event(cell)
adapter = self._adapter(cell=cell)
self.assertEqual(adapter.preflight(event).status, "ready")
mutated = json.loads(event)
mutated["binding"]["effective_model"] = "fallback"
with self.assertRaises(ClaudeIopProtocolError):
parse_preflight_binding(json.dumps(mutated), cell)
incomplete = json.loads(_binding_event(self.cell))
del incomplete["binding"]["effective_effort"]
with self.assertRaises(ClaudeIopProtocolError):
self._adapter().preflight(json.dumps(incomplete))
def test_runtime_requires_available_binary_and_complete_iop_config(self) -> None:
missing_binary = ClaudeIopRuntime(
str(self.root / "missing-claude"), SENTINELS[1], SENTINELS[2]
)
with self.assertRaises(ClaudeIopValidationError):
ClaudeIopAdapter(self.cell, self.workspace, missing_binary)
for base_url, api_key in (("", SENTINELS[2]), (SENTINELS[1], "")):
with self.assertRaises(ClaudeIopValidationError):
ClaudeIopAdapter(
self.cell, self.workspace,
ClaudeIopRuntime(str(self.binary), base_url, api_key),
)
def test_fixture_uses_production_shaped_ordered_terminal_evidence(self) -> None:
lines = self._fixture_lines()
parser = ClaudeStreamParser(self.cell, "session-fixture")
self.assertEqual([parser("stdout", line) for line in lines], [None, "finish", "idle"])
malformed = json.loads(lines[1])
del malformed["message"]["model"]
with self.assertRaises(ClaudeIopProtocolError):
parser = ClaudeStreamParser(self.cell, "session-fixture")
parser("stdout", lines[0])
parser("stdout", json.dumps(malformed))
with self.assertRaises(ClaudeIopProtocolError):
parser("stdout", "not-json")
def test_parser_rejects_missing_duplicate_mismatched_and_out_of_order_evidence(self) -> None:
init, assistant, result = self._fixture_lines()
wrong_session = json.loads(assistant)
wrong_session["session_id"] = "other-claude-session"
wrong_model = json.loads(assistant)
wrong_model["message"]["model"] = "fallback-model"
missing_session = json.loads(result)
del missing_session["session_id"]
missing_result = [init, assistant]
cases = {
"assistant-before-init": [assistant],
"result-before-assistant": [init, result],
"duplicate-init": [init, init],
"duplicate-assistant": [init, assistant, assistant],
"session-mismatch": [init, json.dumps(wrong_session)],
"nested-model-mismatch": [init, json.dumps(wrong_model)],
"missing-result-session": [init, assistant, json.dumps(missing_session)],
}
for name, events in cases.items():
with self.subTest(name=name):
parser = ClaudeStreamParser(self.cell, "session-fixture")
with self.assertRaises(ClaudeIopProtocolError):
for event in events:
parser("stdout", event)
parser = ClaudeStreamParser(self.cell, "session-fixture")
self.assertEqual([parser("stdout", event) for event in missing_result], [None, "finish"])
def test_structural_redaction_never_retains_sensitive_content(self) -> None:
raw = json.dumps({
"type": "result", "result": "result-secret-sentinel",
"message": "error-secret-sentinel",
"content": "prompt-secret-sentinel",
"tool_input": {"arguments": "tool-secret-sentinel"},
"diagnostic": "https://private.iop.invalid api-secret-sentinel",
})
redacted = redact_claude_event(raw, SENTINELS)
error_redacted = redact_claude_event(json.dumps({
"type": "error", "message": "error-secret-sentinel",
"error": {"detail": "tool-secret-sentinel"},
}), SENTINELS)
for sentinel in (*SENTINELS, *ARBITRARY_SENTINELS):
self.assertNotIn(sentinel, redacted)
self.assertNotIn(sentinel, error_redacted)
self.assertIn("[redacted]", redacted)
self.assertEqual(redact_claude_event("raw prompt-secret-sentinel", SENTINELS),
'{"type":"invalid_claude_json"}')
def test_fake_cli_runs_once_and_durable_evidence_is_redacted(self) -> None:
init, assistant, result = (json.loads(line) for line in self._fixture_lines())
assistant["message"]["content"] = SENTINELS[0]
assistant["tool_input"] = {"arguments": ARBITRARY_SENTINELS[0]}
result["result"] = ARBITRARY_SENTINELS[1]
diagnostic = {"type": "system", "subtype": "notice", "error": ARBITRARY_SENTINELS[2]}
outcome, durable = self._run_fake([
json.dumps(init), json.dumps(diagnostic), json.dumps(assistant), json.dumps(result),
])
result = outcome
self.assertTrue(result.success, result)
self.assertEqual(result.terminal_reason, REASON_SUCCESS)
self.assertTrue(result.submitted)
self.assertTrue(result.finish_then_idle_then_quiet)
for sentinel in (*SENTINELS, *ARBITRARY_SENTINELS):
self.assertNotIn(sentinel, durable)
def test_lifecycle_rejects_boundary_violations(self) -> None:
init, assistant, result = self._fixture_lines()
bad_model = json.loads(assistant)
bad_model["message"]["model"] = "fallback-model"
cases = {
"missing-init": [assistant, result],
"missing-result": [init, assistant],
"duplicate-init": [init, init, assistant, result],
"out-of-order-result": [init, result, assistant],
"mismatched-model": [init, json.dumps(bad_model), result],
"malformed-json": [init, "not-json", assistant, result],
}
for name, lines in cases.items():
with self.subTest(name=name):
outcome, durable = self._run_fake(lines)
self.assertFalse(outcome.success, outcome)
for sentinel in (*SENTINELS, *ARBITRARY_SENTINELS):
self.assertNotIn(sentinel, durable)
def test_metric_prefixed_malformed_output_is_redacted_before_durable_capture(self) -> None:
metric_sentinel = "metric:" + SENTINELS[0]
outcome, durable = self._run_fake([metric_sentinel])
self.assertFalse(outcome.success, outcome)
self.assertEqual(outcome.terminal_reason, REASON_PARSER_ERROR)
self.assertTrue(outcome.submitted)
self.assertIn("invalid_claude_json", durable)
for sentinel in (*SENTINELS, metric_sentinel, SENTINELS[0]):
self.assertNotIn(sentinel, durable)
if __name__ == "__main__":
unittest.main()