test_benchmark_cli.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472
  1. import importlib
  2. import importlib.util
  3. import json
  4. import tomllib
  5. from datetime import datetime, timezone
  6. from io import StringIO
  7. from pathlib import Path
  8. from types import SimpleNamespace
  9. import pytest
  10. from agent_lab.application.benchmark import (
  11. BenchmarkCaseId,
  12. BenchmarkMode,
  13. BenchmarkRunResult,
  14. )
  15. def _cli_module():
  16. spec = importlib.util.find_spec("agent_lab.presentation.benchmark_cli")
  17. assert spec is not None, "benchmark_cli module must be implemented"
  18. return importlib.import_module("agent_lab.presentation.benchmark_cli")
  19. def _write_config(path: Path, **overrides: object) -> Path:
  20. payload = {
  21. "schema_version": 1,
  22. "base_url": "https://json.example/v1",
  23. "model": "json-model",
  24. "runs_per_case": 1,
  25. "cases": ["ordinary_chat"],
  26. "modes": ["dual_agent"],
  27. }
  28. payload.update(overrides)
  29. path.write_text(json.dumps(payload))
  30. return path
  31. def _run_result(
  32. *,
  33. case_id: BenchmarkCaseId = BenchmarkCaseId.ORDINARY_CHAT,
  34. mode: BenchmarkMode = BenchmarkMode.DUAL_AGENT,
  35. iteration: int = 1,
  36. status: str = "passed",
  37. error: str | None = None,
  38. ) -> BenchmarkRunResult:
  39. return BenchmarkRunResult(
  40. case_id=case_id,
  41. mode=mode,
  42. iteration=iteration,
  43. status=status,
  44. initial_provider_ttft_ms=1,
  45. visible_ttft_ms=2,
  46. turn_wall_time_ms=3,
  47. prompt_tokens=2,
  48. completion_tokens=3,
  49. total_tokens=5,
  50. cached_tokens=0,
  51. model_call_count=1,
  52. fallback_count=0,
  53. tool_count=0,
  54. event_names=[],
  55. batch_event_names=[],
  56. tool_event_names=[],
  57. event_sources=[],
  58. tool_statuses=[],
  59. tool_latencies_ms=[],
  60. semantic_failures=[],
  61. error=error,
  62. model_calls=[],
  63. )
  64. class FakeRunner:
  65. def __init__(self, results: list[BenchmarkRunResult]) -> None:
  66. self.results = results
  67. async def run(self) -> list[BenchmarkRunResult]:
  68. return self.results
  69. def _dependencies(
  70. cli,
  71. *,
  72. results: list[BenchmarkRunResult],
  73. api_key: str = "",
  74. runner_calls: list[dict[str, object]] | None = None,
  75. report_writer=None,
  76. stderr: StringIO | None = None,
  77. stdout: StringIO | None = None,
  78. ):
  79. calls = runner_calls if runner_calls is not None else []
  80. def runner_factory(**kwargs):
  81. calls.append(kwargs)
  82. return FakeRunner(results)
  83. return cli.BenchmarkCliDependencies(
  84. settings_factory=lambda: SimpleNamespace(
  85. openai_api_key=api_key,
  86. openai_base_url="https://env-must-not-win.example/v1",
  87. openai_default_model="env-must-not-win",
  88. ),
  89. runner_factory=runner_factory,
  90. report_writer=report_writer or cli.write_benchmark_report,
  91. now=lambda: datetime(2026, 7, 14, 1, 2, 3, tzinfo=timezone.utc),
  92. stdout=stdout or StringIO(),
  93. stderr=stderr or StringIO(),
  94. )
  95. def test_cli_overrides_json_and_repeatable_case_mode_replace_lists(tmp_path: Path):
  96. cli = _cli_module()
  97. config_path = _write_config(
  98. tmp_path / "config.json",
  99. cases=["ordinary_chat", "web_search_two_answers"],
  100. modes=["dual_agent", "chat_agent_tools"],
  101. )
  102. output_dir = tmp_path / "reports"
  103. runner_calls: list[dict[str, object]] = []
  104. dependencies = _dependencies(
  105. cli,
  106. results=[
  107. _run_result(
  108. case_id=BenchmarkCaseId.SESSION_TERMINATE,
  109. mode=BenchmarkMode.CHAT_AGENT_TOOLS,
  110. )
  111. ],
  112. api_key="env-key",
  113. runner_calls=runner_calls,
  114. )
  115. exit_code = cli.run_cli(
  116. [
  117. "--config",
  118. str(config_path),
  119. "--base-url",
  120. "https://cli.example/v1",
  121. "--model",
  122. "cli-model",
  123. "--runs",
  124. "3",
  125. "--case",
  126. "ordinary_chat",
  127. "--case",
  128. "session_terminate",
  129. "--mode",
  130. "chat_agent_tools",
  131. "--output-dir",
  132. str(output_dir),
  133. ],
  134. dependencies=dependencies,
  135. )
  136. assert exit_code == 0
  137. assert len(runner_calls) == 1
  138. resolved = runner_calls[0]
  139. assert resolved["api_key"] == "env-key"
  140. assert resolved["mock"] is False
  141. config = resolved["config"]
  142. assert config.base_url == "https://cli.example/v1"
  143. assert config.model == "cli-model"
  144. assert config.runs_per_case == 3
  145. assert config.cases == [
  146. BenchmarkCaseId.ORDINARY_CHAT,
  147. BenchmarkCaseId.SESSION_TERMINATE,
  148. ]
  149. assert config.modes == [BenchmarkMode.CHAT_AGENT_TOOLS]
  150. assert len(list(output_dir.glob("*.json"))) == 1
  151. assert len(list(output_dir.glob("*.md"))) == 1
  152. def test_mock_run_needs_no_api_key_and_exits_zero(tmp_path: Path):
  153. cli = _cli_module()
  154. config_path = _write_config(tmp_path / "config.json")
  155. output_dir = tmp_path / "reports"
  156. stdout = StringIO()
  157. stderr = StringIO()
  158. dependencies = _dependencies(
  159. cli,
  160. results=[_run_result()],
  161. api_key="",
  162. stdout=stdout,
  163. stderr=stderr,
  164. )
  165. exit_code = cli.run_cli(
  166. [
  167. "--config",
  168. str(config_path),
  169. "--mock",
  170. "--output-dir",
  171. str(output_dir),
  172. ],
  173. dependencies=dependencies,
  174. )
  175. assert exit_code == 0
  176. assert "attempted=1 passed=1 failed=0" in stdout.getvalue()
  177. assert "json_report=" in stdout.getvalue()
  178. assert "markdown_report=" in stdout.getvalue()
  179. assert stderr.getvalue() == ""
  180. def test_partial_failed_run_exits_one_writes_redacted_reports(tmp_path: Path):
  181. cli = _cli_module()
  182. secret = "sk-cli-real-secret"
  183. config_path = _write_config(tmp_path / "config.json")
  184. output_dir = tmp_path / "reports"
  185. stdout = StringIO()
  186. stderr = StringIO()
  187. dependencies = _dependencies(
  188. cli,
  189. results=[
  190. _run_result(iteration=1),
  191. _run_result(
  192. iteration=2,
  193. status="failed",
  194. error=f"Authorization: Bearer {secret}",
  195. ),
  196. ],
  197. api_key=secret,
  198. stdout=stdout,
  199. stderr=stderr,
  200. )
  201. exit_code = cli.run_cli(
  202. [
  203. "--config",
  204. str(config_path),
  205. "--output-dir",
  206. str(output_dir),
  207. ],
  208. dependencies=dependencies,
  209. )
  210. assert exit_code == 1
  211. assert "attempted=2 passed=1 failed=1" in stdout.getvalue()
  212. assert secret not in stdout.getvalue()
  213. assert stderr.getvalue() == ""
  214. report_text = "\n".join(
  215. path.read_text() for path in sorted(output_dir.iterdir())
  216. )
  217. assert secret not in report_text
  218. assert "[REDACTED]" in report_text
  219. @pytest.mark.parametrize(
  220. "payload",
  221. [
  222. {"schema_version": 1, "api_key": "secret-in-json"},
  223. {"schema_version": 1, "base_url": "not-a-url", "model": "m"},
  224. ],
  225. )
  226. def test_bad_config_exits_two_without_leaking_secret(
  227. tmp_path: Path, payload: dict[str, object]
  228. ):
  229. cli = _cli_module()
  230. config_path = tmp_path / "bad.json"
  231. config_path.write_text(json.dumps(payload))
  232. stderr = StringIO()
  233. runner_calls: list[dict[str, object]] = []
  234. dependencies = _dependencies(
  235. cli,
  236. results=[],
  237. runner_calls=runner_calls,
  238. stderr=stderr,
  239. )
  240. exit_code = cli.run_cli(
  241. ["--config", str(config_path), "--mock"],
  242. dependencies=dependencies,
  243. )
  244. assert exit_code == 2
  245. assert runner_calls == []
  246. assert "secret-in-json" not in stderr.getvalue()
  247. assert "benchmark error:" in stderr.getvalue()
  248. def test_final_validation_allows_cli_to_supply_missing_base_url_and_model(
  249. tmp_path: Path,
  250. ):
  251. cli = _cli_module()
  252. config_path = tmp_path / "minimal.json"
  253. config_path.write_text(
  254. json.dumps(
  255. {
  256. "schema_version": 1,
  257. "cases": ["ordinary_chat"],
  258. "modes": ["dual_agent"],
  259. }
  260. )
  261. )
  262. runner_calls: list[dict[str, object]] = []
  263. dependencies = _dependencies(
  264. cli,
  265. results=[_run_result()],
  266. runner_calls=runner_calls,
  267. )
  268. exit_code = cli.run_cli(
  269. [
  270. "--config",
  271. str(config_path),
  272. "--base-url",
  273. "https://cli.example/v1",
  274. "--model",
  275. "cli-model",
  276. "--mock",
  277. "--output-dir",
  278. str(tmp_path / "reports"),
  279. ],
  280. dependencies=dependencies,
  281. )
  282. assert exit_code == 0
  283. assert runner_calls[0]["config"].model == "cli-model"
  284. def test_live_run_without_api_key_exits_two(tmp_path: Path):
  285. cli = _cli_module()
  286. config_path = _write_config(tmp_path / "config.json")
  287. stderr = StringIO()
  288. runner_calls: list[dict[str, object]] = []
  289. dependencies = _dependencies(
  290. cli,
  291. results=[],
  292. api_key="",
  293. runner_calls=runner_calls,
  294. stderr=stderr,
  295. )
  296. exit_code = cli.run_cli(
  297. ["--config", str(config_path)],
  298. dependencies=dependencies,
  299. )
  300. assert exit_code == 2
  301. assert runner_calls == []
  302. assert "api key is required" in stderr.getvalue().lower()
  303. def test_report_failure_exits_two_and_redacts_stderr(tmp_path: Path):
  304. cli = _cli_module()
  305. secret = "sk-report-failure-secret"
  306. config_path = _write_config(tmp_path / "config.json")
  307. stderr = StringIO()
  308. def failing_writer(report, output_dir):
  309. del report, output_dir
  310. raise OSError(f"API-Key: {secret}")
  311. dependencies = _dependencies(
  312. cli,
  313. results=[_run_result()],
  314. api_key=secret,
  315. report_writer=failing_writer,
  316. stderr=stderr,
  317. )
  318. exit_code = cli.run_cli(
  319. ["--config", str(config_path)],
  320. dependencies=dependencies,
  321. )
  322. assert exit_code == 2
  323. assert secret not in stderr.getvalue()
  324. assert "[REDACTED]" in stderr.getvalue()
  325. def test_unexpected_runner_failure_still_attempts_report_write(tmp_path: Path):
  326. cli = _cli_module()
  327. secret = "runner-secret"
  328. config_path = _write_config(tmp_path / "config.json")
  329. writes: list[object] = []
  330. stderr = StringIO()
  331. stdout = StringIO()
  332. class FailingRunner:
  333. async def run(self):
  334. raise RuntimeError(f"Bearer {secret}")
  335. def write_report(report, output_dir):
  336. writes.append(report)
  337. output = Path(output_dir)
  338. output.mkdir(parents=True)
  339. json_path = output / "report.json"
  340. md_path = output / "report.md"
  341. json_path.write_text("{}")
  342. md_path.write_text("# report")
  343. return json_path, md_path
  344. dependencies = cli.BenchmarkCliDependencies(
  345. settings_factory=lambda: SimpleNamespace(openai_api_key=secret),
  346. runner_factory=lambda **kwargs: FailingRunner(),
  347. report_writer=write_report,
  348. now=lambda: datetime(2026, 7, 14, tzinfo=timezone.utc),
  349. stdout=stdout,
  350. stderr=stderr,
  351. )
  352. exit_code = cli.run_cli(
  353. [
  354. "--config",
  355. str(config_path),
  356. "--output-dir",
  357. str(tmp_path / "reports"),
  358. ],
  359. dependencies=dependencies,
  360. )
  361. assert exit_code == 2
  362. assert len(writes) == 1
  363. assert writes[0].overall.attempted == 1
  364. assert writes[0].overall.failed == 1
  365. assert writes[0].overall.system_errors == ["Bearer [REDACTED]"]
  366. assert "attempted=1 passed=0 failed=1" in stdout.getvalue()
  367. assert secret not in stdout.getvalue()
  368. assert secret not in stderr.getvalue()
  369. assert "Bearer [REDACTED]" in stderr.getvalue()
  370. def test_help_and_required_config_use_argparse(capsys: pytest.CaptureFixture[str]):
  371. cli = _cli_module()
  372. with pytest.raises(SystemExit) as help_exit:
  373. cli.run_cli(["--help"])
  374. assert help_exit.value.code == 0
  375. assert "--config" in capsys.readouterr().out
  376. with pytest.raises(SystemExit) as missing_exit:
  377. cli.run_cli([])
  378. assert missing_exit.value.code == 2
  379. def test_argparse_errors_are_redacted(capsys: pytest.CaptureFixture[str]):
  380. cli = _cli_module()
  381. secret = "argument-secret"
  382. with pytest.raises(SystemExit) as exc_info:
  383. cli.run_cli(
  384. [
  385. "--config",
  386. "unused.json",
  387. "--mode",
  388. f"api_key={secret}",
  389. ]
  390. )
  391. assert exc_info.value.code == 2
  392. stderr = capsys.readouterr().err
  393. assert secret not in stderr
  394. assert "[REDACTED]" in stderr
  395. def test_main_raises_system_exit(monkeypatch: pytest.MonkeyPatch):
  396. cli = _cli_module()
  397. monkeypatch.setattr(cli, "run_cli", lambda argv=None: 1)
  398. with pytest.raises(SystemExit) as exc_info:
  399. cli.main()
  400. assert exc_info.value.code == 1
  401. def test_pyproject_registers_benchmark_console_script():
  402. pyproject = tomllib.loads(Path("pyproject.toml").read_text())
  403. assert pyproject["project"]["scripts"]["agent-lab-benchmark"] == (
  404. "agent_lab.presentation.benchmark_cli:main"
  405. )