test_websocket_api.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784
  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
  15. from agent_lab.infrastructure.chat_client import OpenAICompatibleChatClient
  16. from agent_lab.presentation.web import create_app
  17. class FakeRuntime:
  18. def __init__(self) -> None:
  19. self.requests: list[DebugRunRequest] = []
  20. async def run(self, request: DebugRunRequest) -> AsyncIterator[dict]:
  21. self.requests.append(request)
  22. yield {"type": "session_started"}
  23. yield {"type": "message_delta", "content": "hello"}
  24. yield {"type": "done"}
  25. class QueueAwareRuntime:
  26. def __init__(self) -> None:
  27. self.requests: list[DebugRunRequest] = []
  28. self.queues: RuntimeQueues | None = None
  29. self.task: asyncio.Task | None = None
  30. def start(self, request: DebugRunRequest) -> RuntimeQueues:
  31. self.requests.append(request)
  32. self.queues = RuntimeQueues()
  33. self.task = asyncio.create_task(self._run())
  34. return self.queues
  35. async def _run(self) -> None:
  36. assert self.queues is not None
  37. await self.queues.output.put({"type": "session_started"})
  38. message = await self.queues.input.get()
  39. await self.queues.output.put(
  40. {"type": "message_delta", "content": message.content}
  41. )
  42. await self.queues.output.put({"type": "done"})
  43. async def aclose(self) -> None:
  44. if self.task is not None and not self.task.done():
  45. self.task.cancel()
  46. await asyncio.gather(self.task, return_exceptions=True)
  47. class PersistentSessionRuntime:
  48. def __init__(self) -> None:
  49. self.requests: list[DebugRunRequest] = []
  50. self.queues: RuntimeQueues | None = None
  51. self.task: asyncio.Task | None = None
  52. def start_session(self, request: DebugRunRequest) -> RuntimeQueues:
  53. self.requests.append(request)
  54. self.queues = RuntimeQueues()
  55. self.task = asyncio.create_task(self._run(request))
  56. return self.queues
  57. async def _run(self, request: DebugRunRequest) -> None:
  58. assert self.queues is not None
  59. await self.queues.output.put({"type": "session_started"})
  60. await self._emit_turn(1, request.user_message)
  61. turn_index = 1
  62. while True:
  63. message = await self.queues.input.get()
  64. turn_index += 1
  65. await self._emit_turn(turn_index, message.content)
  66. async def _emit_turn(self, turn_index: int, content: str) -> None:
  67. assert self.queues is not None
  68. await self.queues.output.put({"type": "turn_started", "turn_index": turn_index})
  69. await self.queues.output.put({"type": "message_delta", "content": content})
  70. await self.queues.output.put(
  71. {"type": "turn_completed", "turn_index": turn_index}
  72. )
  73. async def aclose(self) -> None:
  74. if self.task is not None and not self.task.done():
  75. self.task.cancel()
  76. await asyncio.gather(self.task, return_exceptions=True)
  77. def _request_payload() -> dict:
  78. return {
  79. "user_message": "debug this",
  80. "system_prompts": ["You are a debugger."],
  81. "pre_messages": [{"role": "user", "content": "previous turn"}],
  82. "chat_agent": {
  83. "model": "fake-model",
  84. "temperature": 0.1,
  85. "max_tokens": 200,
  86. },
  87. "event_agent": {
  88. "enabled_tools": ["handoff_note"],
  89. "max_event_loops": 2,
  90. },
  91. }
  92. def _event_tool_call_from_tools(
  93. tools: list[dict],
  94. messages: list[ChatMessage],
  95. ) -> StreamItem:
  96. tool_name = tools[0]["function"]["name"]
  97. content = ""
  98. for role in ("assistant", "user"):
  99. content = next(
  100. (
  101. message.content
  102. for message in reversed(messages)
  103. if message.role == role and message.content.strip()
  104. ),
  105. "",
  106. )
  107. if content:
  108. break
  109. arguments = {
  110. "message": content,
  111. "query": content,
  112. "title": content,
  113. }
  114. return StreamItem.event(
  115. ToolCallEvent(
  116. id="event_agent_call_1",
  117. name=tool_name,
  118. arguments=arguments,
  119. raw_arguments=json.dumps(arguments),
  120. )
  121. )
  122. def test_agent_params_defaults_include_extra_body_and_event_agent_defaults_to_one_round():
  123. chat_params = AgentParams()
  124. event_params = EventAgentParams()
  125. assert chat_params.extra_body == {
  126. "thinking": {"type": "disabled"},
  127. "enable_search": False,
  128. "search_options": {"forced_search": False},
  129. }
  130. assert event_params.extra_body == chat_params.extra_body
  131. assert event_params.max_event_loops == 1
  132. assert event_params.system_prompt == ""
  133. def test_health_returns_ok():
  134. app = create_app(runtime_factory=FakeRuntime)
  135. client = TestClient(app)
  136. response = client.get("/health")
  137. assert response.status_code == 200
  138. assert response.json() == {"status": "ok"}
  139. def test_api_tools_returns_handoff_note_metadata():
  140. app = create_app(runtime_factory=FakeRuntime)
  141. client = TestClient(app)
  142. response = client.get("/api/tools")
  143. assert response.status_code == 200
  144. tools = response.json()
  145. assert [tool["name"] for tool in tools] == [
  146. "handoff_note",
  147. "mock_search",
  148. "mock_ticket",
  149. ]
  150. assert tools[0]["parameters"]["required"] == ["message"]
  151. def test_websocket_debug_streams_runtime_messages():
  152. runtime = FakeRuntime()
  153. app = create_app(runtime_factory=lambda: runtime)
  154. client = TestClient(app)
  155. with client.websocket_connect("/ws/debug") as websocket:
  156. websocket.send_json(_request_payload())
  157. assert websocket.receive_json() == {"type": "session_started"}
  158. assert websocket.receive_json() == {
  159. "type": "message_delta",
  160. "content": "hello",
  161. }
  162. assert websocket.receive_json() == {"type": "done"}
  163. assert runtime.requests[0].user_message == "debug this"
  164. assert runtime.requests[0].pre_messages[0].content == "previous turn"
  165. def test_websocket_debug_logs_session_lifecycle(caplog):
  166. runtime = FakeRuntime()
  167. app = create_app(runtime_factory=lambda: runtime)
  168. client = TestClient(app)
  169. with caplog.at_level(logging.INFO, logger="agent_lab.presentation.web"):
  170. with client.websocket_connect("/ws/debug") as websocket:
  171. websocket.send_json(_request_payload())
  172. assert websocket.receive_json() == {"type": "session_started"}
  173. assert websocket.receive_json() == {
  174. "type": "message_delta",
  175. "content": "hello",
  176. }
  177. assert websocket.receive_json() == {"type": "done"}
  178. assert "websocket session accepted" in caplog.text
  179. assert "websocket request accepted" in caplog.text
  180. assert "websocket session closed" in caplog.text
  181. def test_websocket_debug_enqueues_user_messages_during_running_session():
  182. runtime = QueueAwareRuntime()
  183. app = create_app(runtime_factory=lambda: runtime)
  184. client = TestClient(app)
  185. with client.websocket_connect("/ws/debug") as websocket:
  186. websocket.send_json(_request_payload())
  187. assert websocket.receive_json() == {"type": "session_started"}
  188. websocket.send_json({"type": "user_message", "content": "follow-up"})
  189. assert websocket.receive_json() == {
  190. "type": "message_delta",
  191. "content": "follow-up",
  192. }
  193. assert websocket.receive_json() == {"type": "done"}
  194. assert runtime.requests[0].user_message == "debug this"
  195. def test_websocket_debug_keeps_session_open_for_multiple_turns():
  196. runtime = PersistentSessionRuntime()
  197. app = create_app(runtime_factory=lambda: runtime)
  198. client = TestClient(app)
  199. with client.websocket_connect("/ws/debug") as websocket:
  200. websocket.send_json(_request_payload())
  201. assert websocket.receive_json() == {"type": "session_started"}
  202. assert websocket.receive_json() == {"type": "turn_started", "turn_index": 1}
  203. assert websocket.receive_json() == {
  204. "type": "message_delta",
  205. "content": "debug this",
  206. }
  207. assert websocket.receive_json() == {"type": "turn_completed", "turn_index": 1}
  208. websocket.send_json({"type": "user_message", "content": "follow-up"})
  209. assert websocket.receive_json() == {"type": "turn_started", "turn_index": 2}
  210. assert websocket.receive_json() == {
  211. "type": "message_delta",
  212. "content": "follow-up",
  213. }
  214. assert websocket.receive_json() == {"type": "turn_completed", "turn_index": 2}
  215. websocket.close()
  216. assert runtime.requests[0].user_message == "debug this"
  217. def test_websocket_debug_sends_error_for_invalid_request():
  218. app = create_app(runtime_factory=FakeRuntime)
  219. client = TestClient(app)
  220. with client.websocket_connect("/ws/debug") as websocket:
  221. websocket.send_json({"chat_agent": {"model": "fake-model"}})
  222. message = websocket.receive_json()
  223. assert message["type"] == "error"
  224. assert "user_message" in message["message"]
  225. def test_debug_run_request_rejects_invalid_pre_message_role():
  226. payload = _request_payload()
  227. payload["pre_messages"] = [{"role": "developer", "content": "invalid"}]
  228. with pytest.raises(ValidationError) as exc_info:
  229. DebugRunRequest.model_validate(payload)
  230. assert "role" in str(exc_info.value)
  231. def test_debug_run_request_rejects_tool_pre_messages():
  232. payload = _request_payload()
  233. payload["pre_messages"] = [
  234. {
  235. "role": "tool",
  236. "content": "orphan result",
  237. "tool_call_id": "call_1",
  238. }
  239. ]
  240. with pytest.raises(ValidationError, match="pre_messages cannot include tool messages"):
  241. DebugRunRequest.model_validate(payload)
  242. def test_chat_message_allows_internal_tool_replies():
  243. message = ChatMessage(
  244. role="tool",
  245. content='{"message":"handled"}',
  246. name="handoff_note",
  247. tool_call_id="call_1",
  248. )
  249. assert message.role == "tool"
  250. assert message.tool_call_id == "call_1"
  251. class HistoryCapturingChatClient:
  252. def __init__(self) -> None:
  253. self.calls = 0
  254. self.second_call_messages: list[ChatMessage] = []
  255. async def stream_chat(
  256. self,
  257. messages: list[ChatMessage],
  258. tools: list[dict],
  259. params: AgentParams,
  260. ) -> AsyncIterator[StreamItem]:
  261. if tools:
  262. yield _event_tool_call_from_tools(tools, messages)
  263. return
  264. self.calls += 1
  265. if self.calls == 1:
  266. yield StreamItem.message_delta("Need event help.")
  267. yield StreamItem.event(
  268. ToolCallEvent(
  269. id="call_1",
  270. name="handoff_note",
  271. arguments={},
  272. raw_arguments="{}",
  273. )
  274. )
  275. return
  276. self.second_call_messages = list(messages)
  277. yield StreamItem.message_delta("Final answer.")
  278. @pytest.mark.asyncio
  279. async def test_runtime_appends_event_summary_without_tool_reply_history():
  280. request = DebugRunRequest(
  281. user_message="debug this",
  282. system_prompts=["You are a debugger."],
  283. pre_messages=[],
  284. chat_agent=AgentParams(model="fake-model", temperature=0.1, max_tokens=200),
  285. event_agent=EventAgentParams(enabled_tools=["handoff_note"], max_event_loops=2),
  286. )
  287. client = HistoryCapturingChatClient()
  288. runtime = DebugRuntime(client)
  289. outputs = [message async for message in runtime.run(request)]
  290. assert client.calls == 2
  291. assert [message.role for message in client.second_call_messages] == [
  292. "system",
  293. "system",
  294. "user",
  295. "assistant",
  296. "user",
  297. ]
  298. assert "Available events:" in client.second_call_messages[1].content
  299. assert client.second_call_messages[3].content == "Need event help."
  300. assert not any(message.role == "tool" for message in client.second_call_messages)
  301. assert client.second_call_messages[4].content.startswith("EventAgent results:\n")
  302. assert client.second_call_messages[4].name == "event_agent"
  303. assert outputs[-1] == {"type": "done"}
  304. @pytest.mark.asyncio
  305. async def test_openai_chat_client_streams_sse_chunks_through_parser():
  306. requests: list[httpx.Request] = []
  307. def handler(request: httpx.Request) -> httpx.Response:
  308. requests.append(request)
  309. payload = json.loads(request.content)
  310. assert payload["stream"] is True
  311. assert payload["model"] == "model-x"
  312. assert payload["messages"] == [{"role": "user", "content": "hi"}]
  313. assert payload["tools"][0]["function"]["name"] == "handoff_note"
  314. assert payload["tool_choice"] == {
  315. "type": "function",
  316. "function": {"name": "handoff_note"},
  317. }
  318. assert payload["temperature"] == 0.3
  319. assert payload["max_tokens"] == 50
  320. assert request.headers["authorization"] == "Bearer test-key"
  321. return httpx.Response(
  322. 200,
  323. content=(
  324. b'data: {"choices":[{"delta":{"content":"hi"},"finish_reason":null}]}\n\n'
  325. b"data: [DONE]\n\n"
  326. ),
  327. )
  328. async with httpx.AsyncClient(
  329. transport=httpx.MockTransport(handler),
  330. base_url="https://llm.test/v1",
  331. ) as http_client:
  332. client = OpenAICompatibleChatClient(
  333. api_key="test-key",
  334. base_url="https://llm.test/v1",
  335. default_model="default-model",
  336. request_timeout_seconds=5,
  337. http_client=http_client,
  338. )
  339. items = [
  340. item
  341. async for item in client.stream_chat(
  342. messages=[ChatMessage(role="user", content="hi")],
  343. tools=[
  344. {
  345. "type": "function",
  346. "function": {"name": "handoff_note", "parameters": {}},
  347. }
  348. ],
  349. params=AgentParams(model="model-x", temperature=0.3, max_tokens=50),
  350. )
  351. ]
  352. assert requests[0].url.path == "/v1/chat/completions"
  353. assert [item.content for item in items] == ["hi"]
  354. @pytest.mark.asyncio
  355. async def test_openai_chat_client_uses_default_model_when_request_model_is_blank():
  356. captured_payloads: list[dict] = []
  357. def handler(request: httpx.Request) -> httpx.Response:
  358. captured_payloads.append(json.loads(request.content))
  359. return httpx.Response(
  360. 200,
  361. content=b'data: {"choices":[{"delta":{"content":"hi"},"finish_reason":null}]}\n\n',
  362. )
  363. async with httpx.AsyncClient(
  364. transport=httpx.MockTransport(handler),
  365. base_url="https://llm.test/v1",
  366. ) as http_client:
  367. client = OpenAICompatibleChatClient(
  368. api_key="",
  369. base_url="https://llm.test/v1",
  370. default_model="provider-default",
  371. request_timeout_seconds=5,
  372. http_client=http_client,
  373. )
  374. [
  375. item
  376. async for item in client.stream_chat(
  377. messages=[ChatMessage(role="user", content="hi")],
  378. tools=[],
  379. params=AgentParams(model=" ", temperature=0.3, max_tokens=50),
  380. )
  381. ]
  382. assert captured_payloads[0]["model"] == "provider-default"
  383. @pytest.mark.asyncio
  384. async def test_openai_chat_client_rejects_internal_tool_messages_before_network():
  385. network_called = False
  386. def handler(request: httpx.Request) -> httpx.Response:
  387. nonlocal network_called
  388. network_called = True
  389. return httpx.Response(
  390. 200,
  391. content=b'data: {"choices":[{"delta":{"content":"done"},"finish_reason":null}]}\n\n',
  392. )
  393. async with httpx.AsyncClient(
  394. transport=httpx.MockTransport(handler),
  395. base_url="https://llm.test/v1",
  396. ) as http_client:
  397. client = OpenAICompatibleChatClient(
  398. api_key="",
  399. base_url="https://llm.test/v1",
  400. default_model="provider-default",
  401. request_timeout_seconds=5,
  402. http_client=http_client,
  403. )
  404. with pytest.raises(ValueError, match="tool messages are internal"):
  405. [
  406. item
  407. async for item in client.stream_chat(
  408. messages=[
  409. ChatMessage(
  410. role="tool",
  411. content="internal tool result",
  412. tool_call_id="call_1",
  413. )
  414. ],
  415. tools=[],
  416. params=AgentParams(model="provider-default", temperature=0.3, max_tokens=50),
  417. )
  418. ]
  419. assert network_called is False
  420. def test_static_pre_message_role_selector_does_not_offer_tool():
  421. html = Path("src/agent_lab/presentation/static/index.html").read_text()
  422. assert '<option value="tool">tool</option>' not in html
  423. def test_static_tools_ui_is_dynamic_and_not_hardcoded_to_handoff_note_checkbox():
  424. html = Path("src/agent_lab/presentation/static/index.html").read_text()
  425. js = Path("src/agent_lab/presentation/static/app.js").read_text()
  426. assert 'id="tool-handoff-note"' not in html
  427. assert "#tool-handoff-note" not in js
  428. assert 'id="tool-list"' in html
  429. assert '"/api/tools"' in js
  430. assert "fetch" in js
  431. def test_static_workspace_snapshot_controls_are_available_outside_agent_config():
  432. html = Path("src/agent_lab/presentation/static/index.html").read_text()
  433. chat_dialog_start = html.index('<dialog id="chat-config-dialog"')
  434. event_dialog_start = html.index('<dialog id="event-config-dialog"')
  435. chat_dialog_html = html[chat_dialog_start:event_dialog_start]
  436. sidebar_html = html[:chat_dialog_start]
  437. assert 'id="open-prompt-config"' not in html
  438. assert '<dialog id="prompt-dialog"' not in html
  439. assert 'id="workspace-snapshot-name"' in sidebar_html
  440. assert 'id="saved-workspace-snapshots"' in sidebar_html
  441. assert 'id="save-workspace-snapshot"' in sidebar_html
  442. assert 'id="load-workspace-snapshot"' in sidebar_html
  443. assert 'id="delete-workspace-snapshot"' in sidebar_html
  444. assert 'id="workspace-snapshot-name"' not in chat_dialog_html
  445. assert 'id="saved-workspace-snapshots"' not in chat_dialog_html
  446. assert 'id="save-workspace-snapshot"' not in chat_dialog_html
  447. assert 'id="system-prompts"' in chat_dialog_html
  448. assert 'id="pre-messages"' in chat_dialog_html
  449. def test_static_chat_agent_prompt_is_a_single_sortable_list_with_event_instructions():
  450. html = Path("src/agent_lab/presentation/static/index.html").read_text()
  451. js = Path("src/agent_lab/presentation/static/app.js").read_text()
  452. css = Path("src/agent_lab/presentation/static/styles.css").read_text()
  453. assert 'id="chat-system-prompt"' not in html
  454. assert 'id="chat-event-prompt-preview"' not in html
  455. assert 'class="prompt-list stack"' in html
  456. assert 'class="prompt-item"' in html
  457. assert 'data-prompt-role="system"' in html
  458. assert "prompt-type" in html
  459. assert "function buildAvailableEventsPrompt(" in js
  460. assert "Available events:" in js
  461. assert "function updateEventPromptItem(" in js
  462. assert "function createSystemPrompt(" in js
  463. assert "function movePromptItem(" in js
  464. assert 'draggable = true' in js
  465. assert "dataset.promptRole" in js
  466. assert "prompt-title" in js
  467. assert "delete-system-prompt" in js
  468. assert 'prompt_kind: "event_agent_system"' in js
  469. assert "system_prompts: collectSystemPrompts()" in js
  470. assert 'system_prompt: ""' in js
  471. assert ".prompt-item" in css
  472. assert ".prompt-item.dragging" in css
  473. assert ".prompt-meta" in css
  474. assert ".prompt-type" in css
  475. assert ".prompt-actions button" in css
  476. assert "#open-prompt-config" not in js
  477. assert "promptDialog" not in js
  478. def test_static_app_script_is_versioned_and_click_binding_is_guarded():
  479. html = Path("src/agent_lab/presentation/static/index.html").read_text()
  480. js = Path("src/agent_lab/presentation/static/app.js").read_text()
  481. assert '<script src="/static/app.js?v=' in html
  482. assert "function bindClick(" in js
  483. assert 'bindClick("#open-chat-config-panel", openChatConfig)' in js
  484. assert 'bindClick("#open-event-config-panel", openEventConfig)' in js
  485. def test_static_agent_config_controls_are_aligned_for_chat_and_event_agents():
  486. html = Path("src/agent_lab/presentation/static/index.html").read_text()
  487. js = Path("src/agent_lab/presentation/static/app.js").read_text()
  488. assert '<dialog id="chat-config-dialog"' in html
  489. assert '<dialog id="event-config-dialog"' in html
  490. assert html.count('id="open-chat-config-panel"') == 1
  491. assert html.count('id="open-event-config-panel"') == 1
  492. assert 'id="open-chat-config"' not in html
  493. assert 'id="open-event-config"' not in html
  494. assert '#open-chat-config"' not in js
  495. assert '#open-event-config"' not in js
  496. assert '#open-chat-config-panel"' in js
  497. assert '#open-event-config-panel"' in js
  498. assert 'id="close-chat-config"' in html
  499. assert 'id="close-event-config"' in html
  500. assert "chatConfigDialog.showModal()" in js
  501. assert "eventConfigDialog.showModal()" in js
  502. assert "chatConfigDialog.close()" in js
  503. assert "eventConfigDialog.close()" in js
  504. for control_id in [
  505. "model",
  506. "temperature",
  507. "max-tokens",
  508. "chat-thinking-disabled",
  509. "chat-enable-search",
  510. "chat-forced-search",
  511. "event-model",
  512. "event-temperature",
  513. "event-max-tokens",
  514. "event-system-prompt",
  515. "event-thinking-disabled",
  516. "event-enable-search",
  517. "event-forced-search",
  518. ]:
  519. assert f'id="{control_id}"' in html
  520. assert 'extra_body: buildExtraBody("chat")' in js
  521. assert 'extra_body: buildExtraBody("event")' in js
  522. assert 'system_prompt: ""' in js
  523. assert 'system_prompt: document.querySelector("#event-system-prompt")' in js
  524. def test_static_tools_ui_shows_tool_metadata_and_selection_actions():
  525. html = Path("src/agent_lab/presentation/static/index.html").read_text()
  526. js = Path("src/agent_lab/presentation/static/app.js").read_text()
  527. assert 'id="tool-count"' in html
  528. assert 'id="select-all-tools"' in html
  529. assert 'id="clear-tools"' in html
  530. assert "tool-card" in js
  531. assert "tool-description" in js
  532. assert "tool-required" in js
  533. assert "function updateToolCount()" in js
  534. def test_static_app_handles_audit_messages():
  535. js = Path("src/agent_lab/presentation/static/app.js").read_text()
  536. css = Path("src/agent_lab/presentation/static/styles.css").read_text()
  537. assert 'message.type === "audit"' in js
  538. assert 'appendLog("audit"' in js
  539. assert ".audit" in css
  540. def test_static_app_shows_wait_state_and_immediate_user_echo():
  541. html = Path("src/agent_lab/presentation/static/index.html").read_text()
  542. js = Path("src/agent_lab/presentation/static/app.js").read_text()
  543. css = Path("src/agent_lab/presentation/static/styles.css").read_text()
  544. assert 'id="run-button"' in html
  545. assert 'const runButton = document.querySelector("#run-button")' in js
  546. assert 'appendLog("user", submittedMessage)' in js
  547. assert 'statusEl.textContent = "Waiting for first token"' in js
  548. assert "function startElapsedTimer()" in js
  549. assert "function stopElapsedTimer()" in js
  550. assert "function setRunState(" in js
  551. assert "if (firstTokenAt) {\n hasBackendRoundStats = true;" in js
  552. assert "hasBackendRoundStats && firstTokenAt" in js
  553. assert ".user" in css
  554. def test_static_app_reuses_websocket_for_session_turns():
  555. js = Path("src/agent_lab/presentation/static/app.js").read_text()
  556. assert "function isSocketOpen()" in js
  557. assert "socket.send(JSON.stringify({ type: \"user_message\"" in js
  558. assert 'message.type === "turn_completed"' in js
  559. assert 'message.type === "done"' in js
  560. def test_static_workspace_snapshot_uses_stable_storage_hooks():
  561. js = Path("src/agent_lab/presentation/static/app.js").read_text()
  562. assert "agent-lab.workspace-snapshots.v1" in js
  563. assert "agent-lab.prompt-sets.v1" in js
  564. assert "function saveWorkspaceSnapshot()" in js
  565. assert "function loadWorkspaceSnapshot()" in js
  566. assert "function deleteWorkspaceSnapshot()" in js
  567. assert "function buildWorkspaceSnapshot()" in js
  568. assert "prompt_items: buildPromptItems()" in js
  569. assert "enabled_tools: selectedTools()" in js
  570. assert "extra_body: buildExtraBody(\"chat\")" in js
  571. assert "extra_body: buildExtraBody(\"event\")" in js
  572. def test_static_app_handles_backend_round_stats_messages():
  573. js = Path("src/agent_lab/presentation/static/app.js").read_text()
  574. assert 'message.type === "round_stats"' in js
  575. assert "function updateRoundStats(" in js
  576. assert "#stat-ttft" in js
  577. assert "#stat-elapsed" in js
  578. @pytest.mark.asyncio
  579. async def test_openai_chat_client_omits_stream_options_when_usage_disabled():
  580. captured_payloads: list[dict] = []
  581. def handler(request: httpx.Request) -> httpx.Response:
  582. captured_payloads.append(json.loads(request.content))
  583. return httpx.Response(
  584. 200,
  585. content=b'data: {"choices":[{"delta":{"content":"hi"},"finish_reason":null}]}\n\n',
  586. )
  587. async with httpx.AsyncClient(
  588. transport=httpx.MockTransport(handler),
  589. base_url="https://llm.test/v1",
  590. ) as http_client:
  591. client = OpenAICompatibleChatClient(
  592. api_key="",
  593. base_url="https://llm.test/v1",
  594. default_model="provider-default",
  595. request_timeout_seconds=5,
  596. include_usage=False,
  597. http_client=http_client,
  598. )
  599. [
  600. item
  601. async for item in client.stream_chat(
  602. messages=[ChatMessage(role="user", content="hi")],
  603. tools=[],
  604. params=AgentParams(model="provider-default", temperature=0.3, max_tokens=50),
  605. )
  606. ]
  607. assert "stream_options" not in captured_payloads[0]
  608. @pytest.mark.asyncio
  609. async def test_openai_chat_client_merges_agent_extra_body_into_payload():
  610. captured_payloads: list[dict] = []
  611. def handler(request: httpx.Request) -> httpx.Response:
  612. captured_payloads.append(json.loads(request.content))
  613. return httpx.Response(
  614. 200,
  615. content=b'data: {"choices":[{"delta":{"content":"hi"},"finish_reason":null}]}\n\n',
  616. )
  617. async with httpx.AsyncClient(
  618. transport=httpx.MockTransport(handler),
  619. base_url="https://llm.test/v1",
  620. ) as http_client:
  621. client = OpenAICompatibleChatClient(
  622. api_key="",
  623. base_url="https://llm.test/v1",
  624. default_model="provider-default",
  625. request_timeout_seconds=5,
  626. http_client=http_client,
  627. )
  628. [
  629. item
  630. async for item in client.stream_chat(
  631. messages=[ChatMessage(role="user", content="hi")],
  632. tools=[],
  633. params=AgentParams(
  634. model="provider-default",
  635. temperature=0.3,
  636. max_tokens=50,
  637. extra_body={
  638. "thinking": {"type": "disabled"},
  639. "enable_search": False,
  640. "search_options": {"forced_search": False},
  641. },
  642. ),
  643. )
  644. ]
  645. assert captured_payloads[0]["thinking"] == {"type": "disabled"}
  646. assert captured_payloads[0]["enable_search"] is False
  647. assert captured_payloads[0]["search_options"] == {"forced_search": False}