test_benchmark_cli.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671
  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. class FailingTextStream(StringIO):
  70. def __init__(
  71. self,
  72. *,
  73. write_error: BaseException | None = None,
  74. flush_error: BaseException | None = None,
  75. ) -> None:
  76. super().__init__()
  77. self.write_error = write_error
  78. self.flush_error = flush_error
  79. def write(self, value: str) -> int:
  80. if self.write_error is not None:
  81. raise self.write_error
  82. return super().write(value)
  83. def flush(self) -> None:
  84. if self.flush_error is not None:
  85. raise self.flush_error
  86. super().flush()
  87. def _dependencies(
  88. cli,
  89. *,
  90. results: list[BenchmarkRunResult],
  91. api_key: str = "",
  92. runner_calls: list[dict[str, object]] | None = None,
  93. report_writer=None,
  94. stderr: StringIO | None = None,
  95. stdout: StringIO | None = None,
  96. ):
  97. calls = runner_calls if runner_calls is not None else []
  98. def runner_factory(**kwargs):
  99. calls.append(kwargs)
  100. return FakeRunner(results)
  101. return cli.BenchmarkCliDependencies(
  102. settings_factory=lambda: SimpleNamespace(
  103. openai_api_key=api_key,
  104. openai_base_url="https://env-must-not-win.example/v1",
  105. openai_default_model="env-must-not-win",
  106. ),
  107. runner_factory=runner_factory,
  108. report_writer=report_writer or cli.write_benchmark_report,
  109. now=lambda: datetime(2026, 7, 14, 1, 2, 3, tzinfo=timezone.utc),
  110. stdout=stdout or StringIO(),
  111. stderr=stderr or StringIO(),
  112. )
  113. def test_cli_overrides_json_and_repeatable_case_mode_replace_lists(tmp_path: Path):
  114. cli = _cli_module()
  115. config_path = _write_config(
  116. tmp_path / "config.json",
  117. cases=["ordinary_chat", "web_search_two_answers"],
  118. modes=["dual_agent", "chat_agent_tools"],
  119. )
  120. output_dir = tmp_path / "reports"
  121. runner_calls: list[dict[str, object]] = []
  122. dependencies = _dependencies(
  123. cli,
  124. results=[
  125. _run_result(
  126. case_id=case_id,
  127. mode=BenchmarkMode.CHAT_AGENT_TOOLS,
  128. iteration=iteration,
  129. )
  130. for case_id in (
  131. BenchmarkCaseId.ORDINARY_CHAT,
  132. BenchmarkCaseId.SESSION_TERMINATE,
  133. )
  134. for iteration in range(1, 4)
  135. ],
  136. api_key="env-key",
  137. runner_calls=runner_calls,
  138. )
  139. exit_code = cli.run_cli(
  140. [
  141. "--config",
  142. str(config_path),
  143. "--base-url",
  144. "https://cli.example/v1",
  145. "--model",
  146. "cli-model",
  147. "--runs",
  148. "3",
  149. "--case",
  150. "ordinary_chat",
  151. "--case",
  152. "session_terminate",
  153. "--mode",
  154. "chat_agent_tools",
  155. "--output-dir",
  156. str(output_dir),
  157. ],
  158. dependencies=dependencies,
  159. )
  160. assert exit_code == 0
  161. assert len(runner_calls) == 1
  162. resolved = runner_calls[0]
  163. assert resolved["api_key"] == "env-key"
  164. assert resolved["mock"] is False
  165. config = resolved["config"]
  166. assert config.base_url == "https://cli.example/v1"
  167. assert config.model == "cli-model"
  168. assert config.runs_per_case == 3
  169. assert config.cases == [
  170. BenchmarkCaseId.ORDINARY_CHAT,
  171. BenchmarkCaseId.SESSION_TERMINATE,
  172. ]
  173. assert config.modes == [BenchmarkMode.CHAT_AGENT_TOOLS]
  174. assert len(list(output_dir.glob("*.json"))) == 1
  175. assert len(list(output_dir.glob("*.md"))) == 1
  176. def test_mock_run_needs_no_api_key_and_exits_zero(tmp_path: Path):
  177. cli = _cli_module()
  178. config_path = _write_config(tmp_path / "config.json")
  179. output_dir = tmp_path / "reports"
  180. stdout = StringIO()
  181. stderr = StringIO()
  182. dependencies = _dependencies(
  183. cli,
  184. results=[_run_result()],
  185. api_key="",
  186. stdout=stdout,
  187. stderr=stderr,
  188. )
  189. exit_code = cli.run_cli(
  190. [
  191. "--config",
  192. str(config_path),
  193. "--mock",
  194. "--output-dir",
  195. str(output_dir),
  196. ],
  197. dependencies=dependencies,
  198. )
  199. assert exit_code == 0
  200. assert "attempted=1 passed=1 failed=0" in stdout.getvalue()
  201. assert "json_report=" in stdout.getvalue()
  202. assert "markdown_report=" in stdout.getvalue()
  203. assert stderr.getvalue() == ""
  204. def test_partial_failed_run_exits_one_writes_redacted_reports(tmp_path: Path):
  205. cli = _cli_module()
  206. secret = "sk-cli-real-secret"
  207. config_path = _write_config(
  208. tmp_path / "config.json",
  209. runs_per_case=2,
  210. )
  211. output_dir = tmp_path / "reports"
  212. stdout = StringIO()
  213. stderr = StringIO()
  214. dependencies = _dependencies(
  215. cli,
  216. results=[
  217. _run_result(iteration=1),
  218. _run_result(
  219. iteration=2,
  220. status="failed",
  221. error=f"Authorization: Bearer {secret}",
  222. ),
  223. ],
  224. api_key=secret,
  225. stdout=stdout,
  226. stderr=stderr,
  227. )
  228. exit_code = cli.run_cli(
  229. [
  230. "--config",
  231. str(config_path),
  232. "--output-dir",
  233. str(output_dir),
  234. ],
  235. dependencies=dependencies,
  236. )
  237. assert exit_code == 1
  238. assert "attempted=2 passed=1 failed=1" in stdout.getvalue()
  239. assert secret not in stdout.getvalue()
  240. assert stderr.getvalue() == ""
  241. report_text = "\n".join(
  242. path.read_text() for path in sorted(output_dir.iterdir())
  243. )
  244. assert secret not in report_text
  245. assert "[REDACTED]" in report_text
  246. @pytest.mark.parametrize(
  247. "payload",
  248. [
  249. {"schema_version": 1, "api_key": "secret-in-json"},
  250. {"schema_version": 1, "base_url": "not-a-url", "model": "m"},
  251. ],
  252. )
  253. def test_bad_config_exits_two_without_leaking_secret(
  254. tmp_path: Path, payload: dict[str, object]
  255. ):
  256. cli = _cli_module()
  257. config_path = tmp_path / "bad.json"
  258. config_path.write_text(json.dumps(payload))
  259. stderr = StringIO()
  260. runner_calls: list[dict[str, object]] = []
  261. dependencies = _dependencies(
  262. cli,
  263. results=[],
  264. runner_calls=runner_calls,
  265. stderr=stderr,
  266. )
  267. exit_code = cli.run_cli(
  268. ["--config", str(config_path), "--mock"],
  269. dependencies=dependencies,
  270. )
  271. assert exit_code == 2
  272. assert runner_calls == []
  273. assert "secret-in-json" not in stderr.getvalue()
  274. assert "benchmark error:" in stderr.getvalue()
  275. def test_final_validation_allows_cli_to_supply_missing_base_url_and_model(
  276. tmp_path: Path,
  277. ):
  278. cli = _cli_module()
  279. config_path = tmp_path / "minimal.json"
  280. config_path.write_text(
  281. json.dumps(
  282. {
  283. "schema_version": 1,
  284. "cases": ["ordinary_chat"],
  285. "modes": ["dual_agent"],
  286. }
  287. )
  288. )
  289. runner_calls: list[dict[str, object]] = []
  290. dependencies = _dependencies(
  291. cli,
  292. results=[_run_result()],
  293. runner_calls=runner_calls,
  294. )
  295. exit_code = cli.run_cli(
  296. [
  297. "--config",
  298. str(config_path),
  299. "--base-url",
  300. "https://cli.example/v1",
  301. "--model",
  302. "cli-model",
  303. "--mock",
  304. "--output-dir",
  305. str(tmp_path / "reports"),
  306. ],
  307. dependencies=dependencies,
  308. )
  309. assert exit_code == 0
  310. assert runner_calls[0]["config"].model == "cli-model"
  311. def test_live_run_without_api_key_exits_two(tmp_path: Path):
  312. cli = _cli_module()
  313. config_path = _write_config(tmp_path / "config.json")
  314. stderr = StringIO()
  315. runner_calls: list[dict[str, object]] = []
  316. dependencies = _dependencies(
  317. cli,
  318. results=[],
  319. api_key="",
  320. runner_calls=runner_calls,
  321. stderr=stderr,
  322. )
  323. exit_code = cli.run_cli(
  324. ["--config", str(config_path)],
  325. dependencies=dependencies,
  326. )
  327. assert exit_code == 2
  328. assert runner_calls == []
  329. assert "api key is required" in stderr.getvalue().lower()
  330. def test_report_failure_exits_two_and_redacts_stderr(tmp_path: Path):
  331. cli = _cli_module()
  332. secret = "sk-report-failure-secret"
  333. config_path = _write_config(tmp_path / "config.json")
  334. stderr = StringIO()
  335. def failing_writer(report, output_dir):
  336. del report, output_dir
  337. raise OSError(f"API-Key: {secret}")
  338. dependencies = _dependencies(
  339. cli,
  340. results=[_run_result()],
  341. api_key=secret,
  342. report_writer=failing_writer,
  343. stderr=stderr,
  344. )
  345. exit_code = cli.run_cli(
  346. ["--config", str(config_path)],
  347. dependencies=dependencies,
  348. )
  349. assert exit_code == 2
  350. assert secret not in stderr.getvalue()
  351. assert "[REDACTED]" in stderr.getvalue()
  352. def test_unexpected_runner_failure_still_attempts_report_write(tmp_path: Path):
  353. cli = _cli_module()
  354. secret = "runner-secret"
  355. config_path = _write_config(tmp_path / "config.json")
  356. writes: list[object] = []
  357. stderr = StringIO()
  358. stdout = StringIO()
  359. class FailingRunner:
  360. async def run(self):
  361. raise RuntimeError(f"Bearer {secret}")
  362. def write_report(report, output_dir):
  363. writes.append(report)
  364. output = Path(output_dir)
  365. output.mkdir(parents=True)
  366. json_path = output / "report.json"
  367. md_path = output / "report.md"
  368. json_path.write_text("{}")
  369. md_path.write_text("# report")
  370. return json_path, md_path
  371. dependencies = cli.BenchmarkCliDependencies(
  372. settings_factory=lambda: SimpleNamespace(openai_api_key=secret),
  373. runner_factory=lambda **kwargs: FailingRunner(),
  374. report_writer=write_report,
  375. now=lambda: datetime(2026, 7, 14, tzinfo=timezone.utc),
  376. stdout=stdout,
  377. stderr=stderr,
  378. )
  379. exit_code = cli.run_cli(
  380. [
  381. "--config",
  382. str(config_path),
  383. "--output-dir",
  384. str(tmp_path / "reports"),
  385. ],
  386. dependencies=dependencies,
  387. )
  388. assert exit_code == 2
  389. assert len(writes) == 1
  390. assert writes[0].overall.attempted == 1
  391. assert writes[0].overall.failed == 1
  392. assert writes[0].overall.system_errors == ["Bearer [REDACTED]"]
  393. assert "result count mismatch" not in writes[0].overall.system_errors[0]
  394. assert "attempted=1 passed=0 failed=1" in stdout.getvalue()
  395. assert secret not in stdout.getvalue()
  396. assert secret not in stderr.getvalue()
  397. assert "Bearer [REDACTED]" in stderr.getvalue()
  398. @pytest.mark.parametrize("failure_point", ["write", "flush"])
  399. def test_summary_output_failure_after_report_write_exits_two_and_redacts_stderr(
  400. tmp_path: Path,
  401. failure_point: str,
  402. ):
  403. cli = _cli_module()
  404. secret = "sk-summary-output-secret"
  405. config_path = _write_config(tmp_path / "config.json")
  406. output_dir = tmp_path / "reports"
  407. error = BrokenPipeError(f"Bearer {secret}")
  408. stdout = FailingTextStream(
  409. write_error=error if failure_point == "write" else None,
  410. flush_error=error if failure_point == "flush" else None,
  411. )
  412. stderr = StringIO()
  413. dependencies = _dependencies(
  414. cli,
  415. results=[_run_result()],
  416. api_key=secret,
  417. stdout=stdout,
  418. stderr=stderr,
  419. )
  420. exit_code = cli.run_cli(
  421. [
  422. "--config",
  423. str(config_path),
  424. "--output-dir",
  425. str(output_dir),
  426. ],
  427. dependencies=dependencies,
  428. )
  429. assert exit_code == 2
  430. assert any(output_dir.rglob("*.json"))
  431. assert any(output_dir.rglob("*.md"))
  432. assert secret not in stderr.getvalue()
  433. assert "Bearer [REDACTED]" in stderr.getvalue()
  434. def test_execution_error_stderr_failure_still_exits_two(tmp_path: Path):
  435. cli = _cli_module()
  436. secret = "sk-execution-error-secret"
  437. config_path = _write_config(tmp_path / "config.json")
  438. output_dir = tmp_path / "reports"
  439. stderr = FailingTextStream(
  440. write_error=BrokenPipeError(f"Bearer {secret}")
  441. )
  442. dependencies = _dependencies(
  443. cli,
  444. results=[],
  445. api_key=secret,
  446. stderr=stderr,
  447. )
  448. exit_code = cli.run_cli(
  449. [
  450. "--config",
  451. str(config_path),
  452. "--output-dir",
  453. str(output_dir),
  454. ],
  455. dependencies=dependencies,
  456. )
  457. assert exit_code == 2
  458. assert any(output_dir.rglob("*.json"))
  459. assert any(output_dir.rglob("*.md"))
  460. assert secret not in stderr.getvalue()
  461. def test_summary_output_keyboard_interrupt_still_exits_130(tmp_path: Path):
  462. cli = _cli_module()
  463. config_path = _write_config(tmp_path / "config.json")
  464. output_dir = tmp_path / "reports"
  465. dependencies = _dependencies(
  466. cli,
  467. results=[_run_result()],
  468. stdout=FailingTextStream(write_error=KeyboardInterrupt()),
  469. )
  470. exit_code = cli.run_cli(
  471. [
  472. "--config",
  473. str(config_path),
  474. "--mock",
  475. "--output-dir",
  476. str(output_dir),
  477. ],
  478. dependencies=dependencies,
  479. )
  480. assert exit_code == 130
  481. @pytest.mark.parametrize(
  482. ("results", "attempted", "passed", "failed", "actual_count"),
  483. [
  484. ([], 1, 0, 1, 0),
  485. ([_run_result(iteration=1), _run_result(iteration=2)], 3, 2, 1, 2),
  486. ],
  487. )
  488. def test_runner_result_cardinality_mismatch_is_reported_and_exits_two(
  489. tmp_path: Path,
  490. results: list[BenchmarkRunResult],
  491. attempted: int,
  492. passed: int,
  493. failed: int,
  494. actual_count: int,
  495. ):
  496. cli = _cli_module()
  497. config_path = _write_config(tmp_path / "config.json")
  498. output_dir = tmp_path / "reports"
  499. stdout = StringIO()
  500. stderr = StringIO()
  501. dependencies = _dependencies(
  502. cli,
  503. results=results,
  504. stdout=stdout,
  505. stderr=stderr,
  506. )
  507. exit_code = cli.run_cli(
  508. [
  509. "--config",
  510. str(config_path),
  511. "--mock",
  512. "--output-dir",
  513. str(output_dir),
  514. ],
  515. dependencies=dependencies,
  516. )
  517. assert exit_code == 2
  518. json_path = next(output_dir.glob("*.json"))
  519. markdown_path = next(output_dir.glob("*.md"))
  520. payload = json.loads(json_path.read_text())
  521. expected_error = (
  522. f"runner result count mismatch: expected 1, got {actual_count}"
  523. )
  524. assert payload["overall"] == {
  525. "attempted": attempted,
  526. "passed": passed,
  527. "failed": failed,
  528. "warnings": payload["overall"]["warnings"],
  529. "system_errors": [expected_error],
  530. }
  531. assert expected_error in markdown_path.read_text()
  532. assert f"attempted={attempted} passed={passed} failed={failed}" in (
  533. stdout.getvalue()
  534. )
  535. assert expected_error in stderr.getvalue()
  536. def test_help_and_required_config_use_argparse(capsys: pytest.CaptureFixture[str]):
  537. cli = _cli_module()
  538. with pytest.raises(SystemExit) as help_exit:
  539. cli.run_cli(["--help"])
  540. assert help_exit.value.code == 0
  541. assert "--config" in capsys.readouterr().out
  542. with pytest.raises(SystemExit) as missing_exit:
  543. cli.run_cli([])
  544. assert missing_exit.value.code == 2
  545. @pytest.mark.parametrize(
  546. "invalid_args",
  547. [
  548. ["--runs", "api_key=runs-secret"],
  549. ["--case", "api_key=case-secret"],
  550. ],
  551. )
  552. def test_argparse_errors_use_only_generic_safe_message(
  553. capsys: pytest.CaptureFixture[str],
  554. invalid_args: list[str],
  555. ):
  556. cli = _cli_module()
  557. with pytest.raises(SystemExit) as exc_info:
  558. cli.run_cli(
  559. [
  560. "--config",
  561. "unused.json",
  562. *invalid_args,
  563. ]
  564. )
  565. assert exc_info.value.code == 2
  566. stderr = capsys.readouterr().err
  567. assert "runs-secret" not in stderr
  568. assert "case-secret" not in stderr
  569. assert "api_key" not in stderr
  570. assert "invalid command-line arguments" in stderr
  571. assert "invalid int value" not in stderr
  572. assert "invalid choice" not in stderr
  573. assert "choose from" not in stderr
  574. def test_main_raises_system_exit(monkeypatch: pytest.MonkeyPatch):
  575. cli = _cli_module()
  576. monkeypatch.setattr(cli, "run_cli", lambda argv=None: 1)
  577. with pytest.raises(SystemExit) as exc_info:
  578. cli.main()
  579. assert exc_info.value.code == 1
  580. def test_pyproject_registers_benchmark_console_script():
  581. pyproject = tomllib.loads(Path("pyproject.toml").read_text())
  582. assert pyproject["project"]["scripts"]["agent-lab-benchmark"] == (
  583. "agent_lab.presentation.benchmark_cli:main"
  584. )