345 lines
16 KiB
Python
345 lines
16 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")
|
|
parsed = [parser("stdout", line) for line in lines]
|
|
self.assertEqual(parsed[:2], [None, "finish"])
|
|
self.assertEqual(parsed[2][-1], "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_reported_result_values_become_bound_normalized_observations(self) -> None:
|
|
init, assistant, result = self._fixture_lines()
|
|
parser = ClaudeStreamParser(self.cell, "session-fixture")
|
|
parser("stdout", init)
|
|
parser("stdout", assistant)
|
|
parsed = parser("stdout", result)
|
|
self.assertEqual(parsed[-1], "idle")
|
|
observed = {metric.name: metric for metric in parsed[:-1]}
|
|
self.assertEqual(observed["total_duration"].value, 1234 * 10 ** 6)
|
|
self.assertFalse(observed["total_duration"].overlap)
|
|
# The reported API duration is inside the reported total, so it is
|
|
# published as an overlapping interval and never subtracted from it.
|
|
self.assertEqual(observed["model_duration"].value, 1000 * 10 ** 6)
|
|
self.assertTrue(observed["model_duration"].overlap)
|
|
self.assertEqual(observed["model_calls"].value, 1)
|
|
self.assertEqual(observed["input_tokens"].value, 11)
|
|
self.assertEqual(observed["output_tokens"].value, 22)
|
|
self.assertEqual(observed["cached_input_tokens"].value, 5)
|
|
# The fixture omits cache creation, so that category stays unreported
|
|
# rather than being reported as zero.
|
|
self.assertNotIn("cache_write_tokens", observed)
|
|
for metric in parsed[:-1]:
|
|
self.assertEqual(metric.model, "claude-sonnet")
|
|
self.assertEqual(metric.source, "caller_output")
|
|
self.assertEqual(observed["input_tokens"].clock, "none")
|
|
self.assertEqual(observed["total_duration"].clock, "caller_reported")
|
|
|
|
def test_unknown_negative_boolean_and_fractional_usage_fails_closed(self) -> None:
|
|
init, assistant, result = self._fixture_lines()
|
|
base = json.loads(result)
|
|
cases = {
|
|
"unknown-usage-key": {"usage": {"input_tokens": 1, "web_search_requests": 2}},
|
|
"fractional-token": {"usage": {"input_tokens": 1.5}},
|
|
"boolean-token": {"usage": {"input_tokens": True}},
|
|
"negative-token": {"usage": {"input_tokens": -1}},
|
|
"string-duration": {"duration_ms": "1234"},
|
|
"boolean-duration": {"duration_ms": True},
|
|
"negative-duration": {"duration_ms": -5},
|
|
"non-object-usage": {"usage": [1, 2]},
|
|
}
|
|
for name, override in cases.items():
|
|
with self.subTest(name=name):
|
|
parser = ClaudeStreamParser(self.cell, "session-fixture")
|
|
parser("stdout", init)
|
|
parser("stdout", assistant)
|
|
with self.assertRaises(ClaudeIopProtocolError):
|
|
parser("stdout", json.dumps({**base, **override}))
|
|
|
|
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)
|
|
self.assertEqual(
|
|
[metric.name for metric in result.metrics],
|
|
[
|
|
"total_duration", "model_duration", "model_calls",
|
|
"cached_input_tokens", "input_tokens", "output_tokens",
|
|
],
|
|
)
|
|
# Numeric observations survive the content redactors that removed every
|
|
# sentinel above, and the durable journal keeps them in one closed shape.
|
|
self.assertIn('"kind": "metric:total_duration"', durable)
|
|
self.assertIn('\\"value\\":1234000000', 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()
|