test_benchmark_runner.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696
  1. import asyncio
  2. from collections.abc import AsyncIterator
  3. from typing import Any
  4. import pytest
  5. from pydantic import ValidationError
  6. from agent_lab.application import benchmark
  7. from agent_lab.application.contracts import AgentParams
  8. from agent_lab.application.events import ResultPolicy
  9. from agent_lab.application.tools import ToolDefinition, ToolRegistry
  10. from agent_lab.domain.events import ToolCallEvent
  11. from agent_lab.domain.messages import ChatMessage, StreamItem
  12. from agent_lab.domain.messages import TokenUsage
  13. from agent_lab.infrastructure.sqlite_store import SQLiteSessionStore
  14. class ManualClock:
  15. def __init__(self) -> None:
  16. self.now = 0.0
  17. def __call__(self) -> float:
  18. return self.now
  19. class ScriptedTimingClient:
  20. def __init__(self, clock: ManualClock, *, fail: bool = False) -> None:
  21. self.clock = clock
  22. self.fail = fail
  23. self.closed = False
  24. async def stream_chat(
  25. self,
  26. messages: list[ChatMessage],
  27. tools: list[dict[str, Any]],
  28. params: AgentParams,
  29. tool_choice: dict[str, Any] | None = None,
  30. ) -> AsyncIterator[StreamItem]:
  31. del messages, tools, params, tool_choice
  32. self.clock.now = 0.011
  33. yield StreamItem.raw_response_chunk({"provider": "first"})
  34. if self.fail:
  35. self.clock.now = 0.023
  36. raise RuntimeError("stream failed")
  37. self.clock.now = 0.037
  38. yield StreamItem.message_delta("visible")
  39. self.clock.now = 0.041
  40. yield StreamItem.usage_item(TokenUsage(total_tokens=3))
  41. self.clock.now = 0.059
  42. yield StreamItem.usage_item(TokenUsage(total_tokens=5))
  43. self.clock.now = 0.071
  44. async def aclose(self) -> None:
  45. self.closed = True
  46. class FallbackBenchmarkClient:
  47. def __init__(self) -> None:
  48. self.calls = 0
  49. self.closed = False
  50. async def stream_chat(
  51. self,
  52. messages: list[ChatMessage],
  53. tools: list[dict[str, Any]],
  54. params: AgentParams,
  55. tool_choice: dict[str, Any] | None = None,
  56. ) -> AsyncIterator[StreamItem]:
  57. del messages, tools, params
  58. self.calls += 1
  59. if self.calls == 1:
  60. assert tool_choice is None
  61. yield StreamItem.raw_response_chunk({"chat": 1})
  62. yield StreamItem.message_delta("I will set the volume to 30.")
  63. yield StreamItem.text_event(
  64. ToolCallEvent(
  65. id="volume-1",
  66. name="device.volume.adjust",
  67. arguments={"mode": "absolute"},
  68. raw_arguments='{"mode":"absolute"}',
  69. )
  70. )
  71. yield StreamItem.usage_item(TokenUsage(total_tokens=5))
  72. return
  73. if self.calls == 2:
  74. assert tool_choice is not None
  75. yield StreamItem.raw_response_chunk({"fallback": 1})
  76. yield StreamItem.provider_tool_call(
  77. ToolCallEvent(
  78. id="fallback-volume",
  79. name="device.volume.adjust",
  80. arguments={"mode": "absolute", "value": 30},
  81. raw_arguments='{"mode":"absolute","value":30}',
  82. )
  83. )
  84. yield StreamItem.usage_item(TokenUsage(total_tokens=7))
  85. return
  86. raise RuntimeError("unexpected fallback client call")
  87. async def aclose(self) -> None:
  88. self.closed = True
  89. class RecordingSQLiteSessionStore(SQLiteSessionStore):
  90. instances: list["RecordingSQLiteSessionStore"] = []
  91. def __init__(self, database_path: str) -> None:
  92. super().__init__(database_path)
  93. self.instances.append(self)
  94. class BlockingTimingClient:
  95. def __init__(self, clock: ManualClock) -> None:
  96. self.clock = clock
  97. self.blocked = asyncio.Event()
  98. self.closed = False
  99. async def stream_chat(
  100. self,
  101. messages: list[ChatMessage],
  102. tools: list[dict[str, Any]],
  103. params: AgentParams,
  104. tool_choice: dict[str, Any] | None = None,
  105. ) -> AsyncIterator[StreamItem]:
  106. del messages, tools, params, tool_choice
  107. self.clock.now = 0.010
  108. yield StreamItem.raw_response_chunk({"first": True})
  109. self.blocked.set()
  110. await asyncio.Event().wait()
  111. async def aclose(self) -> None:
  112. self.closed = True
  113. class NoAnswerClient:
  114. def __init__(self) -> None:
  115. self.closed = False
  116. async def stream_chat(
  117. self,
  118. messages: list[ChatMessage],
  119. tools: list[dict[str, Any]],
  120. params: AgentParams,
  121. tool_choice: dict[str, Any] | None = None,
  122. ) -> AsyncIterator[StreamItem]:
  123. del messages, tools, params, tool_choice
  124. yield StreamItem.raw_response_chunk({"no_answer": True})
  125. yield StreamItem.usage_item(TokenUsage(total_tokens=1))
  126. async def aclose(self) -> None:
  127. self.closed = True
  128. class ConcurrentTimingClient:
  129. def __init__(self) -> None:
  130. self.calls = 0
  131. self.first_started = asyncio.Event()
  132. self.release_first = asyncio.Event()
  133. async def stream_chat(
  134. self,
  135. messages: list[ChatMessage],
  136. tools: list[dict[str, Any]],
  137. params: AgentParams,
  138. tool_choice: dict[str, Any] | None = None,
  139. ) -> AsyncIterator[StreamItem]:
  140. del messages, tools, params, tool_choice
  141. self.calls += 1
  142. call_number = self.calls
  143. yield StreamItem.raw_response_chunk({"call": call_number})
  144. if call_number == 1:
  145. self.first_started.set()
  146. await self.release_first.wait()
  147. yield StreamItem.usage_item(TokenUsage(total_tokens=call_number))
  148. async def aclose(self) -> None:
  149. return None
  150. def test_benchmark_result_models_are_strict_and_allow_nullable_metrics():
  151. timing_type = getattr(benchmark, "BenchmarkModelCallTiming")
  152. result_type = getattr(benchmark, "BenchmarkRunResult")
  153. timing = timing_type(
  154. call_index=1,
  155. call_kind="chat_completion",
  156. first_item_kind="raw_chunk",
  157. provider_ttft_ms=11,
  158. visible_ttft_ms=37,
  159. elapsed_ms=59,
  160. usage=TokenUsage(
  161. prompt_tokens=2,
  162. completion_tokens=3,
  163. total_tokens=5,
  164. cached_tokens=1,
  165. ),
  166. )
  167. result = result_type(
  168. case_id=benchmark.BenchmarkCaseId.ORDINARY_CHAT,
  169. mode=benchmark.BenchmarkMode.DUAL_AGENT,
  170. iteration=1,
  171. status="failed",
  172. initial_provider_ttft_ms=None,
  173. visible_ttft_ms=None,
  174. turn_wall_time_ms=None,
  175. prompt_tokens=None,
  176. completion_tokens=None,
  177. total_tokens=None,
  178. cached_tokens=None,
  179. model_call_count=None,
  180. fallback_count=None,
  181. tool_count=None,
  182. event_names=[],
  183. event_sources=[],
  184. tool_statuses=[],
  185. tool_latencies_ms=[],
  186. semantic_failures=["missing visible answer"],
  187. error=None,
  188. model_calls=[timing],
  189. )
  190. assert result.model_calls == [timing]
  191. assert result.initial_provider_ttft_ms is None
  192. with pytest.raises(ValidationError):
  193. timing_type(
  194. call_index="1",
  195. call_kind="chat_completion",
  196. first_item_kind=None,
  197. provider_ttft_ms=None,
  198. visible_ttft_ms=None,
  199. elapsed_ms=None,
  200. usage=None,
  201. )
  202. with pytest.raises(ValidationError):
  203. result_type.model_validate(result.model_dump() | {"unexpected": True})
  204. @pytest.mark.asyncio
  205. async def test_timing_client_keeps_provider_visible_and_elapsed_timings_separate():
  206. clock = ManualClock()
  207. inner = ScriptedTimingClient(clock)
  208. client = benchmark.TimingChatClient(inner, clock=clock)
  209. items = [
  210. item
  211. async for item in client.stream_chat(
  212. messages=[],
  213. tools=[],
  214. params=AgentParams(model="benchmark-model"),
  215. )
  216. ]
  217. assert [item.kind for item in items] == [
  218. "raw_chunk",
  219. "message_delta",
  220. "usage",
  221. "usage",
  222. ]
  223. assert client.timings == [
  224. benchmark.BenchmarkModelCallTiming(
  225. call_index=1,
  226. call_kind="chat_completion",
  227. first_item_kind="raw_chunk",
  228. provider_ttft_ms=11,
  229. visible_ttft_ms=37,
  230. elapsed_ms=71,
  231. usage=TokenUsage(total_tokens=5),
  232. )
  233. ]
  234. @pytest.mark.asyncio
  235. async def test_timing_client_records_failed_fallback_and_closes_inner_client():
  236. clock = ManualClock()
  237. inner = ScriptedTimingClient(clock, fail=True)
  238. client = benchmark.TimingChatClient(inner, clock=clock)
  239. with pytest.raises(RuntimeError, match="stream failed"):
  240. async for _ in client.stream_chat(
  241. messages=[],
  242. tools=[{"type": "function", "function": {"name": "mock"}}],
  243. params=AgentParams(model="benchmark-model"),
  244. tool_choice={"type": "function", "function": {"name": "mock"}},
  245. ):
  246. pass
  247. await client.aclose()
  248. assert inner.closed is True
  249. assert client.timings == [
  250. benchmark.BenchmarkModelCallTiming(
  251. call_index=1,
  252. call_kind="argument_fallback",
  253. first_item_kind="raw_chunk",
  254. provider_ttft_ms=11,
  255. visible_ttft_ms=None,
  256. elapsed_ms=23,
  257. usage=None,
  258. )
  259. ]
  260. @pytest.mark.asyncio
  261. async def test_timing_client_finishes_timing_when_stream_is_cancelled():
  262. clock = ManualClock()
  263. inner = BlockingTimingClient(clock)
  264. client = benchmark.TimingChatClient(inner, clock=clock)
  265. async def consume() -> None:
  266. async for _ in client.stream_chat(
  267. messages=[],
  268. tools=[],
  269. params=AgentParams(model="benchmark-model"),
  270. ):
  271. pass
  272. task = asyncio.create_task(consume())
  273. await inner.blocked.wait()
  274. clock.now = 0.025
  275. task.cancel()
  276. await asyncio.gather(task, return_exceptions=True)
  277. await client.aclose()
  278. assert client.timings[0].first_item_kind == "raw_chunk"
  279. assert client.timings[0].provider_ttft_ms == 10
  280. assert client.timings[0].visible_ttft_ms is None
  281. assert client.timings[0].elapsed_ms == 25
  282. assert inner.closed is True
  283. @pytest.mark.asyncio
  284. async def test_timing_client_indexes_concurrent_calls_by_start_order():
  285. inner = ConcurrentTimingClient()
  286. client = benchmark.TimingChatClient(inner)
  287. async def collect() -> None:
  288. async for _ in client.stream_chat(
  289. messages=[],
  290. tools=[],
  291. params=AgentParams(model="benchmark-model"),
  292. ):
  293. pass
  294. first = asyncio.create_task(collect())
  295. await inner.first_started.wait()
  296. await collect()
  297. inner.release_first.set()
  298. await first
  299. assert [timing.call_index for timing in client.timings] == [1, 2]
  300. assert [timing.usage.total_tokens for timing in client.timings] == [1, 2]
  301. @pytest.mark.asyncio
  302. @pytest.mark.parametrize("mode", benchmark.BENCHMARK_MODES)
  303. @pytest.mark.parametrize("case_id", benchmark.BENCHMARK_CASE_IDS)
  304. async def test_mock_benchmark_client_emits_raw_semantics_and_fixed_usage(
  305. case_id: benchmark.BenchmarkCaseId,
  306. mode: benchmark.BenchmarkMode,
  307. ):
  308. case = benchmark.BENCHMARK_CASE_CATALOG[case_id]
  309. client = benchmark.MockBenchmarkChatClient(case, mode)
  310. expected_rounds = benchmark.build_mock_rounds(case, mode)
  311. emitted_rounds = []
  312. for _ in expected_rounds:
  313. emitted_rounds.append(
  314. [
  315. item
  316. async for item in client.stream_chat(
  317. messages=[],
  318. tools=[],
  319. params=AgentParams(model="benchmark-model"),
  320. )
  321. ]
  322. )
  323. await client.aclose()
  324. assert [items[0].kind for items in emitted_rounds] == [
  325. "raw_chunk"
  326. ] * len(expected_rounds)
  327. assert [items[1:-1] for items in emitted_rounds] == expected_rounds
  328. assert [items[-1].usage for items in emitted_rounds] == [
  329. TokenUsage(
  330. prompt_tokens=10,
  331. completion_tokens=5,
  332. total_tokens=15,
  333. cached_tokens=2,
  334. )
  335. ] * len(expected_rounds)
  336. with pytest.raises(RuntimeError, match=f"mock benchmark stream exhausted: {case_id}"):
  337. async for _ in client.stream_chat(
  338. messages=[],
  339. tools=[],
  340. params=AgentParams(model="benchmark-model"),
  341. ):
  342. pass
  343. @pytest.mark.asyncio
  344. async def test_mock_runner_is_ordered_ignores_factory_and_reports_event_ledgers():
  345. config = benchmark.BenchmarkConfig(
  346. schema_version=1,
  347. base_url="https://provider.example/v1",
  348. model="benchmark-model",
  349. runs_per_case=2,
  350. cases=[
  351. benchmark.BenchmarkCaseId.WEB_SEARCH_TWO_ANSWERS,
  352. benchmark.BenchmarkCaseId.ORDINARY_CHAT,
  353. ],
  354. modes=[
  355. benchmark.BenchmarkMode.CHAT_AGENT_TOOLS,
  356. benchmark.BenchmarkMode.DUAL_AGENT,
  357. ],
  358. )
  359. factory_calls = 0
  360. def forbidden_factory(config: benchmark.BenchmarkConfig, api_key: str):
  361. del config, api_key
  362. nonlocal factory_calls
  363. factory_calls += 1
  364. raise AssertionError("mock mode must not call client_factory")
  365. results = await benchmark.BenchmarkRunner(
  366. config,
  367. api_key=None,
  368. mock=True,
  369. client_factory=forbidden_factory,
  370. ).run()
  371. assert factory_calls == 0
  372. assert [(item.case_id, item.mode, item.iteration) for item in results] == [
  373. (case_id, mode, iteration)
  374. for case_id in config.cases
  375. for mode in config.modes
  376. for iteration in range(1, 3)
  377. ]
  378. assert {item.status for item in results} == {"passed"}
  379. search_results = [
  380. item
  381. for item in results
  382. if item.case_id is benchmark.BenchmarkCaseId.WEB_SEARCH_TWO_ANSWERS
  383. ]
  384. assert {item.model_call_count for item in search_results} == {2}
  385. assert {item.fallback_count for item in search_results} == {0}
  386. assert {item.tool_count for item in search_results} == {1}
  387. assert {tuple(item.event_names) for item in search_results} == {
  388. ("knowledge.web.search",)
  389. }
  390. assert {tuple(item.tool_statuses) for item in search_results} == {("success",)}
  391. assert {
  392. tuple(item.event_sources) for item in search_results
  393. } == {("provider_resolved",), ("text_event",)}
  394. assert all(len(item.tool_latencies_ms) == 1 for item in search_results)
  395. assert all(
  396. latency is not None and latency >= 0
  397. for item in search_results
  398. for latency in item.tool_latencies_ms
  399. )
  400. ordinary_results = [
  401. item
  402. for item in results
  403. if item.case_id is benchmark.BenchmarkCaseId.ORDINARY_CHAT
  404. ]
  405. assert all(item.event_names == [] for item in ordinary_results)
  406. assert all(item.tool_count == 0 for item in ordinary_results)
  407. @pytest.mark.asyncio
  408. async def test_mock_runner_passes_all_catalog_cases_in_both_modes():
  409. config = benchmark.BenchmarkConfig(
  410. schema_version=1,
  411. base_url="https://provider.example/v1",
  412. model="benchmark-model",
  413. )
  414. results = await benchmark.BenchmarkRunner(config, api_key=None, mock=True).run()
  415. assert len(results) == 12
  416. assert all(result.status == "passed" for result in results)
  417. assert all(result.semantic_failures == [] for result in results)
  418. @pytest.mark.asyncio
  419. async def test_runner_uses_a_fresh_in_memory_store_and_unique_session_per_run(monkeypatch):
  420. RecordingSQLiteSessionStore.instances = []
  421. monkeypatch.setattr(benchmark, "SQLiteSessionStore", RecordingSQLiteSessionStore)
  422. config = benchmark.BenchmarkConfig(
  423. schema_version=1,
  424. base_url="https://provider.example/v1",
  425. model="benchmark-model",
  426. runs_per_case=2,
  427. cases=[benchmark.BenchmarkCaseId.ORDINARY_CHAT],
  428. modes=[benchmark.BenchmarkMode.DUAL_AGENT],
  429. )
  430. results = await benchmark.BenchmarkRunner(config, api_key=None, mock=True).run()
  431. assert len(results) == 2
  432. assert len(RecordingSQLiteSessionStore.instances) == 2
  433. sessions = [store.list_sessions() for store in RecordingSQLiteSessionStore.instances]
  434. assert all(store.database_path == ":memory:" for store in RecordingSQLiteSessionStore.instances)
  435. assert [len(records) for records in sessions] == [1, 1]
  436. assert len({records[0]["id"] for records in sessions}) == 2
  437. @pytest.mark.asyncio
  438. async def test_runner_keeps_provider_visible_and_turn_wall_time_distinct():
  439. clock = ManualClock()
  440. client = ScriptedTimingClient(clock)
  441. config = benchmark.BenchmarkConfig(
  442. schema_version=1,
  443. base_url="https://provider.example/v1",
  444. model="benchmark-model",
  445. cases=[benchmark.BenchmarkCaseId.ORDINARY_CHAT],
  446. modes=[benchmark.BenchmarkMode.DUAL_AGENT],
  447. )
  448. result = (
  449. await benchmark.BenchmarkRunner(
  450. config,
  451. api_key="test-key",
  452. client_factory=lambda config, api_key: client,
  453. clock=clock,
  454. ).run()
  455. )[0]
  456. assert result.status == "passed"
  457. assert result.initial_provider_ttft_ms == 11
  458. assert result.visible_ttft_ms == 37
  459. assert result.turn_wall_time_ms == 71
  460. assert result.model_calls[0].elapsed_ms == 71
  461. assert client.closed is True
  462. @pytest.mark.asyncio
  463. async def test_runner_continues_after_one_factory_failure():
  464. config = benchmark.BenchmarkConfig(
  465. schema_version=1,
  466. base_url="https://provider.example/v1",
  467. model="benchmark-model",
  468. cases=[benchmark.BenchmarkCaseId.ORDINARY_CHAT],
  469. modes=[
  470. benchmark.BenchmarkMode.DUAL_AGENT,
  471. benchmark.BenchmarkMode.CHAT_AGENT_TOOLS,
  472. ],
  473. )
  474. clients: list[ScriptedTimingClient] = []
  475. calls = 0
  476. def factory(config: benchmark.BenchmarkConfig, api_key: str):
  477. del config, api_key
  478. nonlocal calls
  479. calls += 1
  480. if calls == 1:
  481. raise RuntimeError("first factory failed")
  482. clock = ManualClock()
  483. client = ScriptedTimingClient(clock)
  484. clients.append(client)
  485. return client
  486. results = await benchmark.BenchmarkRunner(
  487. config,
  488. api_key="test-key",
  489. client_factory=factory,
  490. ).run()
  491. assert [result.status for result in results] == ["failed", "passed"]
  492. assert results[0].error == "first factory failed"
  493. assert results[1].error is None
  494. assert clients[0].closed is True
  495. @pytest.mark.asyncio
  496. async def test_runner_marks_semantic_mismatch_failed_and_continues():
  497. config = benchmark.BenchmarkConfig(
  498. schema_version=1,
  499. base_url="https://provider.example/v1",
  500. model="benchmark-model",
  501. cases=[benchmark.BenchmarkCaseId.ORDINARY_CHAT],
  502. modes=[
  503. benchmark.BenchmarkMode.DUAL_AGENT,
  504. benchmark.BenchmarkMode.CHAT_AGENT_TOOLS,
  505. ],
  506. )
  507. failed_client = NoAnswerClient()
  508. calls = 0
  509. def factory(config: benchmark.BenchmarkConfig, api_key: str):
  510. del config, api_key
  511. nonlocal calls
  512. calls += 1
  513. if calls == 1:
  514. return failed_client
  515. return benchmark.MockBenchmarkChatClient(
  516. benchmark.BENCHMARK_CASE_CATALOG[benchmark.BenchmarkCaseId.ORDINARY_CHAT],
  517. benchmark.BenchmarkMode.CHAT_AGENT_TOOLS,
  518. )
  519. results = await benchmark.BenchmarkRunner(
  520. config,
  521. api_key="test-key",
  522. client_factory=factory,
  523. ).run()
  524. assert [result.status for result in results] == ["failed", "passed"]
  525. assert results[0].error is None
  526. assert results[0].semantic_failures == ["answer_count expected 1, got 0"]
  527. assert failed_client.closed is True
  528. @pytest.mark.asyncio
  529. async def test_runner_reports_argument_fallback_model_and_tool_ledgers(monkeypatch):
  530. client = FallbackBenchmarkClient()
  531. registry = ToolRegistry(
  532. [
  533. ToolDefinition(
  534. name="device.volume.adjust",
  535. description="Force fallback for benchmark ledger coverage.",
  536. parameters={
  537. "type": "object",
  538. "properties": {
  539. "mode": {"type": "string"},
  540. "value": {"type": "integer"},
  541. },
  542. "required": ["mode", "value"],
  543. },
  544. handler=lambda event: {
  545. "tool": event.name,
  546. "status": "payload-status-is-not-kernel-status",
  547. },
  548. argument_resolver=lambda event, context: {},
  549. result_policy=ResultPolicy.SILENT_SUCCESS,
  550. )
  551. ]
  552. )
  553. monkeypatch.setattr(benchmark, "build_default_tool_registry", lambda: registry)
  554. config = benchmark.BenchmarkConfig(
  555. schema_version=1,
  556. base_url="https://provider.example/v1",
  557. model="benchmark-model",
  558. cases=[benchmark.BenchmarkCaseId.DEVICE_VOLUME_SILENT],
  559. modes=[benchmark.BenchmarkMode.DUAL_AGENT],
  560. )
  561. result = (
  562. await benchmark.BenchmarkRunner(
  563. config,
  564. api_key="test-key",
  565. client_factory=lambda config, api_key: client,
  566. ).run()
  567. )[0]
  568. assert result.status == "passed"
  569. assert result.model_call_count == 1
  570. assert result.fallback_count == 1
  571. assert result.tool_count == 1
  572. assert result.total_tokens == 12
  573. assert [timing.call_kind for timing in result.model_calls] == [
  574. "chat_completion",
  575. "argument_fallback",
  576. ]
  577. assert result.event_names == ["device.volume.adjust"]
  578. assert result.event_sources == ["text_event"]
  579. assert result.tool_statuses == ["success"]
  580. assert client.calls == 2
  581. assert client.closed is True
  582. def test_live_runner_requires_an_api_key():
  583. config = benchmark.BenchmarkConfig(
  584. schema_version=1,
  585. base_url="https://provider.example/v1",
  586. model="benchmark-model",
  587. cases=[benchmark.BenchmarkCaseId.ORDINARY_CHAT],
  588. modes=[benchmark.BenchmarkMode.DUAL_AGENT],
  589. )
  590. with pytest.raises(ValueError, match="api_key"):
  591. benchmark.BenchmarkRunner(config, api_key=None)
  592. def test_visible_answer_order_ignores_empty_deltas_before_a_tool_result():
  593. answers, first_answer_index, first_tool_index = (
  594. benchmark.BenchmarkRunner._visible_answers(
  595. [
  596. {"type": "message_delta", "content": " "},
  597. {"type": "tool_result", "message": {}},
  598. {"type": "message_delta", "content": "late answer"},
  599. ]
  600. )
  601. )
  602. assert answers == ["late answer"]
  603. assert first_answer_index == 2
  604. assert first_tool_index == 1