test_websocket_api.py 49 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389
  1. import json
  2. import asyncio
  3. import logging
  4. from collections.abc import AsyncIterator
  5. from pathlib import Path
  6. import httpx
  7. import pytest
  8. from fastapi.testclient import TestClient
  9. from pydantic import ValidationError
  10. from agent_lab.application.contracts import AgentParams, DebugRunRequest, EventAgentParams
  11. from agent_lab.application.queues import RuntimeQueues
  12. from agent_lab.application.runtime import DebugRuntime
  13. from agent_lab.domain.events import ToolCallEvent
  14. from agent_lab.domain.messages import ChatMessage, StreamItem, TokenUsage
  15. from agent_lab.infrastructure.chat_client import OpenAICompatibleChatClient
  16. from agent_lab.infrastructure.sqlite_store import SQLiteSessionStore
  17. from agent_lab.presentation.web import create_app
  18. from agent_lab.settings import Settings
  19. def _css_rule(css: str, selector: str) -> str:
  20. start = css.index(f"{selector} {{")
  21. end = css.index("}", start)
  22. return css[start:end]
  23. class FakeRuntime:
  24. def __init__(self) -> None:
  25. self.requests: list[DebugRunRequest] = []
  26. async def run(self, request: DebugRunRequest) -> AsyncIterator[dict]:
  27. self.requests.append(request)
  28. yield {"type": "session_started"}
  29. yield {"type": "message_delta", "content": "hello"}
  30. yield {"type": "done"}
  31. class QueueAwareRuntime:
  32. def __init__(self) -> None:
  33. self.requests: list[DebugRunRequest] = []
  34. self.queues: RuntimeQueues | None = None
  35. self.task: asyncio.Task | None = None
  36. def start(self, request: DebugRunRequest) -> RuntimeQueues:
  37. self.requests.append(request)
  38. self.queues = RuntimeQueues()
  39. self.task = asyncio.create_task(self._run())
  40. return self.queues
  41. async def _run(self) -> None:
  42. assert self.queues is not None
  43. await self.queues.output.put({"type": "session_started"})
  44. message = await self.queues.input.get()
  45. await self.queues.output.put(
  46. {"type": "message_delta", "content": message.content}
  47. )
  48. await self.queues.output.put({"type": "done"})
  49. async def aclose(self) -> None:
  50. if self.task is not None and not self.task.done():
  51. self.task.cancel()
  52. await asyncio.gather(self.task, return_exceptions=True)
  53. class PersistentSessionRuntime:
  54. def __init__(self) -> None:
  55. self.requests: list[DebugRunRequest] = []
  56. self.queues: RuntimeQueues | None = None
  57. self.task: asyncio.Task | None = None
  58. def start_session(self, request: DebugRunRequest) -> RuntimeQueues:
  59. self.requests.append(request)
  60. self.queues = RuntimeQueues()
  61. self.task = asyncio.create_task(self._run(request))
  62. return self.queues
  63. async def _run(self, request: DebugRunRequest) -> None:
  64. assert self.queues is not None
  65. await self.queues.output.put({"type": "session_started"})
  66. await self._emit_turn(1, request.user_message)
  67. turn_index = 1
  68. while True:
  69. message = await self.queues.input.get()
  70. turn_index += 1
  71. await self._emit_turn(turn_index, message.content)
  72. async def _emit_turn(self, turn_index: int, content: str) -> None:
  73. assert self.queues is not None
  74. await self.queues.output.put({"type": "turn_started", "turn_index": turn_index})
  75. await self.queues.output.put({"type": "message_delta", "content": content})
  76. await self.queues.output.put(
  77. {"type": "turn_completed", "turn_index": turn_index}
  78. )
  79. async def aclose(self) -> None:
  80. if self.task is not None and not self.task.done():
  81. self.task.cancel()
  82. await asyncio.gather(self.task, return_exceptions=True)
  83. def _request_payload() -> dict:
  84. return {
  85. "user_message": "debug this",
  86. "system_prompts": ["You are a debugger."],
  87. "pre_messages": [{"role": "user", "content": "previous turn"}],
  88. "chat_agent": {
  89. "model": "fake-model",
  90. "temperature": 0.1,
  91. "max_tokens": 200,
  92. },
  93. "event_agent": {
  94. "enabled_tools": ["handoff_note"],
  95. "max_event_loops": 2,
  96. },
  97. }
  98. def _event_tool_call_from_tools(
  99. tools: list[dict],
  100. messages: list[ChatMessage],
  101. ) -> StreamItem:
  102. tool_name = tools[0]["function"]["name"]
  103. content = ""
  104. for role in ("assistant", "user"):
  105. content = next(
  106. (
  107. message.content
  108. for message in reversed(messages)
  109. if message.role == role and message.content.strip()
  110. ),
  111. "",
  112. )
  113. if content:
  114. break
  115. arguments = {
  116. "message": content,
  117. "query": content,
  118. "title": content,
  119. }
  120. return StreamItem.provider_tool_call(
  121. ToolCallEvent(
  122. id="event_agent_call_1",
  123. name=tool_name,
  124. arguments=arguments,
  125. raw_arguments=json.dumps(arguments),
  126. )
  127. )
  128. def test_agent_params_defaults_include_extra_body_and_event_agent_defaults_to_one_round():
  129. chat_params = AgentParams()
  130. event_params = EventAgentParams()
  131. assert chat_params.extra_body == {
  132. "thinking": {"type": "disabled"},
  133. "enable_search": False,
  134. "search_options": {"forced_search": False},
  135. }
  136. assert event_params.extra_body == chat_params.extra_body
  137. assert event_params.max_event_loops == 1
  138. assert event_params.system_prompt == ""
  139. def test_health_returns_ok():
  140. app = create_app(runtime_factory=FakeRuntime)
  141. client = TestClient(app)
  142. response = client.get("/health")
  143. assert response.status_code == 200
  144. assert response.json() == {"status": "ok"}
  145. def test_api_tools_returns_handoff_note_metadata():
  146. app = create_app(runtime_factory=FakeRuntime)
  147. client = TestClient(app)
  148. response = client.get("/api/tools")
  149. assert response.status_code == 200
  150. tools = response.json()
  151. assert [tool["name"] for tool in tools] == [
  152. "handoff_note",
  153. "mock_search",
  154. "mock_ticket",
  155. ]
  156. assert tools[0]["parameters"]["required"] == ["message"]
  157. def test_session_api_creates_lists_and_returns_empty_replay_data(tmp_path):
  158. settings = Settings(database_path=str(tmp_path / "agent_lab.sqlite3"))
  159. app = create_app(settings=settings, runtime_factory=FakeRuntime)
  160. client = TestClient(app)
  161. created = client.post(
  162. "/api/sessions",
  163. json={
  164. "title": "Debug session",
  165. "config": {"chat_agent": {"model": "chat-model"}},
  166. },
  167. )
  168. assert created.status_code == 200
  169. session_id = created.json()["id"]
  170. assert created.json()["title"] == "Debug session"
  171. sessions = client.get("/api/sessions")
  172. assert sessions.status_code == 200
  173. assert sessions.json()[0]["id"] == session_id
  174. assert sessions.json()[0]["turn_count"] == 0
  175. detail = client.get(f"/api/sessions/{session_id}")
  176. assert detail.status_code == 200
  177. assert detail.json()["config"]["chat_agent"]["model"] == "chat-model"
  178. assert client.get(f"/api/sessions/{session_id}/messages").json() == []
  179. assert client.get(f"/api/sessions/{session_id}/audit").json() == []
  180. usage = client.get(f"/api/sessions/{session_id}/usage").json()
  181. assert usage == {
  182. "calls": [],
  183. "turns": [],
  184. "session": {
  185. "prompt_tokens": 0,
  186. "completion_tokens": 0,
  187. "total_tokens": 0,
  188. "cached_tokens": 0,
  189. "elapsed_ms": 0,
  190. },
  191. }
  192. def test_session_api_returns_persisted_replay_data(tmp_path):
  193. database_path = tmp_path / "agent_lab.sqlite3"
  194. settings = Settings(database_path=str(database_path))
  195. app = create_app(settings=settings, runtime_factory=FakeRuntime)
  196. client = TestClient(app)
  197. session_id = client.post("/api/sessions", json={"title": "Replay"}).json()["id"]
  198. store = SQLiteSessionStore(database_path)
  199. store.start_turn(session_id, turn_index=1, user_message="debug this")
  200. store.append_message(
  201. session_id,
  202. turn_index=1,
  203. message=ChatMessage(role="user", content="debug this"),
  204. )
  205. store.append_message(
  206. session_id,
  207. turn_index=1,
  208. message=ChatMessage(role="assistant", content="answer"),
  209. )
  210. store.append_audit(
  211. session_id,
  212. event="chat_agent_request",
  213. details={"model": "chat-model"},
  214. turn_index=1,
  215. round_index=1,
  216. )
  217. store.append_usage(
  218. session_id,
  219. turn_index=1,
  220. round_index=1,
  221. usage=TokenUsage(prompt_tokens=1, completion_tokens=2, total_tokens=3),
  222. ttft_ms=10,
  223. elapsed_ms=20,
  224. )
  225. assert [message["content"] for message in client.get(
  226. f"/api/sessions/{session_id}/messages"
  227. ).json()] == ["debug this", "answer"]
  228. assert client.get(f"/api/sessions/{session_id}/audit").json()[0]["details"] == {
  229. "model": "chat-model"
  230. }
  231. usage = client.get(f"/api/sessions/{session_id}/usage").json()
  232. assert usage["calls"][0]["total_tokens"] == 3
  233. assert usage["turns"][0]["total_tokens"] == 3
  234. assert usage["session"]["total_tokens"] == 3
  235. def test_default_websocket_runtime_factory_accepts_session_store(tmp_path):
  236. settings = Settings(database_path=str(tmp_path / "agent_lab.sqlite3"))
  237. app = create_app(settings=settings)
  238. client = TestClient(app)
  239. with client.websocket_connect("/ws/debug") as websocket:
  240. websocket.send_json({"chat_agent": {"model": "fake-model"}})
  241. message = websocket.receive_json()
  242. assert message["type"] == "error"
  243. assert "user_message" in message["message"]
  244. def test_websocket_debug_streams_runtime_messages():
  245. runtime = FakeRuntime()
  246. app = create_app(runtime_factory=lambda: runtime)
  247. client = TestClient(app)
  248. with client.websocket_connect("/ws/debug") as websocket:
  249. websocket.send_json(_request_payload())
  250. assert websocket.receive_json() == {"type": "session_started"}
  251. assert websocket.receive_json() == {
  252. "type": "message_delta",
  253. "content": "hello",
  254. }
  255. assert websocket.receive_json() == {"type": "done"}
  256. assert runtime.requests[0].user_message == "debug this"
  257. assert runtime.requests[0].pre_messages[0].content == "previous turn"
  258. def test_websocket_debug_logs_session_lifecycle(caplog):
  259. runtime = FakeRuntime()
  260. app = create_app(runtime_factory=lambda: runtime)
  261. client = TestClient(app)
  262. with caplog.at_level(logging.INFO, logger="agent_lab.presentation.web"):
  263. with client.websocket_connect("/ws/debug") as websocket:
  264. websocket.send_json(_request_payload())
  265. assert websocket.receive_json() == {"type": "session_started"}
  266. assert websocket.receive_json() == {
  267. "type": "message_delta",
  268. "content": "hello",
  269. }
  270. assert websocket.receive_json() == {"type": "done"}
  271. assert "websocket session accepted" in caplog.text
  272. assert "websocket request accepted" in caplog.text
  273. assert "websocket session closed" in caplog.text
  274. def test_websocket_debug_enqueues_user_messages_during_running_session():
  275. runtime = QueueAwareRuntime()
  276. app = create_app(runtime_factory=lambda: runtime)
  277. client = TestClient(app)
  278. with client.websocket_connect("/ws/debug") as websocket:
  279. websocket.send_json(_request_payload())
  280. assert websocket.receive_json() == {"type": "session_started"}
  281. websocket.send_json({"type": "user_message", "content": "follow-up"})
  282. assert websocket.receive_json() == {
  283. "type": "message_delta",
  284. "content": "follow-up",
  285. }
  286. assert websocket.receive_json() == {"type": "done"}
  287. assert runtime.requests[0].user_message == "debug this"
  288. def test_websocket_debug_keeps_session_open_for_multiple_turns():
  289. runtime = PersistentSessionRuntime()
  290. app = create_app(runtime_factory=lambda: runtime)
  291. client = TestClient(app)
  292. with client.websocket_connect("/ws/debug") as websocket:
  293. websocket.send_json(_request_payload())
  294. assert websocket.receive_json() == {"type": "session_started"}
  295. assert websocket.receive_json() == {"type": "turn_started", "turn_index": 1}
  296. assert websocket.receive_json() == {
  297. "type": "message_delta",
  298. "content": "debug this",
  299. }
  300. assert websocket.receive_json() == {"type": "turn_completed", "turn_index": 1}
  301. websocket.send_json({"type": "user_message", "content": "follow-up"})
  302. assert websocket.receive_json() == {"type": "turn_started", "turn_index": 2}
  303. assert websocket.receive_json() == {
  304. "type": "message_delta",
  305. "content": "follow-up",
  306. }
  307. assert websocket.receive_json() == {"type": "turn_completed", "turn_index": 2}
  308. websocket.close()
  309. assert runtime.requests[0].user_message == "debug this"
  310. def test_websocket_debug_sends_error_for_invalid_request():
  311. app = create_app(runtime_factory=FakeRuntime)
  312. client = TestClient(app)
  313. with client.websocket_connect("/ws/debug") as websocket:
  314. websocket.send_json({"chat_agent": {"model": "fake-model"}})
  315. message = websocket.receive_json()
  316. assert message["type"] == "error"
  317. assert "user_message" in message["message"]
  318. def test_debug_run_request_rejects_invalid_pre_message_role():
  319. payload = _request_payload()
  320. payload["pre_messages"] = [{"role": "developer", "content": "invalid"}]
  321. with pytest.raises(ValidationError) as exc_info:
  322. DebugRunRequest.model_validate(payload)
  323. assert "role" in str(exc_info.value)
  324. def test_debug_run_request_rejects_tool_pre_messages():
  325. payload = _request_payload()
  326. payload["pre_messages"] = [
  327. {
  328. "role": "tool",
  329. "content": "orphan result",
  330. "tool_call_id": "call_1",
  331. }
  332. ]
  333. with pytest.raises(ValidationError, match="pre_messages cannot include tool messages"):
  334. DebugRunRequest.model_validate(payload)
  335. def test_chat_message_allows_internal_tool_replies():
  336. message = ChatMessage(
  337. role="tool",
  338. content='{"message":"handled"}',
  339. name="handoff_note",
  340. tool_call_id="call_1",
  341. )
  342. assert message.role == "tool"
  343. assert message.tool_call_id == "call_1"
  344. def test_chat_message_preserves_assistant_tool_calls():
  345. message = ChatMessage(
  346. role="assistant",
  347. content="I will inspect both sources.",
  348. tool_calls=[
  349. ToolCallEvent(
  350. id="call_1",
  351. name="mock_search",
  352. arguments={"query": "latency docs"},
  353. raw_arguments='{"query":"latency docs"}',
  354. ),
  355. ToolCallEvent(
  356. id="call_2",
  357. name="handoff_note",
  358. arguments={"message": "inspect provider behavior"},
  359. raw_arguments='{"message":"inspect provider behavior"}',
  360. ),
  361. ],
  362. )
  363. assert message.model_dump()["tool_calls"] == [
  364. {
  365. "id": "call_1",
  366. "name": "mock_search",
  367. "arguments": {"query": "latency docs"},
  368. "raw_arguments": '{"query":"latency docs"}',
  369. },
  370. {
  371. "id": "call_2",
  372. "name": "handoff_note",
  373. "arguments": {"message": "inspect provider behavior"},
  374. "raw_arguments": '{"message":"inspect provider behavior"}',
  375. },
  376. ]
  377. def test_chat_message_rejects_tool_calls_on_non_assistant_role():
  378. with pytest.raises(ValidationError, match="tool_calls require assistant role"):
  379. ChatMessage(
  380. role="user",
  381. content="invalid",
  382. tool_calls=[
  383. ToolCallEvent(
  384. id="call_1",
  385. name="mock_search",
  386. arguments={},
  387. raw_arguments="{}",
  388. )
  389. ],
  390. )
  391. def test_chat_message_rejects_tool_role_without_tool_call_id():
  392. with pytest.raises(ValidationError, match="tool messages require tool_call_id"):
  393. ChatMessage(role="tool", content="orphan result")
  394. class HistoryCapturingChatClient:
  395. def __init__(self) -> None:
  396. self.calls = 0
  397. self.second_call_messages: list[ChatMessage] = []
  398. async def stream_chat(
  399. self,
  400. messages: list[ChatMessage],
  401. tools: list[dict],
  402. params: AgentParams,
  403. ) -> AsyncIterator[StreamItem]:
  404. if tools:
  405. yield _event_tool_call_from_tools(tools, messages)
  406. return
  407. self.calls += 1
  408. if self.calls == 1:
  409. yield StreamItem.message_delta("Need event help.")
  410. yield StreamItem.text_event(
  411. ToolCallEvent(
  412. id="call_1",
  413. name="handoff_note",
  414. arguments={},
  415. raw_arguments="{}",
  416. )
  417. )
  418. return
  419. self.second_call_messages = list(messages)
  420. yield StreamItem.message_delta("Final answer.")
  421. @pytest.mark.asyncio
  422. async def test_runtime_appends_event_summary_without_tool_reply_history():
  423. request = DebugRunRequest(
  424. user_message="debug this",
  425. system_prompts=["You are a debugger."],
  426. pre_messages=[],
  427. chat_agent=AgentParams(model="fake-model", temperature=0.1, max_tokens=200),
  428. event_agent=EventAgentParams(enabled_tools=["handoff_note"], max_event_loops=2),
  429. )
  430. client = HistoryCapturingChatClient()
  431. runtime = DebugRuntime(client)
  432. outputs = [message async for message in runtime.run(request)]
  433. assert client.calls == 2
  434. assert [message.role for message in client.second_call_messages] == [
  435. "system",
  436. "system",
  437. "user",
  438. "assistant",
  439. "user",
  440. ]
  441. assert "Available events:" in client.second_call_messages[1].content
  442. assert client.second_call_messages[3].content == "Need event help."
  443. assert not any(message.role == "tool" for message in client.second_call_messages)
  444. assert client.second_call_messages[4].content.startswith("EventAgent results:\n")
  445. assert client.second_call_messages[4].name == "event_agent"
  446. assert outputs[-1] == {"type": "done"}
  447. @pytest.mark.asyncio
  448. async def test_openai_chat_client_streams_sse_chunks_through_parser():
  449. requests: list[httpx.Request] = []
  450. def handler(request: httpx.Request) -> httpx.Response:
  451. requests.append(request)
  452. payload = json.loads(request.content)
  453. assert payload["stream"] is True
  454. assert payload["model"] == "model-x"
  455. assert payload["messages"] == [{"role": "user", "content": "hi"}]
  456. assert payload["tools"][0]["function"]["name"] == "handoff_note"
  457. assert "tool_choice" not in payload
  458. assert payload["temperature"] == 0.3
  459. assert payload["max_tokens"] == 50
  460. assert request.headers["authorization"] == "Bearer test-key"
  461. return httpx.Response(
  462. 200,
  463. content=(
  464. b'data: {"choices":[{"delta":{"content":"hi"},"finish_reason":null}]}\n\n'
  465. b"data: [DONE]\n\n"
  466. ),
  467. )
  468. async with httpx.AsyncClient(
  469. transport=httpx.MockTransport(handler),
  470. base_url="https://llm.test/v1",
  471. ) as http_client:
  472. client = OpenAICompatibleChatClient(
  473. api_key="test-key",
  474. base_url="https://llm.test/v1",
  475. default_model="default-model",
  476. request_timeout_seconds=5,
  477. http_client=http_client,
  478. )
  479. items = [
  480. item
  481. async for item in client.stream_chat(
  482. messages=[ChatMessage(role="user", content="hi")],
  483. tools=[
  484. {
  485. "type": "function",
  486. "function": {"name": "handoff_note", "parameters": {}},
  487. }
  488. ],
  489. params=AgentParams(model="model-x", temperature=0.3, max_tokens=50),
  490. )
  491. ]
  492. assert requests[0].url.path == "/v1/chat/completions"
  493. assert [item.content for item in items if item.kind == "message_delta"] == ["hi"]
  494. assert [item.raw_chunk for item in items if item.kind == "raw_chunk"] == [
  495. {"choices": [{"delta": {"content": "hi"}, "finish_reason": None}]}
  496. ]
  497. @pytest.mark.asyncio
  498. async def test_openai_chat_client_serializes_explicit_tool_choice():
  499. captured_payloads: list[dict] = []
  500. forced_choice = {
  501. "type": "function",
  502. "function": {"name": "handoff_note"},
  503. }
  504. def handler(request: httpx.Request) -> httpx.Response:
  505. captured_payloads.append(json.loads(request.content))
  506. return httpx.Response(200, content=b"data: [DONE]\n\n")
  507. async with httpx.AsyncClient(
  508. transport=httpx.MockTransport(handler),
  509. base_url="https://llm.test/v1",
  510. ) as http_client:
  511. client = OpenAICompatibleChatClient(
  512. api_key="",
  513. base_url="https://llm.test/v1",
  514. default_model="provider-default",
  515. request_timeout_seconds=5,
  516. http_client=http_client,
  517. )
  518. [
  519. item
  520. async for item in client.stream_chat(
  521. messages=[ChatMessage(role="user", content="prepare handoff")],
  522. tools=[
  523. {
  524. "type": "function",
  525. "function": {"name": "handoff_note", "parameters": {}},
  526. }
  527. ],
  528. params=AgentParams(model="provider-default"),
  529. tool_choice=forced_choice,
  530. )
  531. ]
  532. assert captured_payloads[0]["tool_choice"] == forced_choice
  533. @pytest.mark.asyncio
  534. async def test_openai_chat_client_flushes_one_provider_tool_call_at_done():
  535. def handler(request: httpx.Request) -> httpx.Response:
  536. return httpx.Response(
  537. 200,
  538. content=(
  539. b'data: {"choices":[{"delta":{"tool_calls":[{"index":0,'
  540. b'"id":"call_1","function":{"name":"mock_search",'
  541. b'"arguments":"{\\"query\\":\\""}}]},"finish_reason":null}]}\n\n'
  542. b'data: {"choices":[{"delta":{"tool_calls":[{"index":0,'
  543. b'"function":{"arguments":"latency docs\\"}"}}]},'
  544. b'"finish_reason":null}]}\n\n'
  545. b"data: [DONE]\n\n"
  546. ),
  547. )
  548. async with httpx.AsyncClient(
  549. transport=httpx.MockTransport(handler),
  550. base_url="https://llm.test/v1",
  551. ) as http_client:
  552. client = OpenAICompatibleChatClient(
  553. api_key="",
  554. base_url="https://llm.test/v1",
  555. default_model="provider-default",
  556. request_timeout_seconds=5,
  557. http_client=http_client,
  558. )
  559. items = [
  560. item
  561. async for item in client.stream_chat(
  562. messages=[ChatMessage(role="user", content="find docs")],
  563. tools=[
  564. {
  565. "type": "function",
  566. "function": {"name": "mock_search", "parameters": {}},
  567. }
  568. ],
  569. params=AgentParams(model="provider-default"),
  570. )
  571. ]
  572. provider_calls = [
  573. item.event for item in items if item.kind == "provider_tool_call"
  574. ]
  575. assert provider_calls == [
  576. ToolCallEvent(
  577. id="call_1",
  578. name="mock_search",
  579. arguments={"query": "latency docs"},
  580. raw_arguments='{"query":"latency docs"}',
  581. )
  582. ]
  583. @pytest.mark.asyncio
  584. async def test_openai_chat_client_uses_default_model_when_request_model_is_blank():
  585. captured_payloads: list[dict] = []
  586. def handler(request: httpx.Request) -> httpx.Response:
  587. captured_payloads.append(json.loads(request.content))
  588. return httpx.Response(
  589. 200,
  590. content=b'data: {"choices":[{"delta":{"content":"hi"},"finish_reason":null}]}\n\n',
  591. )
  592. async with httpx.AsyncClient(
  593. transport=httpx.MockTransport(handler),
  594. base_url="https://llm.test/v1",
  595. ) as http_client:
  596. client = OpenAICompatibleChatClient(
  597. api_key="",
  598. base_url="https://llm.test/v1",
  599. default_model="provider-default",
  600. request_timeout_seconds=5,
  601. http_client=http_client,
  602. )
  603. [
  604. item
  605. async for item in client.stream_chat(
  606. messages=[ChatMessage(role="user", content="hi")],
  607. tools=[],
  608. params=AgentParams(model=" ", temperature=0.3, max_tokens=50),
  609. )
  610. ]
  611. assert captured_payloads[0]["model"] == "provider-default"
  612. @pytest.mark.asyncio
  613. async def test_openai_chat_client_serializes_provider_tool_transcript():
  614. captured_payloads: list[dict] = []
  615. def handler(request: httpx.Request) -> httpx.Response:
  616. captured_payloads.append(json.loads(request.content))
  617. return httpx.Response(
  618. 200,
  619. content=b'data: {"choices":[{"delta":{"content":"done"},"finish_reason":null}]}\n\n',
  620. )
  621. async with httpx.AsyncClient(
  622. transport=httpx.MockTransport(handler),
  623. base_url="https://llm.test/v1",
  624. ) as http_client:
  625. client = OpenAICompatibleChatClient(
  626. api_key="",
  627. base_url="https://llm.test/v1",
  628. default_model="provider-default",
  629. request_timeout_seconds=5,
  630. http_client=http_client,
  631. )
  632. [
  633. item
  634. async for item in client.stream_chat(
  635. messages=[
  636. ChatMessage(role="user", content="inspect both"),
  637. ChatMessage(
  638. role="assistant",
  639. content="I will inspect both sources.",
  640. tool_calls=[
  641. ToolCallEvent(
  642. id="call_1",
  643. name="mock_search",
  644. arguments={"query": "latency docs"},
  645. raw_arguments='{"query":"latency docs"}',
  646. ),
  647. ToolCallEvent(
  648. id="call_2",
  649. name="handoff_note",
  650. arguments={"message": "inspect provider behavior"},
  651. raw_arguments='{"message":"inspect provider behavior"}',
  652. ),
  653. ],
  654. ),
  655. ChatMessage(
  656. role="tool",
  657. content='{"results":[]}',
  658. name="mock_search",
  659. tool_call_id="call_1",
  660. ),
  661. ChatMessage(
  662. role="tool",
  663. content='{"message":"handled"}',
  664. name="handoff_note",
  665. tool_call_id="call_2",
  666. ),
  667. ChatMessage(role="user", content="continue"),
  668. ],
  669. tools=[],
  670. params=AgentParams(
  671. model="provider-default",
  672. temperature=0.3,
  673. max_tokens=50,
  674. ),
  675. )
  676. ]
  677. assert captured_payloads[0]["messages"] == [
  678. {"role": "user", "content": "inspect both"},
  679. {
  680. "role": "assistant",
  681. "content": "I will inspect both sources.",
  682. "tool_calls": [
  683. {
  684. "id": "call_1",
  685. "type": "function",
  686. "function": {
  687. "name": "mock_search",
  688. "arguments": '{"query":"latency docs"}',
  689. },
  690. },
  691. {
  692. "id": "call_2",
  693. "type": "function",
  694. "function": {
  695. "name": "handoff_note",
  696. "arguments": '{"message":"inspect provider behavior"}',
  697. },
  698. },
  699. ],
  700. },
  701. {
  702. "role": "tool",
  703. "content": '{"results":[]}',
  704. "tool_call_id": "call_1",
  705. },
  706. {
  707. "role": "tool",
  708. "content": '{"message":"handled"}',
  709. "tool_call_id": "call_2",
  710. },
  711. {"role": "user", "content": "continue"},
  712. ]
  713. async def _assert_tool_transcript_rejected(
  714. messages: list[ChatMessage],
  715. error_match: str,
  716. ) -> None:
  717. network_called = False
  718. def handler(request: httpx.Request) -> httpx.Response:
  719. nonlocal network_called
  720. network_called = True
  721. return httpx.Response(200)
  722. async with httpx.AsyncClient(
  723. transport=httpx.MockTransport(handler),
  724. base_url="https://llm.test/v1",
  725. ) as http_client:
  726. client = OpenAICompatibleChatClient(
  727. api_key="",
  728. base_url="https://llm.test/v1",
  729. default_model="provider-default",
  730. request_timeout_seconds=5,
  731. http_client=http_client,
  732. )
  733. with pytest.raises(ValueError, match=error_match):
  734. [
  735. item
  736. async for item in client.stream_chat(
  737. messages=messages,
  738. tools=[],
  739. params=AgentParams(model="provider-default"),
  740. )
  741. ]
  742. assert network_called is False
  743. @pytest.mark.asyncio
  744. async def test_openai_chat_client_rejects_orphan_tool_reply_before_network():
  745. await _assert_tool_transcript_rejected(
  746. [
  747. ChatMessage(
  748. role="tool",
  749. content="orphan",
  750. tool_call_id="call_1",
  751. )
  752. ],
  753. "orphan tool reply: call_1",
  754. )
  755. @pytest.mark.asyncio
  756. async def test_openai_chat_client_rejects_mismatched_tool_call_id_before_network():
  757. await _assert_tool_transcript_rejected(
  758. [
  759. ChatMessage(
  760. role="assistant",
  761. content="",
  762. tool_calls=[
  763. ToolCallEvent(
  764. id="call_1",
  765. name="mock_search",
  766. arguments={},
  767. raw_arguments="{}",
  768. )
  769. ],
  770. ),
  771. ChatMessage(role="tool", content="wrong", tool_call_id="call_2"),
  772. ],
  773. "mismatched tool_call_id: call_2",
  774. )
  775. @pytest.mark.asyncio
  776. async def test_openai_chat_client_rejects_duplicate_tool_reply_before_network():
  777. await _assert_tool_transcript_rejected(
  778. [
  779. ChatMessage(
  780. role="assistant",
  781. content="",
  782. tool_calls=[
  783. ToolCallEvent(
  784. id="call_1",
  785. name="mock_search",
  786. arguments={},
  787. raw_arguments="{}",
  788. )
  789. ],
  790. ),
  791. ChatMessage(role="tool", content="first", tool_call_id="call_1"),
  792. ChatMessage(role="tool", content="duplicate", tool_call_id="call_1"),
  793. ],
  794. "duplicate tool reply: call_1",
  795. )
  796. @pytest.mark.asyncio
  797. async def test_openai_chat_client_rejects_non_tool_message_with_unresolved_calls():
  798. await _assert_tool_transcript_rejected(
  799. [
  800. ChatMessage(
  801. role="assistant",
  802. content="checking",
  803. tool_calls=[
  804. ToolCallEvent(
  805. id="call_1",
  806. name="mock_search",
  807. arguments={},
  808. raw_arguments="{}",
  809. ),
  810. ToolCallEvent(
  811. id="call_2",
  812. name="handoff_note",
  813. arguments={},
  814. raw_arguments="{}",
  815. ),
  816. ],
  817. ),
  818. ChatMessage(role="tool", content="done", tool_call_id="call_1"),
  819. ChatMessage(role="user", content="continue too early"),
  820. ],
  821. "unresolved tool calls before user message: call_2",
  822. )
  823. @pytest.mark.asyncio
  824. async def test_openai_chat_client_rejects_unfinished_tool_calls_at_end():
  825. await _assert_tool_transcript_rejected(
  826. [
  827. ChatMessage(
  828. role="assistant",
  829. content="checking",
  830. tool_calls=[
  831. ToolCallEvent(
  832. id="call_1",
  833. name="mock_search",
  834. arguments={},
  835. raw_arguments="{}",
  836. )
  837. ],
  838. )
  839. ],
  840. "unresolved tool calls at end: call_1",
  841. )
  842. def test_static_pre_message_role_selector_does_not_offer_tool():
  843. html = Path("src/agent_lab/presentation/static/index.html").read_text()
  844. assert '<option value="tool">tool</option>' not in html
  845. def test_static_tools_ui_is_dynamic_and_not_hardcoded_to_handoff_note_checkbox():
  846. html = Path("src/agent_lab/presentation/static/index.html").read_text()
  847. js = Path("src/agent_lab/presentation/static/app.js").read_text()
  848. assert 'id="tool-handoff-note"' not in html
  849. assert "#tool-handoff-note" not in js
  850. assert 'id="tool-list"' in html
  851. assert '"/api/tools"' in js
  852. assert "fetch" in js
  853. def test_static_workspace_snapshot_controls_are_available_outside_agent_config():
  854. html = Path("src/agent_lab/presentation/static/index.html").read_text()
  855. chat_dialog_start = html.index('<dialog id="chat-config-dialog"')
  856. event_dialog_start = html.index('<dialog id="event-config-dialog"')
  857. chat_dialog_html = html[chat_dialog_start:event_dialog_start]
  858. sidebar_html = html[:chat_dialog_start]
  859. assert 'id="open-prompt-config"' not in html
  860. assert '<dialog id="prompt-dialog"' not in html
  861. assert 'id="workspace-snapshot-name"' in sidebar_html
  862. assert 'id="saved-workspace-snapshots"' in sidebar_html
  863. assert 'id="save-workspace-snapshot"' in sidebar_html
  864. assert 'id="load-workspace-snapshot"' in sidebar_html
  865. assert 'id="delete-workspace-snapshot"' in sidebar_html
  866. assert 'id="workspace-snapshot-name"' not in chat_dialog_html
  867. assert 'id="saved-workspace-snapshots"' not in chat_dialog_html
  868. assert 'id="save-workspace-snapshot"' not in chat_dialog_html
  869. assert 'id="system-prompts"' in chat_dialog_html
  870. assert 'id="pre-messages"' in chat_dialog_html
  871. def test_static_session_ui_controls_and_replay_panels_are_available():
  872. html = Path("src/agent_lab/presentation/static/index.html").read_text()
  873. chat_dialog_start = html.index('<dialog id="chat-config-dialog"')
  874. event_dialog_start = html.index('<dialog id="event-config-dialog"')
  875. chat_dialog_html = html[chat_dialog_start:event_dialog_start]
  876. event_dialog_html = html[event_dialog_start:]
  877. sidebar_html = html[:chat_dialog_start]
  878. for control_id in [
  879. "session-title",
  880. "session-list",
  881. "new-session",
  882. "refresh-sessions",
  883. "load-session",
  884. "session-status",
  885. ]:
  886. assert f'id="{control_id}"' in sidebar_html
  887. assert f'id="{control_id}"' not in chat_dialog_html
  888. assert f'id="{control_id}"' not in event_dialog_html
  889. for replay_id in [
  890. "audit-replay",
  891. "audit-count",
  892. "session-usage-summary",
  893. "session-usage-turns",
  894. "session-usage-calls",
  895. ]:
  896. assert f'id="{replay_id}"' in html
  897. def test_static_chat_agent_prompt_is_a_single_sortable_list_with_event_instructions():
  898. html = Path("src/agent_lab/presentation/static/index.html").read_text()
  899. js = Path("src/agent_lab/presentation/static/app.js").read_text()
  900. css = Path("src/agent_lab/presentation/static/styles.css").read_text()
  901. assert 'id="chat-system-prompt"' not in html
  902. assert 'id="chat-event-prompt-preview"' not in html
  903. assert 'class="prompt-list stack"' in html
  904. assert 'class="prompt-item"' in html
  905. assert 'data-prompt-role="system"' in html
  906. assert "prompt-type" in html
  907. assert "function buildAvailableEventsPrompt(" in js
  908. assert "Available events:" in js
  909. assert "function updateEventPromptItem(" in js
  910. assert "function createSystemPrompt(" in js
  911. assert "function movePromptItem(" in js
  912. assert 'draggable = true' in js
  913. assert "dataset.promptRole" in js
  914. assert "prompt-title" in js
  915. assert "delete-system-prompt" in js
  916. assert 'prompt_kind: "event_agent_system"' in js
  917. assert "system_prompts: collectSystemPrompts()" in js
  918. assert 'system_prompt: ""' in js
  919. assert ".prompt-item" in css
  920. assert ".prompt-item.dragging" in css
  921. assert ".prompt-meta" in css
  922. assert ".prompt-type" in css
  923. assert ".prompt-actions button" in css
  924. assert "#open-prompt-config" not in js
  925. assert "promptDialog" not in js
  926. def test_static_app_script_is_versioned_and_click_binding_is_guarded():
  927. html = Path("src/agent_lab/presentation/static/index.html").read_text()
  928. js = Path("src/agent_lab/presentation/static/app.js").read_text()
  929. assert '<script src="/static/app.js?v=' in html
  930. assert "function bindClick(" in js
  931. assert 'bindClick("#open-chat-config-panel", openChatConfig)' in js
  932. assert 'bindClick("#open-event-config-panel", openEventConfig)' in js
  933. def test_static_agent_config_controls_are_aligned_for_chat_and_event_agents():
  934. html = Path("src/agent_lab/presentation/static/index.html").read_text()
  935. js = Path("src/agent_lab/presentation/static/app.js").read_text()
  936. assert '<dialog id="chat-config-dialog"' in html
  937. assert '<dialog id="event-config-dialog"' in html
  938. assert html.count('id="open-chat-config-panel"') == 1
  939. assert html.count('id="open-event-config-panel"') == 1
  940. assert 'id="open-chat-config"' not in html
  941. assert 'id="open-event-config"' not in html
  942. assert '#open-chat-config"' not in js
  943. assert '#open-event-config"' not in js
  944. assert '#open-chat-config-panel"' in js
  945. assert '#open-event-config-panel"' in js
  946. assert 'id="close-chat-config"' in html
  947. assert 'id="close-event-config"' in html
  948. assert "chatConfigDialog.showModal()" in js
  949. assert "eventConfigDialog.showModal()" in js
  950. assert "chatConfigDialog.close()" in js
  951. assert "eventConfigDialog.close()" in js
  952. for control_id in [
  953. "model",
  954. "temperature",
  955. "max-tokens",
  956. "chat-thinking-disabled",
  957. "chat-enable-search",
  958. "chat-forced-search",
  959. "event-model",
  960. "event-temperature",
  961. "event-max-tokens",
  962. "event-system-prompt",
  963. "event-thinking-disabled",
  964. "event-enable-search",
  965. "event-forced-search",
  966. ]:
  967. assert f'id="{control_id}"' in html
  968. assert 'extra_body: buildExtraBody("chat")' in js
  969. assert 'extra_body: buildExtraBody("event")' in js
  970. assert 'system_prompt: ""' in js
  971. assert 'system_prompt: document.querySelector("#event-system-prompt")' in js
  972. def test_static_tools_ui_shows_tool_metadata_and_selection_actions():
  973. html = Path("src/agent_lab/presentation/static/index.html").read_text()
  974. js = Path("src/agent_lab/presentation/static/app.js").read_text()
  975. assert 'id="tool-count"' in html
  976. assert 'id="select-all-tools"' in html
  977. assert 'id="clear-tools"' in html
  978. assert "tool-card" in js
  979. assert "tool-description" in js
  980. assert "tool-required" in js
  981. assert "function updateToolCount()" in js
  982. def test_static_app_handles_audit_messages():
  983. js = Path("src/agent_lab/presentation/static/app.js").read_text()
  984. css = Path("src/agent_lab/presentation/static/styles.css").read_text()
  985. assert 'message.type === "audit"' in js
  986. assert "appendAuditEntry(message)" in js
  987. assert 'appendLog("audit"' not in js
  988. assert "function renderAuditDetail(" in js
  989. assert "chat_agent_request" in js
  990. assert "event_agent_response" in js
  991. assert "chat_message_stream_started" in js
  992. assert "chat_message_stream_finished" in js
  993. assert ".audit-event" in css
  994. assert ".audit-detail" in css
  995. def test_static_audit_replay_uses_event_stream_and_detail_panel():
  996. html = Path("src/agent_lab/presentation/static/index.html").read_text()
  997. js = Path("src/agent_lab/presentation/static/app.js").read_text()
  998. css = Path("src/agent_lab/presentation/static/styles.css").read_text()
  999. assert '<link rel="stylesheet" href="/static/styles.css?v=' in html
  1000. assert "session-switch-idle-socket" in html
  1001. assert 'id="audit-dialog"' in html
  1002. assert 'id="open-audit-dialog"' in html
  1003. assert 'id="close-audit-dialog"' in html
  1004. assert 'id="audit-summary"' in html
  1005. assert 'id="audit-replay" class="audit-stream"' in html
  1006. assert 'id="audit-detail"' in html
  1007. assert "auditDialog.showModal()" in js
  1008. assert "auditDialog.close()" in js
  1009. assert "auditEntryKey(entry)" in js
  1010. assert "selectAuditEntry(key)" in js
  1011. assert 'meta.className = "audit-detail-meta"' in js
  1012. assert 'appendMetaField(meta, "Since User Message"' in js
  1013. assert "auditRelativeTime(entry)" in js
  1014. assert 'const list = document.createElement("ul")' in js
  1015. assert "appendKeyValueSection(auditDetail, \"Model Parameters\"" in js
  1016. assert "appendRepliesSection(auditDetail, details.replies)" in js
  1017. assert "appendRawChunksSection(auditDetail, \"Raw LLM Chunks\"" in js
  1018. assert "appendRawModelChunksSection(auditDetail, details.raw_model_chunks)" in js
  1019. assert "Enabled This Round" in js
  1020. assert "Configured Events" in js
  1021. assert "details.configured_events" in js
  1022. assert "enabled this round" in js
  1023. assert ".audit-modal" in css
  1024. assert ".audit-modal-body" in css
  1025. assert ".audit-modal-body section + section" in css
  1026. assert ".audit-detail-title" in css
  1027. assert ".detail-tags code" in css
  1028. assert ".audit-detail-meta span" not in css
  1029. assert ".detail-tags span" not in css
  1030. assert "display: block" in _css_rule(css, ".audit-detail")
  1031. assert "display: grid" not in _css_rule(css, ".audit-detail")
  1032. assert "display: block" in _css_rule(css, ".audit-detail-header")
  1033. assert "display: grid" not in _css_rule(css, ".audit-detail-header")
  1034. assert "display: inline-flex" in _css_rule(css, ".audit-detail-meta div")
  1035. assert ".audit-marker::before" in css
  1036. assert ".audit-event.is-selected" in css
  1037. def test_static_layout_keeps_controls_and_composer_fixed_with_scrollable_panes():
  1038. css = Path("src/agent_lab/presentation/static/styles.css").read_text()
  1039. assert "height: calc(100vh - 56px)" in css
  1040. assert ".layout" in css and "overflow: hidden" in css
  1041. assert ".controls" in css and "overflow: auto" in css
  1042. assert ".workspace {\n display: grid;\n grid-template-rows: minmax(0, 1fr) auto auto;" in css
  1043. assert ".workspace-body" in css and "overflow: hidden" in css
  1044. assert ".messages" in css and "overflow: auto" in css
  1045. assert ".audit-stream" in css and "overflow: auto" in css
  1046. def test_static_app_shows_wait_state_and_immediate_user_echo():
  1047. html = Path("src/agent_lab/presentation/static/index.html").read_text()
  1048. js = Path("src/agent_lab/presentation/static/app.js").read_text()
  1049. css = Path("src/agent_lab/presentation/static/styles.css").read_text()
  1050. assert 'id="run-button"' in html
  1051. assert 'const runButton = document.querySelector("#run-button")' in js
  1052. assert 'appendLog("user", submittedMessage)' in js
  1053. assert 'statusEl.textContent = "Waiting for first token"' in js
  1054. assert "function startElapsedTimer()" in js
  1055. assert "function stopElapsedTimer()" in js
  1056. assert "function setRunState(" in js
  1057. assert "if (firstTokenAt) {\n hasBackendRoundStats = true;" in js
  1058. assert "hasBackendRoundStats && firstTokenAt" in js
  1059. assert ".user" in css
  1060. def test_static_app_reuses_websocket_for_session_turns():
  1061. js = Path("src/agent_lab/presentation/static/app.js").read_text()
  1062. assert "function isSocketOpen()" in js
  1063. assert "socket.send(JSON.stringify({ type: \"user_message\"" in js
  1064. assert "session_id: currentSessionId || undefined" in js
  1065. assert "message.session_id" in js
  1066. assert 'message.type === "turn_completed"' in js
  1067. assert 'message.type === "done"' in js
  1068. def test_static_app_loads_session_replay_and_usage():
  1069. js = Path("src/agent_lab/presentation/static/app.js").read_text()
  1070. assert "function loadSessions(" in js
  1071. assert "function createSession(" in js
  1072. assert "function setSessionStatus(" in js
  1073. assert '"Refreshing sessions"' in js
  1074. assert '"Creating session"' in js
  1075. assert '"Session created"' in js
  1076. assert 'sessionStatus.textContent = message' in js
  1077. assert "function loadSessionReplay(" in js
  1078. assert 'fetchJson("/api/sessions")' in js
  1079. assert 'method: "POST"' in js
  1080. assert '`/api/sessions/${sessionId}/messages`' in js
  1081. assert '`/api/sessions/${sessionId}/audit`' in js
  1082. assert '`/api/sessions/${sessionId}/usage`' in js
  1083. assert "function renderAuditReplay(" in js
  1084. assert "function renderSessionUsage(" in js
  1085. assert "sessionUsageSummary" in js
  1086. assert "Finish current turn before switching sessions" in js
  1087. def test_static_session_switch_guard_uses_active_turn_not_open_socket():
  1088. js = Path("src/agent_lab/presentation/static/app.js").read_text()
  1089. create_session = js[js.index("async function createSession()") : js.index("async function loadSelectedSession()")]
  1090. load_session = js[js.index("async function loadSelectedSession()") : js.index("async function loadSessionReplay(")]
  1091. assert "function hasActiveTurn()" in js
  1092. assert "turnActive = isRunning" in js
  1093. assert "if (hasActiveTurn())" in create_session
  1094. assert "if (hasActiveTurn())" in load_session
  1095. assert "if (isSocketOpen())" not in create_session
  1096. assert "if (isSocketOpen())" not in load_session
  1097. assert "closeSocket();" in create_session
  1098. assert "closeSocket();" in load_session
  1099. assert "const activeSocket = socket" in js
  1100. assert "if (socket !== activeSocket)" in js
  1101. def test_static_workspace_snapshot_uses_stable_storage_hooks():
  1102. js = Path("src/agent_lab/presentation/static/app.js").read_text()
  1103. assert "agent-lab.workspace-snapshots.v1" in js
  1104. assert "agent-lab.prompt-sets.v1" in js
  1105. assert "function saveWorkspaceSnapshot()" in js
  1106. assert "function loadWorkspaceSnapshot()" in js
  1107. assert "function deleteWorkspaceSnapshot()" in js
  1108. assert "function buildWorkspaceSnapshot()" in js
  1109. assert "prompt_items: buildPromptItems()" in js
  1110. assert "enabled_tools: selectedTools()" in js
  1111. assert "extra_body: buildExtraBody(\"chat\")" in js
  1112. assert "extra_body: buildExtraBody(\"event\")" in js
  1113. def test_static_app_handles_backend_round_stats_messages():
  1114. js = Path("src/agent_lab/presentation/static/app.js").read_text()
  1115. assert 'message.type === "round_stats"' in js
  1116. assert "function updateRoundStats(" in js
  1117. assert "#stat-ttft" in js
  1118. assert "#stat-elapsed" in js
  1119. @pytest.mark.asyncio
  1120. async def test_openai_chat_client_omits_stream_options_when_usage_disabled():
  1121. captured_payloads: list[dict] = []
  1122. def handler(request: httpx.Request) -> httpx.Response:
  1123. captured_payloads.append(json.loads(request.content))
  1124. return httpx.Response(
  1125. 200,
  1126. content=b'data: {"choices":[{"delta":{"content":"hi"},"finish_reason":null}]}\n\n',
  1127. )
  1128. async with httpx.AsyncClient(
  1129. transport=httpx.MockTransport(handler),
  1130. base_url="https://llm.test/v1",
  1131. ) as http_client:
  1132. client = OpenAICompatibleChatClient(
  1133. api_key="",
  1134. base_url="https://llm.test/v1",
  1135. default_model="provider-default",
  1136. request_timeout_seconds=5,
  1137. include_usage=False,
  1138. http_client=http_client,
  1139. )
  1140. [
  1141. item
  1142. async for item in client.stream_chat(
  1143. messages=[ChatMessage(role="user", content="hi")],
  1144. tools=[],
  1145. params=AgentParams(model="provider-default", temperature=0.3, max_tokens=50),
  1146. )
  1147. ]
  1148. assert "stream_options" not in captured_payloads[0]
  1149. @pytest.mark.asyncio
  1150. async def test_openai_chat_client_merges_agent_extra_body_into_payload():
  1151. captured_payloads: list[dict] = []
  1152. def handler(request: httpx.Request) -> httpx.Response:
  1153. captured_payloads.append(json.loads(request.content))
  1154. return httpx.Response(
  1155. 200,
  1156. content=b'data: {"choices":[{"delta":{"content":"hi"},"finish_reason":null}]}\n\n',
  1157. )
  1158. async with httpx.AsyncClient(
  1159. transport=httpx.MockTransport(handler),
  1160. base_url="https://llm.test/v1",
  1161. ) as http_client:
  1162. client = OpenAICompatibleChatClient(
  1163. api_key="",
  1164. base_url="https://llm.test/v1",
  1165. default_model="provider-default",
  1166. request_timeout_seconds=5,
  1167. http_client=http_client,
  1168. )
  1169. [
  1170. item
  1171. async for item in client.stream_chat(
  1172. messages=[ChatMessage(role="user", content="hi")],
  1173. tools=[],
  1174. params=AgentParams(
  1175. model="provider-default",
  1176. temperature=0.3,
  1177. max_tokens=50,
  1178. extra_body={
  1179. "thinking": {"type": "disabled"},
  1180. "enable_search": False,
  1181. "search_options": {"forced_search": False},
  1182. },
  1183. ),
  1184. )
  1185. ]
  1186. assert captured_payloads[0]["thinking"] == {"type": "disabled"}
  1187. assert captured_payloads[0]["enable_search"] is False
  1188. assert captured_payloads[0]["search_options"] == {"forced_search": False}