test_websocket_api.py 67 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974
  1. import json
  2. import asyncio
  3. import logging
  4. import subprocess
  5. from collections.abc import AsyncIterator
  6. from pathlib import Path
  7. import httpx
  8. import pytest
  9. from fastapi.testclient import TestClient
  10. from pydantic import ValidationError
  11. from agent_lab.application.contracts import AgentParams, DebugRunRequest, EventAgentParams
  12. from agent_lab.application.queues import RuntimeQueues
  13. from agent_lab.application.runtime import DebugRuntime
  14. from agent_lab.domain.events import ToolCallEvent
  15. from agent_lab.domain.messages import ChatMessage, StreamItem, TokenUsage
  16. from agent_lab.infrastructure.chat_client import OpenAICompatibleChatClient
  17. from agent_lab.infrastructure.sqlite_store import SQLiteSessionStore
  18. from agent_lab.presentation.web import create_app
  19. from agent_lab.settings import Settings
  20. def _css_rule(css: str, selector: str) -> str:
  21. start = css.index(f"{selector} {{")
  22. end = css.index("}", start)
  23. return css[start:end]
  24. def _run_node_json(source: str) -> object:
  25. completed = subprocess.run(
  26. ["node", "--input-type=module", "-e", source],
  27. capture_output=True,
  28. text=True,
  29. check=False,
  30. )
  31. assert completed.returncode == 0, completed.stderr
  32. return json.loads(completed.stdout)
  33. def _run_load_session_replay_scenario(scenario: str) -> object:
  34. js = Path("src/agent_lab/presentation/static/app.js").read_text()
  35. load_replay = js[
  36. js.index("async function loadSessionReplay(") :
  37. js.index("function setSessionStatus(")
  38. ]
  39. harness = """
  40. let currentSessionId = null;
  41. let sessionReplayLoadToken = 0;
  42. const sessionTitle = { value: "" };
  43. const sessionList = { value: "" };
  44. const mutations = {
  45. workspace: [],
  46. transcript: [],
  47. audit: [],
  48. usage: [],
  49. status: [],
  50. };
  51. const pending = new Map();
  52. function fetchJson(url) {
  53. let resolve;
  54. let reject;
  55. const promise = new Promise((resolvePromise, rejectPromise) => {
  56. resolve = resolvePromise;
  57. reject = rejectPromise;
  58. });
  59. pending.set(url, { resolve, reject });
  60. return promise;
  61. }
  62. function resolveReplay(sessionId) {
  63. pending.get(`/api/sessions/${sessionId}`).resolve({
  64. title: `${sessionId} title`,
  65. config: { marker: sessionId },
  66. });
  67. pending.get(`/api/sessions/${sessionId}/messages`).resolve([
  68. { content: sessionId },
  69. ]);
  70. pending.get(`/api/sessions/${sessionId}/audit`).resolve([
  71. { event: sessionId },
  72. ]);
  73. pending.get(`/api/sessions/${sessionId}/usage`).resolve({
  74. session: { marker: sessionId },
  75. });
  76. }
  77. function restoreWorkspaceSnapshot(config) {
  78. mutations.workspace.push(config.marker);
  79. }
  80. function renderReplayMessages(messages) {
  81. mutations.transcript.push(messages[0].content);
  82. }
  83. function renderAuditReplay(audit) {
  84. mutations.audit.push(audit[0].event);
  85. }
  86. function renderSessionUsage(usage) {
  87. mutations.usage.push(usage.session.marker);
  88. }
  89. function setSessionStatus(status) {
  90. mutations.status.push(status);
  91. }
  92. function isSocketOpen() {
  93. return false;
  94. }
  95. """
  96. return _run_node_json("\n".join([harness, load_replay, scenario]))
  97. class FakeRuntime:
  98. def __init__(self) -> None:
  99. self.requests: list[DebugRunRequest] = []
  100. async def run(self, request: DebugRunRequest) -> AsyncIterator[dict]:
  101. self.requests.append(request)
  102. yield {"type": "session_started"}
  103. yield {"type": "message_delta", "content": "hello"}
  104. yield {"type": "done"}
  105. class QueueAwareRuntime:
  106. def __init__(self) -> None:
  107. self.requests: list[DebugRunRequest] = []
  108. self.queues: RuntimeQueues | None = None
  109. self.task: asyncio.Task | None = None
  110. def start(self, request: DebugRunRequest) -> RuntimeQueues:
  111. self.requests.append(request)
  112. self.queues = RuntimeQueues()
  113. self.task = asyncio.create_task(self._run())
  114. return self.queues
  115. async def _run(self) -> None:
  116. assert self.queues is not None
  117. await self.queues.output.put({"type": "session_started"})
  118. message = await self.queues.input.get()
  119. await self.queues.output.put(
  120. {"type": "message_delta", "content": message.content}
  121. )
  122. await self.queues.output.put({"type": "done"})
  123. async def aclose(self) -> None:
  124. if self.task is not None and not self.task.done():
  125. self.task.cancel()
  126. await asyncio.gather(self.task, return_exceptions=True)
  127. class PersistentSessionRuntime:
  128. def __init__(self) -> None:
  129. self.requests: list[DebugRunRequest] = []
  130. self.queues: RuntimeQueues | None = None
  131. self.task: asyncio.Task | None = None
  132. def start_session(self, request: DebugRunRequest) -> RuntimeQueues:
  133. self.requests.append(request)
  134. self.queues = RuntimeQueues()
  135. self.task = asyncio.create_task(self._run(request))
  136. return self.queues
  137. async def _run(self, request: DebugRunRequest) -> None:
  138. assert self.queues is not None
  139. await self.queues.output.put({"type": "session_started"})
  140. await self._emit_turn(1, request.user_message)
  141. turn_index = 1
  142. while True:
  143. message = await self.queues.input.get()
  144. turn_index += 1
  145. await self._emit_turn(turn_index, message.content)
  146. async def _emit_turn(self, turn_index: int, content: str) -> None:
  147. assert self.queues is not None
  148. await self.queues.output.put({"type": "turn_started", "turn_index": turn_index})
  149. await self.queues.output.put({"type": "message_delta", "content": content})
  150. await self.queues.output.put(
  151. {"type": "turn_completed", "turn_index": turn_index}
  152. )
  153. async def aclose(self) -> None:
  154. if self.task is not None and not self.task.done():
  155. self.task.cancel()
  156. await asyncio.gather(self.task, return_exceptions=True)
  157. def _request_payload() -> dict:
  158. return {
  159. "user_message": "debug this",
  160. "system_prompts": ["You are a debugger."],
  161. "pre_messages": [{"role": "user", "content": "previous turn"}],
  162. "chat_agent": {
  163. "model": "fake-model",
  164. "temperature": 0.1,
  165. "max_tokens": 200,
  166. },
  167. "event_agent": {
  168. "enabled_tools": ["handoff_note"],
  169. "max_event_loops": 2,
  170. },
  171. }
  172. def _event_tool_call_from_tools(
  173. tools: list[dict],
  174. messages: list[ChatMessage],
  175. ) -> StreamItem:
  176. tool_name = tools[0]["function"]["name"]
  177. content = ""
  178. for role in ("assistant", "user"):
  179. content = next(
  180. (
  181. message.content
  182. for message in reversed(messages)
  183. if message.role == role and message.content.strip()
  184. ),
  185. "",
  186. )
  187. if content:
  188. break
  189. arguments = {
  190. "message": content,
  191. "query": content,
  192. "title": content,
  193. }
  194. return StreamItem.provider_tool_call(
  195. ToolCallEvent(
  196. id="event_agent_call_1",
  197. name=tool_name,
  198. arguments=arguments,
  199. raw_arguments=json.dumps(arguments),
  200. )
  201. )
  202. def test_agent_params_defaults_include_extra_body_and_event_agent_defaults_to_one_round():
  203. chat_params = AgentParams()
  204. event_params = EventAgentParams()
  205. assert chat_params.extra_body == {
  206. "thinking": {"type": "disabled"},
  207. "enable_search": False,
  208. "search_options": {"forced_search": False},
  209. }
  210. assert event_params.extra_body == chat_params.extra_body
  211. assert event_params.max_event_loops == 1
  212. assert event_params.max_parallel_events == 4
  213. assert event_params.batch_timeout_seconds == 15.0
  214. assert event_params.system_prompt == ""
  215. @pytest.mark.parametrize("value", [0, 17])
  216. def test_event_agent_params_rejects_out_of_range_parallelism(value: int):
  217. with pytest.raises(ValidationError, match="max_parallel_events"):
  218. EventAgentParams(max_parallel_events=value)
  219. @pytest.mark.parametrize("value", [0, -1, 121])
  220. def test_event_agent_params_rejects_invalid_batch_timeout(value: float):
  221. with pytest.raises(ValidationError, match="batch_timeout_seconds"):
  222. EventAgentParams(batch_timeout_seconds=value)
  223. def test_debug_run_request_defaults_to_dual_agent_tool_invocation_mode():
  224. request = DebugRunRequest.model_validate(_request_payload())
  225. assert request.tool_invocation_mode == "dual_agent"
  226. @pytest.mark.parametrize("mode", ["dual_agent", "chat_agent_tools"])
  227. def test_debug_run_request_accepts_supported_tool_invocation_modes(mode: str):
  228. payload = _request_payload()
  229. payload["tool_invocation_mode"] = mode
  230. request = DebugRunRequest.model_validate(payload)
  231. assert request.tool_invocation_mode == mode
  232. def test_debug_run_request_rejects_unknown_tool_invocation_mode():
  233. payload = _request_payload()
  234. payload["tool_invocation_mode"] = "unknown"
  235. with pytest.raises(ValidationError, match="tool_invocation_mode"):
  236. DebugRunRequest.model_validate(payload)
  237. def test_health_returns_ok():
  238. app = create_app(runtime_factory=FakeRuntime)
  239. client = TestClient(app)
  240. response = client.get("/health")
  241. assert response.status_code == 200
  242. assert response.json() == {"status": "ok"}
  243. def test_api_tools_returns_default_tool_catalog():
  244. app = create_app(runtime_factory=FakeRuntime)
  245. client = TestClient(app)
  246. response = client.get("/api/tools")
  247. assert response.status_code == 200
  248. tools = response.json()
  249. assert [tool["name"] for tool in tools] == [
  250. "handoff_note",
  251. "mock_search",
  252. "mock_ticket",
  253. "session.terminate",
  254. "device.volume.adjust",
  255. "calendar.schedule.create",
  256. "knowledge.web.search",
  257. ]
  258. assert tools[0]["parameters"]["required"] == ["message"]
  259. def test_session_api_creates_lists_and_returns_empty_replay_data(tmp_path):
  260. settings = Settings(database_path=str(tmp_path / "agent_lab.sqlite3"))
  261. app = create_app(settings=settings, runtime_factory=FakeRuntime)
  262. client = TestClient(app)
  263. created = client.post(
  264. "/api/sessions",
  265. json={
  266. "title": "Debug session",
  267. "config": {"chat_agent": {"model": "chat-model"}},
  268. },
  269. )
  270. assert created.status_code == 200
  271. session_id = created.json()["id"]
  272. assert created.json()["title"] == "Debug session"
  273. sessions = client.get("/api/sessions")
  274. assert sessions.status_code == 200
  275. assert sessions.json()[0]["id"] == session_id
  276. assert sessions.json()[0]["turn_count"] == 0
  277. detail = client.get(f"/api/sessions/{session_id}")
  278. assert detail.status_code == 200
  279. assert detail.json()["config"]["chat_agent"]["model"] == "chat-model"
  280. assert client.get(f"/api/sessions/{session_id}/messages").json() == []
  281. assert client.get(f"/api/sessions/{session_id}/audit").json() == []
  282. usage = client.get(f"/api/sessions/{session_id}/usage").json()
  283. assert usage == {
  284. "calls": [],
  285. "turns": [],
  286. "session": {
  287. "prompt_tokens": 0,
  288. "completion_tokens": 0,
  289. "total_tokens": 0,
  290. "cached_tokens": 0,
  291. "elapsed_ms": 0,
  292. "call_count": 0,
  293. "fallback_count": 0,
  294. "tool_count": 0,
  295. "turn_wall_time_ms": None,
  296. },
  297. }
  298. def test_session_api_returns_persisted_replay_data(tmp_path):
  299. database_path = tmp_path / "agent_lab.sqlite3"
  300. settings = Settings(database_path=str(database_path))
  301. app = create_app(settings=settings, runtime_factory=FakeRuntime)
  302. client = TestClient(app)
  303. session_id = client.post("/api/sessions", json={"title": "Replay"}).json()["id"]
  304. store = SQLiteSessionStore(database_path)
  305. store.start_turn(session_id, turn_index=1, user_message="debug this")
  306. store.append_message(
  307. session_id,
  308. turn_index=1,
  309. message=ChatMessage(role="user", content="debug this"),
  310. )
  311. store.append_message(
  312. session_id,
  313. turn_index=1,
  314. message=ChatMessage(
  315. role="assistant",
  316. content="answer",
  317. tool_calls=[
  318. ToolCallEvent(
  319. id="call-1",
  320. name="mock_search",
  321. arguments={"query": "docs"},
  322. raw_arguments='{"query":"docs"}',
  323. )
  324. ],
  325. ),
  326. )
  327. store.append_message(
  328. session_id,
  329. turn_index=1,
  330. message=ChatMessage(
  331. role="tool",
  332. content='{"results":[]}',
  333. name="mock_search",
  334. tool_call_id="call-1",
  335. ),
  336. )
  337. store.append_audit(
  338. session_id,
  339. event="chat_agent_request",
  340. details={"model": "chat-model"},
  341. turn_index=1,
  342. round_index=1,
  343. )
  344. store.append_usage(
  345. session_id,
  346. turn_index=1,
  347. round_index=1,
  348. usage=TokenUsage(prompt_tokens=1, completion_tokens=2, total_tokens=3),
  349. ttft_ms=10,
  350. elapsed_ms=20,
  351. )
  352. messages = client.get(f"/api/sessions/{session_id}/messages").json()
  353. assert [message["content"] for message in messages] == [
  354. "debug this",
  355. "answer",
  356. '{"results":[]}',
  357. ]
  358. assert messages[0]["tool_calls"] == []
  359. assert messages[1]["tool_calls"] == [
  360. {
  361. "id": "call-1",
  362. "name": "mock_search",
  363. "arguments": {"query": "docs"},
  364. "raw_arguments": '{"query":"docs"}',
  365. }
  366. ]
  367. assert messages[2]["tool_call_id"] == "call-1"
  368. assert messages[2]["tool_calls"] == []
  369. assert client.get(f"/api/sessions/{session_id}/audit").json()[0]["details"] == {
  370. "model": "chat-model"
  371. }
  372. usage = client.get(f"/api/sessions/{session_id}/usage").json()
  373. assert usage["calls"][0]["total_tokens"] == 3
  374. assert usage["turns"][0]["total_tokens"] == 3
  375. assert usage["session"]["total_tokens"] == 3
  376. def test_default_websocket_runtime_factory_accepts_session_store(tmp_path):
  377. settings = Settings(database_path=str(tmp_path / "agent_lab.sqlite3"))
  378. app = create_app(settings=settings)
  379. client = TestClient(app)
  380. with client.websocket_connect("/ws/debug") as websocket:
  381. websocket.send_json({"chat_agent": {"model": "fake-model"}})
  382. message = websocket.receive_json()
  383. assert message["type"] == "error"
  384. assert "user_message" in message["message"]
  385. def test_websocket_debug_streams_runtime_messages():
  386. runtime = FakeRuntime()
  387. app = create_app(runtime_factory=lambda: runtime)
  388. client = TestClient(app)
  389. with client.websocket_connect("/ws/debug") as websocket:
  390. websocket.send_json(_request_payload())
  391. assert websocket.receive_json() == {"type": "session_started"}
  392. assert websocket.receive_json() == {
  393. "type": "message_delta",
  394. "content": "hello",
  395. }
  396. assert websocket.receive_json() == {"type": "done"}
  397. assert runtime.requests[0].user_message == "debug this"
  398. assert runtime.requests[0].pre_messages[0].content == "previous turn"
  399. def test_websocket_debug_logs_session_lifecycle(caplog):
  400. runtime = FakeRuntime()
  401. app = create_app(runtime_factory=lambda: runtime)
  402. client = TestClient(app)
  403. with caplog.at_level(logging.INFO, logger="agent_lab.presentation.web"):
  404. with client.websocket_connect("/ws/debug") as websocket:
  405. websocket.send_json(_request_payload())
  406. assert websocket.receive_json() == {"type": "session_started"}
  407. assert websocket.receive_json() == {
  408. "type": "message_delta",
  409. "content": "hello",
  410. }
  411. assert websocket.receive_json() == {"type": "done"}
  412. assert "websocket session accepted" in caplog.text
  413. assert "websocket request accepted" in caplog.text
  414. assert "websocket session closed" in caplog.text
  415. def test_websocket_debug_enqueues_user_messages_during_running_session():
  416. runtime = QueueAwareRuntime()
  417. app = create_app(runtime_factory=lambda: runtime)
  418. client = TestClient(app)
  419. with client.websocket_connect("/ws/debug") as websocket:
  420. websocket.send_json(_request_payload())
  421. assert websocket.receive_json() == {"type": "session_started"}
  422. websocket.send_json({"type": "user_message", "content": "follow-up"})
  423. assert websocket.receive_json() == {
  424. "type": "message_delta",
  425. "content": "follow-up",
  426. }
  427. assert websocket.receive_json() == {"type": "done"}
  428. assert runtime.requests[0].user_message == "debug this"
  429. def test_websocket_debug_keeps_session_open_for_multiple_turns():
  430. runtime = PersistentSessionRuntime()
  431. app = create_app(runtime_factory=lambda: runtime)
  432. client = TestClient(app)
  433. with client.websocket_connect("/ws/debug") as websocket:
  434. websocket.send_json(_request_payload())
  435. assert websocket.receive_json() == {"type": "session_started"}
  436. assert websocket.receive_json() == {"type": "turn_started", "turn_index": 1}
  437. assert websocket.receive_json() == {
  438. "type": "message_delta",
  439. "content": "debug this",
  440. }
  441. assert websocket.receive_json() == {"type": "turn_completed", "turn_index": 1}
  442. websocket.send_json({"type": "user_message", "content": "follow-up"})
  443. assert websocket.receive_json() == {"type": "turn_started", "turn_index": 2}
  444. assert websocket.receive_json() == {
  445. "type": "message_delta",
  446. "content": "follow-up",
  447. }
  448. assert websocket.receive_json() == {"type": "turn_completed", "turn_index": 2}
  449. websocket.close()
  450. assert runtime.requests[0].user_message == "debug this"
  451. def test_websocket_debug_sends_error_for_invalid_request():
  452. app = create_app(runtime_factory=FakeRuntime)
  453. client = TestClient(app)
  454. with client.websocket_connect("/ws/debug") as websocket:
  455. websocket.send_json({"chat_agent": {"model": "fake-model"}})
  456. message = websocket.receive_json()
  457. assert message["type"] == "error"
  458. assert "user_message" in message["message"]
  459. def test_debug_run_request_rejects_invalid_pre_message_role():
  460. payload = _request_payload()
  461. payload["pre_messages"] = [{"role": "developer", "content": "invalid"}]
  462. with pytest.raises(ValidationError) as exc_info:
  463. DebugRunRequest.model_validate(payload)
  464. assert "role" in str(exc_info.value)
  465. def test_debug_run_request_rejects_tool_pre_messages():
  466. payload = _request_payload()
  467. payload["pre_messages"] = [
  468. {
  469. "role": "tool",
  470. "content": "orphan result",
  471. "tool_call_id": "call_1",
  472. }
  473. ]
  474. with pytest.raises(ValidationError, match="pre_messages cannot include tool messages"):
  475. DebugRunRequest.model_validate(payload)
  476. def test_debug_run_request_rejects_assistant_tool_calls_in_pre_messages():
  477. payload = _request_payload()
  478. payload["pre_messages"] = [
  479. {
  480. "role": "assistant",
  481. "content": "I will inspect this.",
  482. "tool_calls": [
  483. {
  484. "id": "call_1",
  485. "name": "mock_search",
  486. "arguments": {"query": "latency"},
  487. "raw_arguments": '{"query":"latency"}',
  488. }
  489. ],
  490. }
  491. ]
  492. with pytest.raises(
  493. ValidationError,
  494. match="pre_messages cannot include assistant tool calls",
  495. ):
  496. DebugRunRequest.model_validate(payload)
  497. def test_chat_message_allows_internal_tool_replies():
  498. message = ChatMessage(
  499. role="tool",
  500. content='{"message":"handled"}',
  501. name="handoff_note",
  502. tool_call_id="call_1",
  503. )
  504. assert message.role == "tool"
  505. assert message.tool_call_id == "call_1"
  506. def test_chat_message_preserves_assistant_tool_calls():
  507. message = ChatMessage(
  508. role="assistant",
  509. content="I will inspect both sources.",
  510. tool_calls=[
  511. ToolCallEvent(
  512. id="call_1",
  513. name="mock_search",
  514. arguments={"query": "latency docs"},
  515. raw_arguments='{"query":"latency docs"}',
  516. ),
  517. ToolCallEvent(
  518. id="call_2",
  519. name="handoff_note",
  520. arguments={"message": "inspect provider behavior"},
  521. raw_arguments='{"message":"inspect provider behavior"}',
  522. ),
  523. ],
  524. )
  525. assert message.model_dump()["tool_calls"] == [
  526. {
  527. "id": "call_1",
  528. "name": "mock_search",
  529. "arguments": {"query": "latency docs"},
  530. "raw_arguments": '{"query":"latency docs"}',
  531. },
  532. {
  533. "id": "call_2",
  534. "name": "handoff_note",
  535. "arguments": {"message": "inspect provider behavior"},
  536. "raw_arguments": '{"message":"inspect provider behavior"}',
  537. },
  538. ]
  539. def test_chat_message_rejects_tool_calls_on_non_assistant_role():
  540. with pytest.raises(ValidationError, match="tool_calls require assistant role"):
  541. ChatMessage(
  542. role="user",
  543. content="invalid",
  544. tool_calls=[
  545. ToolCallEvent(
  546. id="call_1",
  547. name="mock_search",
  548. arguments={},
  549. raw_arguments="{}",
  550. )
  551. ],
  552. )
  553. def test_chat_message_rejects_tool_role_without_tool_call_id():
  554. with pytest.raises(ValidationError, match="tool messages require tool_call_id"):
  555. ChatMessage(role="tool", content="orphan result")
  556. @pytest.mark.parametrize("role", ["system", "user", "assistant"])
  557. def test_chat_message_rejects_tool_call_id_on_non_tool_roles(role: str):
  558. with pytest.raises(ValidationError, match="tool_call_id requires tool role"):
  559. ChatMessage(role=role, content="invalid", tool_call_id="call_1")
  560. def test_chat_message_rejects_duplicate_assistant_tool_call_ids():
  561. with pytest.raises(ValidationError, match="duplicate assistant tool-call ID: call_1"):
  562. ChatMessage(
  563. role="assistant",
  564. content="checking twice",
  565. tool_calls=[
  566. ToolCallEvent(
  567. id="call_1",
  568. name="mock_search",
  569. arguments={"query": "first"},
  570. raw_arguments='{"query":"first"}',
  571. ),
  572. ToolCallEvent(
  573. id="call_1",
  574. name="handoff_note",
  575. arguments={"message": "second"},
  576. raw_arguments='{"message":"second"}',
  577. ),
  578. ],
  579. )
  580. class HistoryCapturingChatClient:
  581. def __init__(self) -> None:
  582. self.calls = 0
  583. self.second_call_messages: list[ChatMessage] = []
  584. async def stream_chat(
  585. self,
  586. messages: list[ChatMessage],
  587. tools: list[dict],
  588. params: AgentParams,
  589. tool_choice: dict | None = None,
  590. ) -> AsyncIterator[StreamItem]:
  591. if tools:
  592. yield _event_tool_call_from_tools(tools, messages)
  593. return
  594. self.calls += 1
  595. if self.calls == 1:
  596. yield StreamItem.message_delta("Need event help.")
  597. yield StreamItem.text_event(
  598. ToolCallEvent(
  599. id="call_1",
  600. name="handoff_note",
  601. arguments={},
  602. raw_arguments="{}",
  603. )
  604. )
  605. return
  606. self.second_call_messages = list(messages)
  607. yield StreamItem.message_delta("Final answer.")
  608. @pytest.mark.asyncio
  609. async def test_runtime_appends_event_summary_without_tool_reply_history():
  610. request = DebugRunRequest(
  611. user_message="debug this",
  612. system_prompts=["You are a debugger."],
  613. pre_messages=[],
  614. chat_agent=AgentParams(model="fake-model", temperature=0.1, max_tokens=200),
  615. event_agent=EventAgentParams(enabled_tools=["handoff_note"], max_event_loops=2),
  616. )
  617. client = HistoryCapturingChatClient()
  618. runtime = DebugRuntime(client)
  619. outputs = [message async for message in runtime.run(request)]
  620. assert client.calls == 2
  621. assert [message.role for message in client.second_call_messages] == [
  622. "system",
  623. "system",
  624. "user",
  625. "assistant",
  626. "user",
  627. ]
  628. assert "Available events:" in client.second_call_messages[1].content
  629. assert client.second_call_messages[3].content == "Need event help."
  630. assert not any(message.role == "tool" for message in client.second_call_messages)
  631. assert client.second_call_messages[4].content.startswith("EventAgent results:\n")
  632. assert client.second_call_messages[4].name == "event_agent"
  633. assert outputs[-1] == {"type": "done"}
  634. @pytest.mark.asyncio
  635. async def test_openai_chat_client_streams_sse_chunks_through_parser():
  636. requests: list[httpx.Request] = []
  637. def handler(request: httpx.Request) -> httpx.Response:
  638. requests.append(request)
  639. payload = json.loads(request.content)
  640. assert payload["stream"] is True
  641. assert payload["model"] == "model-x"
  642. assert payload["messages"] == [{"role": "user", "content": "hi"}]
  643. assert payload["tools"][0]["function"]["name"] == "handoff_note"
  644. assert "tool_choice" not in payload
  645. assert payload["temperature"] == 0.3
  646. assert payload["max_tokens"] == 50
  647. assert request.headers["authorization"] == "Bearer test-key"
  648. return httpx.Response(
  649. 200,
  650. content=(
  651. b'data: {"choices":[{"delta":{"content":"hi"},"finish_reason":null}]}\n\n'
  652. b"data: [DONE]\n\n"
  653. ),
  654. )
  655. async with httpx.AsyncClient(
  656. transport=httpx.MockTransport(handler),
  657. base_url="https://llm.test/v1",
  658. ) as http_client:
  659. client = OpenAICompatibleChatClient(
  660. api_key="test-key",
  661. base_url="https://llm.test/v1",
  662. default_model="default-model",
  663. request_timeout_seconds=5,
  664. http_client=http_client,
  665. )
  666. items = [
  667. item
  668. async for item in client.stream_chat(
  669. messages=[ChatMessage(role="user", content="hi")],
  670. tools=[
  671. {
  672. "type": "function",
  673. "function": {"name": "handoff_note", "parameters": {}},
  674. }
  675. ],
  676. params=AgentParams(model="model-x", temperature=0.3, max_tokens=50),
  677. )
  678. ]
  679. assert requests[0].url.path == "/v1/chat/completions"
  680. assert [item.content for item in items if item.kind == "message_delta"] == ["hi"]
  681. assert [item.raw_chunk for item in items if item.kind == "raw_chunk"] == [
  682. {"choices": [{"delta": {"content": "hi"}, "finish_reason": None}]}
  683. ]
  684. @pytest.mark.asyncio
  685. async def test_openai_chat_client_serializes_explicit_tool_choice():
  686. captured_payloads: list[dict] = []
  687. forced_choice = {
  688. "type": "function",
  689. "function": {"name": "handoff_note"},
  690. }
  691. def handler(request: httpx.Request) -> httpx.Response:
  692. captured_payloads.append(json.loads(request.content))
  693. return httpx.Response(200, content=b"data: [DONE]\n\n")
  694. async with httpx.AsyncClient(
  695. transport=httpx.MockTransport(handler),
  696. base_url="https://llm.test/v1",
  697. ) as http_client:
  698. client = OpenAICompatibleChatClient(
  699. api_key="",
  700. base_url="https://llm.test/v1",
  701. default_model="provider-default",
  702. request_timeout_seconds=5,
  703. http_client=http_client,
  704. )
  705. [
  706. item
  707. async for item in client.stream_chat(
  708. messages=[ChatMessage(role="user", content="prepare handoff")],
  709. tools=[
  710. {
  711. "type": "function",
  712. "function": {"name": "handoff_note", "parameters": {}},
  713. }
  714. ],
  715. params=AgentParams(model="provider-default"),
  716. tool_choice=forced_choice,
  717. )
  718. ]
  719. assert captured_payloads[0]["tool_choice"] == forced_choice
  720. @pytest.mark.asyncio
  721. async def test_openai_chat_client_flushes_one_provider_tool_call_at_done():
  722. def handler(request: httpx.Request) -> httpx.Response:
  723. return httpx.Response(
  724. 200,
  725. content=(
  726. b'data: {"choices":[{"delta":{"tool_calls":[{"index":0,'
  727. b'"id":"call_1","function":{"name":"mock_search",'
  728. b'"arguments":"{\\"query\\":\\""}}]},"finish_reason":null}]}\n\n'
  729. b'data: {"choices":[{"delta":{"tool_calls":[{"index":0,'
  730. b'"function":{"arguments":"latency docs\\"}"}}]},'
  731. b'"finish_reason":null}]}\n\n'
  732. b"data: [DONE]\n\n"
  733. ),
  734. )
  735. async with httpx.AsyncClient(
  736. transport=httpx.MockTransport(handler),
  737. base_url="https://llm.test/v1",
  738. ) as http_client:
  739. client = OpenAICompatibleChatClient(
  740. api_key="",
  741. base_url="https://llm.test/v1",
  742. default_model="provider-default",
  743. request_timeout_seconds=5,
  744. http_client=http_client,
  745. )
  746. items = [
  747. item
  748. async for item in client.stream_chat(
  749. messages=[ChatMessage(role="user", content="find docs")],
  750. tools=[
  751. {
  752. "type": "function",
  753. "function": {"name": "mock_search", "parameters": {}},
  754. }
  755. ],
  756. params=AgentParams(model="provider-default"),
  757. )
  758. ]
  759. provider_calls = [
  760. item.event for item in items if item.kind == "provider_tool_call"
  761. ]
  762. assert provider_calls == [
  763. ToolCallEvent(
  764. id="call_1",
  765. name="mock_search",
  766. arguments={"query": "latency docs"},
  767. raw_arguments='{"query":"latency docs"}',
  768. )
  769. ]
  770. @pytest.mark.asyncio
  771. async def test_openai_chat_client_uses_default_model_when_request_model_is_blank():
  772. captured_payloads: list[dict] = []
  773. def handler(request: httpx.Request) -> httpx.Response:
  774. captured_payloads.append(json.loads(request.content))
  775. return httpx.Response(
  776. 200,
  777. content=b'data: {"choices":[{"delta":{"content":"hi"},"finish_reason":null}]}\n\n',
  778. )
  779. async with httpx.AsyncClient(
  780. transport=httpx.MockTransport(handler),
  781. base_url="https://llm.test/v1",
  782. ) as http_client:
  783. client = OpenAICompatibleChatClient(
  784. api_key="",
  785. base_url="https://llm.test/v1",
  786. default_model="provider-default",
  787. request_timeout_seconds=5,
  788. http_client=http_client,
  789. )
  790. [
  791. item
  792. async for item in client.stream_chat(
  793. messages=[ChatMessage(role="user", content="hi")],
  794. tools=[],
  795. params=AgentParams(model=" ", temperature=0.3, max_tokens=50),
  796. )
  797. ]
  798. assert captured_payloads[0]["model"] == "provider-default"
  799. @pytest.mark.asyncio
  800. async def test_openai_chat_client_serializes_provider_tool_transcript():
  801. captured_payloads: list[dict] = []
  802. def handler(request: httpx.Request) -> httpx.Response:
  803. captured_payloads.append(json.loads(request.content))
  804. return httpx.Response(
  805. 200,
  806. content=b'data: {"choices":[{"delta":{"content":"done"},"finish_reason":null}]}\n\n',
  807. )
  808. async with httpx.AsyncClient(
  809. transport=httpx.MockTransport(handler),
  810. base_url="https://llm.test/v1",
  811. ) as http_client:
  812. client = OpenAICompatibleChatClient(
  813. api_key="",
  814. base_url="https://llm.test/v1",
  815. default_model="provider-default",
  816. request_timeout_seconds=5,
  817. http_client=http_client,
  818. )
  819. [
  820. item
  821. async for item in client.stream_chat(
  822. messages=[
  823. ChatMessage(role="user", content="inspect both"),
  824. ChatMessage(
  825. role="assistant",
  826. content="I will inspect both sources.",
  827. tool_calls=[
  828. ToolCallEvent(
  829. id="call_1",
  830. name="mock_search",
  831. arguments={"query": "latency docs"},
  832. raw_arguments='{"query":"latency docs"}',
  833. ),
  834. ToolCallEvent(
  835. id="call_2",
  836. name="handoff_note",
  837. arguments={"message": "inspect provider behavior"},
  838. raw_arguments='{"message":"inspect provider behavior"}',
  839. ),
  840. ],
  841. ),
  842. ChatMessage(
  843. role="tool",
  844. content='{"results":[]}',
  845. name="mock_search",
  846. tool_call_id="call_1",
  847. ),
  848. ChatMessage(
  849. role="tool",
  850. content='{"message":"handled"}',
  851. name="handoff_note",
  852. tool_call_id="call_2",
  853. ),
  854. ChatMessage(role="user", content="continue"),
  855. ],
  856. tools=[],
  857. params=AgentParams(
  858. model="provider-default",
  859. temperature=0.3,
  860. max_tokens=50,
  861. ),
  862. )
  863. ]
  864. assert captured_payloads[0]["messages"] == [
  865. {"role": "user", "content": "inspect both"},
  866. {
  867. "role": "assistant",
  868. "content": "I will inspect both sources.",
  869. "tool_calls": [
  870. {
  871. "id": "call_1",
  872. "type": "function",
  873. "function": {
  874. "name": "mock_search",
  875. "arguments": '{"query":"latency docs"}',
  876. },
  877. },
  878. {
  879. "id": "call_2",
  880. "type": "function",
  881. "function": {
  882. "name": "handoff_note",
  883. "arguments": '{"message":"inspect provider behavior"}',
  884. },
  885. },
  886. ],
  887. },
  888. {
  889. "role": "tool",
  890. "content": '{"results":[]}',
  891. "tool_call_id": "call_1",
  892. },
  893. {
  894. "role": "tool",
  895. "content": '{"message":"handled"}',
  896. "tool_call_id": "call_2",
  897. },
  898. {"role": "user", "content": "continue"},
  899. ]
  900. async def _assert_tool_transcript_rejected(
  901. messages: list[ChatMessage],
  902. error_match: str,
  903. ) -> None:
  904. network_called = False
  905. def handler(request: httpx.Request) -> httpx.Response:
  906. nonlocal network_called
  907. network_called = True
  908. return httpx.Response(200)
  909. async with httpx.AsyncClient(
  910. transport=httpx.MockTransport(handler),
  911. base_url="https://llm.test/v1",
  912. ) as http_client:
  913. client = OpenAICompatibleChatClient(
  914. api_key="",
  915. base_url="https://llm.test/v1",
  916. default_model="provider-default",
  917. request_timeout_seconds=5,
  918. http_client=http_client,
  919. )
  920. with pytest.raises(ValueError, match=error_match):
  921. [
  922. item
  923. async for item in client.stream_chat(
  924. messages=messages,
  925. tools=[],
  926. params=AgentParams(model="provider-default"),
  927. )
  928. ]
  929. assert network_called is False
  930. @pytest.mark.asyncio
  931. async def test_openai_chat_client_rejects_orphan_tool_reply_before_network():
  932. await _assert_tool_transcript_rejected(
  933. [
  934. ChatMessage(
  935. role="tool",
  936. content="orphan",
  937. tool_call_id="call_1",
  938. )
  939. ],
  940. "orphan tool reply: call_1",
  941. )
  942. @pytest.mark.asyncio
  943. async def test_openai_chat_client_rejects_mismatched_tool_call_id_before_network():
  944. await _assert_tool_transcript_rejected(
  945. [
  946. ChatMessage(
  947. role="assistant",
  948. content="",
  949. tool_calls=[
  950. ToolCallEvent(
  951. id="call_1",
  952. name="mock_search",
  953. arguments={},
  954. raw_arguments="{}",
  955. )
  956. ],
  957. ),
  958. ChatMessage(role="tool", content="wrong", tool_call_id="call_2"),
  959. ],
  960. "mismatched tool_call_id: call_2",
  961. )
  962. @pytest.mark.asyncio
  963. async def test_openai_chat_client_rejects_duplicate_tool_reply_before_network():
  964. await _assert_tool_transcript_rejected(
  965. [
  966. ChatMessage(
  967. role="assistant",
  968. content="",
  969. tool_calls=[
  970. ToolCallEvent(
  971. id="call_1",
  972. name="mock_search",
  973. arguments={},
  974. raw_arguments="{}",
  975. )
  976. ],
  977. ),
  978. ChatMessage(role="tool", content="first", tool_call_id="call_1"),
  979. ChatMessage(role="tool", content="duplicate", tool_call_id="call_1"),
  980. ],
  981. "duplicate tool reply: call_1",
  982. )
  983. @pytest.mark.asyncio
  984. async def test_openai_chat_client_rejects_non_tool_message_with_unresolved_calls():
  985. await _assert_tool_transcript_rejected(
  986. [
  987. ChatMessage(
  988. role="assistant",
  989. content="checking",
  990. tool_calls=[
  991. ToolCallEvent(
  992. id="call_1",
  993. name="mock_search",
  994. arguments={},
  995. raw_arguments="{}",
  996. ),
  997. ToolCallEvent(
  998. id="call_2",
  999. name="handoff_note",
  1000. arguments={},
  1001. raw_arguments="{}",
  1002. ),
  1003. ],
  1004. ),
  1005. ChatMessage(role="tool", content="done", tool_call_id="call_1"),
  1006. ChatMessage(role="user", content="continue too early"),
  1007. ],
  1008. "unresolved tool calls before user message: call_2",
  1009. )
  1010. @pytest.mark.asyncio
  1011. async def test_openai_chat_client_rejects_unfinished_tool_calls_at_end():
  1012. await _assert_tool_transcript_rejected(
  1013. [
  1014. ChatMessage(
  1015. role="assistant",
  1016. content="checking",
  1017. tool_calls=[
  1018. ToolCallEvent(
  1019. id="call_1",
  1020. name="mock_search",
  1021. arguments={},
  1022. raw_arguments="{}",
  1023. )
  1024. ],
  1025. )
  1026. ],
  1027. "unresolved tool calls at end: call_1",
  1028. )
  1029. @pytest.mark.asyncio
  1030. async def test_openai_chat_client_rejects_reused_tool_call_id_across_transcript():
  1031. await _assert_tool_transcript_rejected(
  1032. [
  1033. ChatMessage(
  1034. role="assistant",
  1035. content="first round",
  1036. tool_calls=[
  1037. ToolCallEvent(
  1038. id="call_1",
  1039. name="mock_search",
  1040. arguments={},
  1041. raw_arguments="{}",
  1042. )
  1043. ],
  1044. ),
  1045. ChatMessage(role="tool", content="done", tool_call_id="call_1"),
  1046. ChatMessage(
  1047. role="assistant",
  1048. content="second round",
  1049. tool_calls=[
  1050. ToolCallEvent(
  1051. id="call_1",
  1052. name="handoff_note",
  1053. arguments={},
  1054. raw_arguments="{}",
  1055. )
  1056. ],
  1057. ),
  1058. ],
  1059. "duplicate assistant tool-call ID across transcript: call_1",
  1060. )
  1061. async def _assert_tool_choice_rejected(
  1062. tools: list[dict],
  1063. tool_choice: dict,
  1064. error_match: str,
  1065. ) -> None:
  1066. network_called = False
  1067. def handler(request: httpx.Request) -> httpx.Response:
  1068. nonlocal network_called
  1069. network_called = True
  1070. return httpx.Response(200)
  1071. async with httpx.AsyncClient(
  1072. transport=httpx.MockTransport(handler),
  1073. base_url="https://llm.test/v1",
  1074. ) as http_client:
  1075. client = OpenAICompatibleChatClient(
  1076. api_key="",
  1077. base_url="https://llm.test/v1",
  1078. default_model="provider-default",
  1079. request_timeout_seconds=5,
  1080. http_client=http_client,
  1081. )
  1082. with pytest.raises(ValueError, match=error_match):
  1083. [
  1084. item
  1085. async for item in client.stream_chat(
  1086. messages=[ChatMessage(role="user", content="use a tool")],
  1087. tools=tools,
  1088. params=AgentParams(model="provider-default"),
  1089. tool_choice=tool_choice,
  1090. )
  1091. ]
  1092. assert network_called is False
  1093. @pytest.mark.asyncio
  1094. async def test_openai_chat_client_rejects_forced_choice_without_tools():
  1095. await _assert_tool_choice_rejected(
  1096. tools=[],
  1097. tool_choice={
  1098. "type": "function",
  1099. "function": {"name": "handoff_note"},
  1100. },
  1101. error_match="forced tool choice requires tools",
  1102. )
  1103. @pytest.mark.asyncio
  1104. async def test_openai_chat_client_rejects_forced_choice_for_unknown_tool():
  1105. await _assert_tool_choice_rejected(
  1106. tools=[
  1107. {
  1108. "type": "function",
  1109. "function": {"name": "mock_search", "parameters": {}},
  1110. }
  1111. ],
  1112. tool_choice={
  1113. "type": "function",
  1114. "function": {"name": "handoff_note"},
  1115. },
  1116. error_match="forced tool choice references unknown tool: handoff_note",
  1117. )
  1118. def test_static_pre_message_role_selector_does_not_offer_tool():
  1119. html = Path("src/agent_lab/presentation/static/index.html").read_text()
  1120. assert '<option value="tool">tool</option>' not in html
  1121. def test_static_tools_ui_is_dynamic_and_not_hardcoded_to_handoff_note_checkbox():
  1122. html = Path("src/agent_lab/presentation/static/index.html").read_text()
  1123. js = Path("src/agent_lab/presentation/static/app.js").read_text()
  1124. assert 'id="tool-handoff-note"' not in html
  1125. assert "#tool-handoff-note" not in js
  1126. assert 'id="tool-list"' in html
  1127. assert '"/api/tools"' in js
  1128. assert "fetch" in js
  1129. def test_static_workspace_snapshot_controls_are_available_outside_agent_config():
  1130. html = Path("src/agent_lab/presentation/static/index.html").read_text()
  1131. chat_dialog_start = html.index('<dialog id="chat-config-dialog"')
  1132. event_dialog_start = html.index('<dialog id="event-config-dialog"')
  1133. chat_dialog_html = html[chat_dialog_start:event_dialog_start]
  1134. sidebar_html = html[:chat_dialog_start]
  1135. assert 'id="open-prompt-config"' not in html
  1136. assert '<dialog id="prompt-dialog"' not in html
  1137. assert 'id="workspace-snapshot-name"' in sidebar_html
  1138. assert 'id="saved-workspace-snapshots"' in sidebar_html
  1139. assert 'id="save-workspace-snapshot"' in sidebar_html
  1140. assert 'id="load-workspace-snapshot"' in sidebar_html
  1141. assert 'id="delete-workspace-snapshot"' in sidebar_html
  1142. assert 'id="workspace-snapshot-name"' not in chat_dialog_html
  1143. assert 'id="saved-workspace-snapshots"' not in chat_dialog_html
  1144. assert 'id="save-workspace-snapshot"' not in chat_dialog_html
  1145. assert 'id="system-prompts"' in chat_dialog_html
  1146. assert 'id="pre-messages"' in chat_dialog_html
  1147. def test_static_session_ui_controls_and_replay_panels_are_available():
  1148. html = Path("src/agent_lab/presentation/static/index.html").read_text()
  1149. chat_dialog_start = html.index('<dialog id="chat-config-dialog"')
  1150. event_dialog_start = html.index('<dialog id="event-config-dialog"')
  1151. chat_dialog_html = html[chat_dialog_start:event_dialog_start]
  1152. event_dialog_html = html[event_dialog_start:]
  1153. sidebar_html = html[:chat_dialog_start]
  1154. for control_id in [
  1155. "session-title",
  1156. "session-list",
  1157. "new-session",
  1158. "refresh-sessions",
  1159. "load-session",
  1160. "session-status",
  1161. ]:
  1162. assert f'id="{control_id}"' in sidebar_html
  1163. assert f'id="{control_id}"' not in chat_dialog_html
  1164. assert f'id="{control_id}"' not in event_dialog_html
  1165. for replay_id in [
  1166. "audit-replay",
  1167. "audit-count",
  1168. "session-usage-summary",
  1169. "session-usage-turns",
  1170. "session-usage-calls",
  1171. ]:
  1172. assert f'id="{replay_id}"' in html
  1173. def test_static_chat_agent_prompt_is_a_single_sortable_list_with_event_instructions():
  1174. html = Path("src/agent_lab/presentation/static/index.html").read_text()
  1175. js = Path("src/agent_lab/presentation/static/app.js").read_text()
  1176. css = Path("src/agent_lab/presentation/static/styles.css").read_text()
  1177. assert 'id="chat-system-prompt"' not in html
  1178. assert 'id="chat-event-prompt-preview"' not in html
  1179. assert 'class="prompt-list stack"' in html
  1180. assert 'class="prompt-item"' in html
  1181. assert 'data-prompt-role="system"' in html
  1182. assert "prompt-type" in html
  1183. assert "function buildAvailableEventsPrompt(" in js
  1184. assert "Available events:" in js
  1185. assert "function updateEventPromptItem(" in js
  1186. assert "function createSystemPrompt(" in js
  1187. assert "function movePromptItem(" in js
  1188. assert 'draggable = true' in js
  1189. assert "dataset.promptRole" in js
  1190. assert "prompt-title" in js
  1191. assert "delete-system-prompt" in js
  1192. assert 'prompt_kind: "event_agent_system"' in js
  1193. assert "system_prompts: collectSystemPrompts()" in js
  1194. assert 'system_prompt: ""' in js
  1195. assert ".prompt-item" in css
  1196. assert ".prompt-item.dragging" in css
  1197. assert ".prompt-meta" in css
  1198. assert ".prompt-type" in css
  1199. assert ".prompt-actions button" in css
  1200. assert "#open-prompt-config" not in js
  1201. assert "promptDialog" not in js
  1202. def test_static_app_script_is_versioned_and_click_binding_is_guarded():
  1203. html = Path("src/agent_lab/presentation/static/index.html").read_text()
  1204. js = Path("src/agent_lab/presentation/static/app.js").read_text()
  1205. assert '<script src="/static/app.js?v=' in html
  1206. assert "function bindClick(" in js
  1207. assert 'bindClick("#open-chat-config-panel", openChatConfig)' in js
  1208. assert 'bindClick("#open-event-config-panel", openEventConfig)' in js
  1209. def test_static_agent_config_controls_are_aligned_for_chat_and_event_agents():
  1210. html = Path("src/agent_lab/presentation/static/index.html").read_text()
  1211. js = Path("src/agent_lab/presentation/static/app.js").read_text()
  1212. assert '<dialog id="chat-config-dialog"' in html
  1213. assert '<dialog id="event-config-dialog"' in html
  1214. assert html.count('id="open-chat-config-panel"') == 1
  1215. assert html.count('id="open-event-config-panel"') == 1
  1216. assert 'id="open-chat-config"' not in html
  1217. assert 'id="open-event-config"' not in html
  1218. assert '#open-chat-config"' not in js
  1219. assert '#open-event-config"' not in js
  1220. assert '#open-chat-config-panel"' in js
  1221. assert '#open-event-config-panel"' in js
  1222. assert 'id="close-chat-config"' in html
  1223. assert 'id="close-event-config"' in html
  1224. assert "chatConfigDialog.showModal()" in js
  1225. assert "eventConfigDialog.showModal()" in js
  1226. assert "chatConfigDialog.close()" in js
  1227. assert "eventConfigDialog.close()" in js
  1228. for control_id in [
  1229. "model",
  1230. "temperature",
  1231. "max-tokens",
  1232. "chat-thinking-disabled",
  1233. "chat-enable-search",
  1234. "chat-forced-search",
  1235. "event-model",
  1236. "event-temperature",
  1237. "event-max-tokens",
  1238. "event-system-prompt",
  1239. "event-thinking-disabled",
  1240. "event-enable-search",
  1241. "event-forced-search",
  1242. ]:
  1243. assert f'id="{control_id}"' in html
  1244. assert 'extra_body: buildExtraBody("chat")' in js
  1245. assert 'extra_body: buildExtraBody("event")' in js
  1246. assert 'system_prompt: ""' in js
  1247. assert 'system_prompt: document.querySelector("#event-system-prompt")' in js
  1248. def test_static_tool_mode_and_batch_controls_explain_active_configuration():
  1249. html = Path("src/agent_lab/presentation/static/index.html").read_text()
  1250. js = Path("src/agent_lab/presentation/static/app.js").read_text()
  1251. assert 'id="tool-invocation-mode"' in html
  1252. assert '<option value="dual_agent" selected>' in html
  1253. assert '<option value="chat_agent_tools">' in html
  1254. assert 'id="tool-mode-help"' in html
  1255. assert 'id="max-parallel-events"' in html
  1256. assert 'value="4"' in html
  1257. assert 'id="batch-timeout-seconds"' in html
  1258. assert 'value="15"' in html
  1259. assert "missing-argument fallback" in js
  1260. assert "EventAgent model, temperature, max tokens, system prompt, and extra body are inactive" in js
  1261. assert "enabled tools, loop budget, batch concurrency, and timeout remain active" in js
  1262. assert "function updateToolModeHelp()" in js
  1263. def test_static_tool_mode_config_roundtrips_request_snapshot_and_session_restore():
  1264. js = Path("src/agent_lab/presentation/static/app.js").read_text()
  1265. build_snapshot = js[
  1266. js.index("function buildWorkspaceSnapshot()") :
  1267. js.index("function restoreWorkspaceSnapshot(")
  1268. ]
  1269. restore_snapshot = js[
  1270. js.index("function restoreWorkspaceSnapshot(") :
  1271. js.index("function buildExtraBody(")
  1272. ]
  1273. build_request = js[
  1274. js.index("function buildRequest(") :
  1275. js.index("function selectedTools(")
  1276. ]
  1277. load_replay = js[
  1278. js.index("async function loadSessionReplay(") :
  1279. js.index("function setSessionStatus(")
  1280. ]
  1281. create_session = js[
  1282. js.index("async function createSession()") :
  1283. js.index("async function loadSelectedSession()")
  1284. ]
  1285. for source in [build_snapshot, build_request]:
  1286. assert 'tool_invocation_mode: document.querySelector("#tool-invocation-mode").value' in source
  1287. assert 'max_parallel_events: Number(document.querySelector("#max-parallel-events").value)' in source
  1288. assert 'batch_timeout_seconds: Number(document.querySelector("#batch-timeout-seconds").value)' in source
  1289. assert 'snapshot.tool_invocation_mode || "dual_agent"' in restore_snapshot
  1290. assert 'eventAgent.max_parallel_events ?? 4' in restore_snapshot
  1291. assert 'eventAgent.batch_timeout_seconds ?? 15' in restore_snapshot
  1292. assert "updateToolModeHelp();" in restore_snapshot
  1293. assert '`/api/sessions/${sessionId}`' in load_replay
  1294. assert "const [session, messages, audit, usage] = await Promise.all([" in load_replay
  1295. assert "restoreWorkspaceSnapshot(session.config || {});" in load_replay
  1296. assert "config: buildWorkspaceSnapshot()" in create_session
  1297. def test_static_tools_ui_shows_tool_metadata_and_selection_actions():
  1298. html = Path("src/agent_lab/presentation/static/index.html").read_text()
  1299. js = Path("src/agent_lab/presentation/static/app.js").read_text()
  1300. assert 'id="tool-count"' in html
  1301. assert 'id="select-all-tools"' in html
  1302. assert 'id="clear-tools"' in html
  1303. assert "tool-card" in js
  1304. assert "tool-description" in js
  1305. assert "tool-required" in js
  1306. assert "function updateToolCount()" in js
  1307. def test_static_app_handles_audit_messages():
  1308. js = Path("src/agent_lab/presentation/static/app.js").read_text()
  1309. css = Path("src/agent_lab/presentation/static/styles.css").read_text()
  1310. assert 'message.type === "audit"' in js
  1311. assert "appendAuditEntry(message)" in js
  1312. assert 'appendLog("audit"' not in js
  1313. assert "function renderAuditDetail(" in js
  1314. assert "chat_agent_request" in js
  1315. assert "event_agent_response" in js
  1316. assert "chat_message_stream_started" in js
  1317. assert "chat_message_stream_finished" in js
  1318. assert ".audit-event" in css
  1319. assert ".audit-detail" in css
  1320. def test_static_audit_replay_uses_event_stream_and_detail_panel():
  1321. html = Path("src/agent_lab/presentation/static/index.html").read_text()
  1322. js = Path("src/agent_lab/presentation/static/app.js").read_text()
  1323. css = Path("src/agent_lab/presentation/static/styles.css").read_text()
  1324. assert '<link rel="stylesheet" href="/static/styles.css?v=' in html
  1325. assert "tool-mode-session-restore" in html
  1326. assert 'id="audit-dialog"' in html
  1327. assert 'id="open-audit-dialog"' in html
  1328. assert 'id="close-audit-dialog"' in html
  1329. assert 'id="audit-summary"' in html
  1330. assert 'id="audit-replay" class="audit-stream"' in html
  1331. assert 'id="audit-detail"' in html
  1332. assert "auditDialog.showModal()" in js
  1333. assert "auditDialog.close()" in js
  1334. assert "auditEntryKey(entry)" in js
  1335. assert "selectAuditEntry(key)" in js
  1336. assert 'meta.className = "audit-detail-meta"' in js
  1337. assert 'appendMetaField(meta, "Since User Message"' in js
  1338. assert "auditRelativeTime(entry)" in js
  1339. assert 'const list = document.createElement("ul")' in js
  1340. assert "appendKeyValueSection(auditDetail, \"Model Parameters\"" in js
  1341. assert "appendRepliesSection(auditDetail, details.replies)" in js
  1342. assert "appendRawChunksSection(auditDetail, \"Raw LLM Chunks\"" in js
  1343. assert "appendRawModelChunksSection(auditDetail, details.raw_model_chunks)" in js
  1344. assert "Enabled This Round" in js
  1345. assert "Configured Events" in js
  1346. assert "details.configured_events" in js
  1347. assert "enabled this round" in js
  1348. assert ".audit-modal" in css
  1349. assert ".audit-modal-body" in css
  1350. assert ".audit-modal-body section + section" in css
  1351. assert ".audit-detail-title" in css
  1352. assert ".detail-tags code" in css
  1353. assert ".audit-detail-meta span" not in css
  1354. assert ".detail-tags span" not in css
  1355. assert "display: block" in _css_rule(css, ".audit-detail")
  1356. assert "display: grid" not in _css_rule(css, ".audit-detail")
  1357. assert "display: block" in _css_rule(css, ".audit-detail-header")
  1358. assert "display: grid" not in _css_rule(css, ".audit-detail-header")
  1359. assert "display: inline-flex" in _css_rule(css, ".audit-detail-meta div")
  1360. assert ".audit-marker::before" in css
  1361. assert ".audit-event.is-selected" in css
  1362. def test_static_layout_keeps_controls_and_composer_fixed_with_scrollable_panes():
  1363. css = Path("src/agent_lab/presentation/static/styles.css").read_text()
  1364. assert "height: calc(100vh - 56px)" in css
  1365. assert ".layout" in css and "overflow: hidden" in css
  1366. assert ".controls" in css and "overflow: auto" in css
  1367. assert ".workspace {\n display: grid;\n grid-template-rows: minmax(0, 1fr) auto auto;" in css
  1368. assert ".workspace-body" in css and "overflow: hidden" in css
  1369. assert ".messages" in css and "overflow: auto" in css
  1370. assert ".audit-stream" in css and "overflow: auto" in css
  1371. def test_static_app_shows_wait_state_and_immediate_user_echo():
  1372. html = Path("src/agent_lab/presentation/static/index.html").read_text()
  1373. js = Path("src/agent_lab/presentation/static/app.js").read_text()
  1374. css = Path("src/agent_lab/presentation/static/styles.css").read_text()
  1375. assert 'id="run-button"' in html
  1376. assert 'const runButton = document.querySelector("#run-button")' in js
  1377. assert 'appendLog("user", submittedMessage)' in js
  1378. assert 'statusEl.textContent = "Waiting for first token"' in js
  1379. assert "function startElapsedTimer()" in js
  1380. assert "function stopElapsedTimer()" in js
  1381. assert "function setRunState(" in js
  1382. assert "if (firstTokenAt) {\n hasBackendRoundStats = true;" in js
  1383. assert "hasBackendRoundStats && firstTokenAt" in js
  1384. assert ".user" in css
  1385. def test_static_app_reuses_websocket_for_session_turns():
  1386. js = Path("src/agent_lab/presentation/static/app.js").read_text()
  1387. assert "function isSocketOpen()" in js
  1388. assert "socket.send(JSON.stringify({ type: \"user_message\"" in js
  1389. assert "session_id: currentSessionId || undefined" in js
  1390. assert "message.session_id" in js
  1391. assert 'message.type === "turn_completed"' in js
  1392. assert 'message.type === "done"' in js
  1393. def test_static_app_loads_session_replay_and_usage():
  1394. js = Path("src/agent_lab/presentation/static/app.js").read_text()
  1395. create_session = js[
  1396. js.index("async function createSession()") :
  1397. js.index("async function loadSelectedSession()")
  1398. ]
  1399. load_session = js[
  1400. js.index("async function loadSelectedSession()") :
  1401. js.index("async function loadSessionReplay(")
  1402. ]
  1403. assert "function loadSessions(" in js
  1404. assert "function createSession(" in js
  1405. assert "function setSessionStatus(" in js
  1406. assert '"Refreshing sessions"' in js
  1407. assert '"Creating session"' in js
  1408. assert '"Session created"' in js
  1409. assert 'sessionStatus.textContent = message' in js
  1410. assert "function loadSessionReplay(" in js
  1411. assert 'fetchJson("/api/sessions")' in js
  1412. assert 'method: "POST"' in js
  1413. assert '`/api/sessions/${sessionId}/messages`' in js
  1414. assert '`/api/sessions/${sessionId}/audit`' in js
  1415. assert '`/api/sessions/${sessionId}/usage`' in js
  1416. assert "function renderAuditReplay(" in js
  1417. assert "function renderSessionUsage(" in js
  1418. assert "session.turn_wall_time_ms ?? session.elapsed_ms" in js
  1419. assert "turn.turn_wall_time_ms ?? turn.elapsed_ms" in js
  1420. assert "call.mode" in js
  1421. assert "call.agent" in js
  1422. assert "call.call_kind" in js
  1423. assert "call.used_fallback" in js
  1424. assert "call.tool_latency_ms" in js
  1425. assert "sessionUsageSummary" in js
  1426. assert "Finish current turn before switching sessions" in js
  1427. assert "await loadSessionReplay(session.id, { preserveStatus: true });" in create_session
  1428. assert "await loadSessionReplay(sessionId);" in load_session
  1429. def test_static_session_replay_ignores_superseded_success():
  1430. js = Path("src/agent_lab/presentation/static/app.js").read_text()
  1431. result = _run_load_session_replay_scenario(
  1432. """
  1433. currentSessionId = "old";
  1434. const oldLoad = loadSessionReplay("old");
  1435. currentSessionId = "new";
  1436. const newLoad = loadSessionReplay("new");
  1437. resolveReplay("new");
  1438. await newLoad;
  1439. resolveReplay("old");
  1440. await oldLoad;
  1441. console.log(JSON.stringify({
  1442. ...mutations,
  1443. title: sessionTitle.value,
  1444. selected: sessionList.value,
  1445. }));
  1446. """
  1447. )
  1448. assert result == {
  1449. "workspace": ["new"],
  1450. "transcript": ["new"],
  1451. "audit": ["new"],
  1452. "usage": ["new"],
  1453. "status": ["Session loaded"],
  1454. "title": "new title",
  1455. "selected": "new",
  1456. }
  1457. assert "let sessionReplayLoadToken = 0;" in js
  1458. def test_static_session_replay_ignores_superseded_failure_status():
  1459. result = _run_load_session_replay_scenario(
  1460. """
  1461. currentSessionId = "old";
  1462. const oldLoad = loadSessionReplay("old");
  1463. currentSessionId = "new";
  1464. const newLoad = loadSessionReplay("new");
  1465. resolveReplay("new");
  1466. await newLoad;
  1467. pending.get("/api/sessions/old").reject(new Error("stale failure"));
  1468. await oldLoad;
  1469. console.log(JSON.stringify(mutations.status));
  1470. """
  1471. )
  1472. assert result == ["Session loaded"]
  1473. def test_static_session_replay_rejects_stale_invocation_before_token_advance():
  1474. result = _run_load_session_replay_scenario(
  1475. """
  1476. currentSessionId = "new";
  1477. const newLoad = loadSessionReplay("new");
  1478. const tokenAfterNew = sessionReplayLoadToken;
  1479. const oldLoad = loadSessionReplay("old");
  1480. const oldResult = await Promise.race([
  1481. oldLoad.then(() => "returned"),
  1482. new Promise((resolve) => setTimeout(() => resolve("pending"), 0)),
  1483. ]);
  1484. const tokenAfterOld = sessionReplayLoadToken;
  1485. const oldFetchStarted = pending.has("/api/sessions/old");
  1486. resolveReplay("new");
  1487. await newLoad;
  1488. console.log(JSON.stringify({
  1489. oldResult,
  1490. tokenAfterNew,
  1491. tokenAfterOld,
  1492. oldFetchStarted,
  1493. mutations,
  1494. title: sessionTitle.value,
  1495. selected: sessionList.value,
  1496. }));
  1497. """
  1498. )
  1499. assert result == {
  1500. "oldResult": "returned",
  1501. "tokenAfterNew": 1,
  1502. "tokenAfterOld": 1,
  1503. "oldFetchStarted": False,
  1504. "mutations": {
  1505. "workspace": ["new"],
  1506. "transcript": ["new"],
  1507. "audit": ["new"],
  1508. "usage": ["new"],
  1509. "status": ["Session loaded"],
  1510. },
  1511. "title": "new title",
  1512. "selected": "new",
  1513. }
  1514. def test_static_replay_hides_whitespace_assistant_tool_protocol_records():
  1515. js = Path("src/agent_lab/presentation/static/app.js").read_text()
  1516. render_replay = js[
  1517. js.index("function renderReplayMessages(") :
  1518. js.index("function appendAuditEntry(")
  1519. ]
  1520. result = _run_node_json(
  1521. "\n".join(
  1522. [
  1523. """
  1524. let activeAssistant = null;
  1525. const messagesEl = {
  1526. children: [],
  1527. scrollHeight: 0,
  1528. scrollTop: 0,
  1529. set textContent(value) {
  1530. if (value === "") {
  1531. this.children = [];
  1532. }
  1533. },
  1534. append(item) {
  1535. this.children.push(item);
  1536. },
  1537. };
  1538. const document = {
  1539. createElement() {
  1540. return { className: "", textContent: "" };
  1541. },
  1542. };
  1543. function appendLog(kind, content) {
  1544. messagesEl.append({ className: `message ${kind}`, textContent: content });
  1545. }
  1546. """,
  1547. render_replay,
  1548. """
  1549. renderReplayMessages([
  1550. { role: "assistant", content: " \\n\\t", tool_calls: [{ id: "call-1" }] },
  1551. { role: "assistant", content: "visible", tool_calls: [] },
  1552. ]);
  1553. console.log(JSON.stringify(messagesEl.children.map((item) => item.textContent)));
  1554. """,
  1555. ]
  1556. )
  1557. )
  1558. assert result == ["visible"]
  1559. def test_static_session_switch_guard_uses_active_turn_not_open_socket():
  1560. js = Path("src/agent_lab/presentation/static/app.js").read_text()
  1561. create_session = js[js.index("async function createSession()") : js.index("async function loadSelectedSession()")]
  1562. load_session = js[js.index("async function loadSelectedSession()") : js.index("async function loadSessionReplay(")]
  1563. assert "function hasActiveTurn()" in js
  1564. assert "turnActive = isRunning" in js
  1565. assert "if (hasActiveTurn())" in create_session
  1566. assert "if (hasActiveTurn())" in load_session
  1567. assert "if (isSocketOpen())" not in create_session
  1568. assert "if (isSocketOpen())" not in load_session
  1569. assert "closeSocket();" in create_session
  1570. assert "closeSocket();" in load_session
  1571. assert "const activeSocket = socket" in js
  1572. assert "if (socket !== activeSocket)" in js
  1573. def test_static_workspace_snapshot_uses_stable_storage_hooks():
  1574. js = Path("src/agent_lab/presentation/static/app.js").read_text()
  1575. assert "agent-lab.workspace-snapshots.v1" in js
  1576. assert "agent-lab.prompt-sets.v1" in js
  1577. assert "function saveWorkspaceSnapshot()" in js
  1578. assert "function loadWorkspaceSnapshot()" in js
  1579. assert "function deleteWorkspaceSnapshot()" in js
  1580. assert "function buildWorkspaceSnapshot()" in js
  1581. assert "prompt_items: buildPromptItems()" in js
  1582. assert "enabled_tools: selectedTools()" in js
  1583. assert "extra_body: buildExtraBody(\"chat\")" in js
  1584. assert "extra_body: buildExtraBody(\"event\")" in js
  1585. def test_static_app_handles_backend_round_stats_messages():
  1586. js = Path("src/agent_lab/presentation/static/app.js").read_text()
  1587. assert 'message.type === "round_stats"' in js
  1588. assert "function updateRoundStats(" in js
  1589. assert "#stat-ttft" in js
  1590. assert "#stat-elapsed" in js
  1591. @pytest.mark.asyncio
  1592. async def test_openai_chat_client_omits_stream_options_when_usage_disabled():
  1593. captured_payloads: list[dict] = []
  1594. def handler(request: httpx.Request) -> httpx.Response:
  1595. captured_payloads.append(json.loads(request.content))
  1596. return httpx.Response(
  1597. 200,
  1598. content=b'data: {"choices":[{"delta":{"content":"hi"},"finish_reason":null}]}\n\n',
  1599. )
  1600. async with httpx.AsyncClient(
  1601. transport=httpx.MockTransport(handler),
  1602. base_url="https://llm.test/v1",
  1603. ) as http_client:
  1604. client = OpenAICompatibleChatClient(
  1605. api_key="",
  1606. base_url="https://llm.test/v1",
  1607. default_model="provider-default",
  1608. request_timeout_seconds=5,
  1609. include_usage=False,
  1610. http_client=http_client,
  1611. )
  1612. [
  1613. item
  1614. async for item in client.stream_chat(
  1615. messages=[ChatMessage(role="user", content="hi")],
  1616. tools=[],
  1617. params=AgentParams(model="provider-default", temperature=0.3, max_tokens=50),
  1618. )
  1619. ]
  1620. assert "stream_options" not in captured_payloads[0]
  1621. @pytest.mark.asyncio
  1622. async def test_openai_chat_client_merges_agent_extra_body_into_payload():
  1623. captured_payloads: list[dict] = []
  1624. def handler(request: httpx.Request) -> httpx.Response:
  1625. captured_payloads.append(json.loads(request.content))
  1626. return httpx.Response(
  1627. 200,
  1628. content=b'data: {"choices":[{"delta":{"content":"hi"},"finish_reason":null}]}\n\n',
  1629. )
  1630. async with httpx.AsyncClient(
  1631. transport=httpx.MockTransport(handler),
  1632. base_url="https://llm.test/v1",
  1633. ) as http_client:
  1634. client = OpenAICompatibleChatClient(
  1635. api_key="",
  1636. base_url="https://llm.test/v1",
  1637. default_model="provider-default",
  1638. request_timeout_seconds=5,
  1639. http_client=http_client,
  1640. )
  1641. [
  1642. item
  1643. async for item in client.stream_chat(
  1644. messages=[ChatMessage(role="user", content="hi")],
  1645. tools=[],
  1646. params=AgentParams(
  1647. model="provider-default",
  1648. temperature=0.3,
  1649. max_tokens=50,
  1650. extra_body={
  1651. "thinking": {"type": "disabled"},
  1652. "enable_search": False,
  1653. "search_options": {"forced_search": False},
  1654. },
  1655. ),
  1656. )
  1657. ]
  1658. assert captured_payloads[0]["thinking"] == {"type": "disabled"}
  1659. assert captured_payloads[0]["enable_search"] is False
  1660. assert captured_payloads[0]["search_options"] == {"forced_search": False}
  1661. @pytest.mark.asyncio
  1662. @pytest.mark.parametrize(
  1663. ("reserved_key", "override_value"),
  1664. [
  1665. ("model", "overridden-model"),
  1666. ("messages", []),
  1667. ("tools", []),
  1668. (
  1669. "tool_choice",
  1670. {"type": "function", "function": {"name": "other_tool"}},
  1671. ),
  1672. ("stream", False),
  1673. ("stream_options", {"include_usage": False}),
  1674. ("temperature", 1.0),
  1675. ("max_tokens", 1),
  1676. ],
  1677. )
  1678. async def test_openai_chat_client_rejects_reserved_extra_body_fields_before_network(
  1679. reserved_key: str,
  1680. override_value: object,
  1681. ):
  1682. network_called = False
  1683. def handler(request: httpx.Request) -> httpx.Response:
  1684. nonlocal network_called
  1685. network_called = True
  1686. return httpx.Response(200)
  1687. async with httpx.AsyncClient(
  1688. transport=httpx.MockTransport(handler),
  1689. base_url="https://llm.test/v1",
  1690. ) as http_client:
  1691. client = OpenAICompatibleChatClient(
  1692. api_key="",
  1693. base_url="https://llm.test/v1",
  1694. default_model="provider-default",
  1695. request_timeout_seconds=5,
  1696. http_client=http_client,
  1697. )
  1698. with pytest.raises(
  1699. ValueError,
  1700. match=f"extra_body contains reserved field: {reserved_key}",
  1701. ):
  1702. [
  1703. item
  1704. async for item in client.stream_chat(
  1705. messages=[ChatMessage(role="user", content="hi")],
  1706. tools=[],
  1707. params=AgentParams(extra_body={reserved_key: override_value}),
  1708. )
  1709. ]
  1710. assert network_called is False