test_websocket_api.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473
  1. import json
  2. import asyncio
  3. from collections.abc import AsyncIterator
  4. from pathlib import Path
  5. import httpx
  6. import pytest
  7. from fastapi.testclient import TestClient
  8. from agent_lab.application.contracts import AgentParams, DebugRunRequest, EventAgentParams
  9. from agent_lab.application.queues import RuntimeQueues
  10. from agent_lab.application.runtime import DebugRuntime
  11. from agent_lab.domain.events import ToolCallEvent
  12. from agent_lab.domain.messages import ChatMessage, StreamItem
  13. from agent_lab.infrastructure.chat_client import OpenAICompatibleChatClient
  14. from agent_lab.presentation.web import create_app
  15. class FakeRuntime:
  16. def __init__(self) -> None:
  17. self.requests: list[DebugRunRequest] = []
  18. async def run(self, request: DebugRunRequest) -> AsyncIterator[dict]:
  19. self.requests.append(request)
  20. yield {"type": "session_started"}
  21. yield {"type": "message_delta", "content": "hello"}
  22. yield {"type": "done"}
  23. class QueueAwareRuntime:
  24. def __init__(self) -> None:
  25. self.requests: list[DebugRunRequest] = []
  26. self.queues: RuntimeQueues | None = None
  27. self.task: asyncio.Task | None = None
  28. def start(self, request: DebugRunRequest) -> RuntimeQueues:
  29. self.requests.append(request)
  30. self.queues = RuntimeQueues()
  31. self.task = asyncio.create_task(self._run())
  32. return self.queues
  33. async def _run(self) -> None:
  34. assert self.queues is not None
  35. await self.queues.output.put({"type": "session_started"})
  36. message = await self.queues.input.get()
  37. await self.queues.output.put(
  38. {"type": "message_delta", "content": message.content}
  39. )
  40. await self.queues.output.put({"type": "done"})
  41. async def aclose(self) -> None:
  42. if self.task is not None and not self.task.done():
  43. self.task.cancel()
  44. await asyncio.gather(self.task, return_exceptions=True)
  45. def _request_payload() -> dict:
  46. return {
  47. "user_message": "debug this",
  48. "system_prompts": ["You are a debugger."],
  49. "pre_messages": [{"role": "user", "content": "previous turn"}],
  50. "chat_agent": {
  51. "model": "fake-model",
  52. "temperature": 0.1,
  53. "max_tokens": 200,
  54. },
  55. "event_agent": {
  56. "enabled_tools": ["handoff_note"],
  57. "max_event_loops": 2,
  58. },
  59. }
  60. def test_health_returns_ok():
  61. app = create_app(runtime_factory=FakeRuntime)
  62. client = TestClient(app)
  63. response = client.get("/health")
  64. assert response.status_code == 200
  65. assert response.json() == {"status": "ok"}
  66. def test_api_tools_returns_handoff_note_metadata():
  67. app = create_app(runtime_factory=FakeRuntime)
  68. client = TestClient(app)
  69. response = client.get("/api/tools")
  70. assert response.status_code == 200
  71. assert response.json() == [
  72. {
  73. "name": "handoff_note",
  74. "description": "Send a note to the event agent.",
  75. "parameters": {
  76. "type": "object",
  77. "properties": {
  78. "message": {"type": "string"},
  79. },
  80. "required": ["message"],
  81. },
  82. }
  83. ]
  84. def test_websocket_debug_streams_runtime_messages():
  85. runtime = FakeRuntime()
  86. app = create_app(runtime_factory=lambda: runtime)
  87. client = TestClient(app)
  88. with client.websocket_connect("/ws/debug") as websocket:
  89. websocket.send_json(_request_payload())
  90. assert websocket.receive_json() == {"type": "session_started"}
  91. assert websocket.receive_json() == {
  92. "type": "message_delta",
  93. "content": "hello",
  94. }
  95. assert websocket.receive_json() == {"type": "done"}
  96. assert runtime.requests[0].user_message == "debug this"
  97. assert runtime.requests[0].pre_messages[0].content == "previous turn"
  98. def test_websocket_debug_enqueues_user_messages_during_running_session():
  99. runtime = QueueAwareRuntime()
  100. app = create_app(runtime_factory=lambda: runtime)
  101. client = TestClient(app)
  102. with client.websocket_connect("/ws/debug") as websocket:
  103. websocket.send_json(_request_payload())
  104. assert websocket.receive_json() == {"type": "session_started"}
  105. websocket.send_json({"type": "user_message", "content": "follow-up"})
  106. assert websocket.receive_json() == {
  107. "type": "message_delta",
  108. "content": "follow-up",
  109. }
  110. assert websocket.receive_json() == {"type": "done"}
  111. assert runtime.requests[0].user_message == "debug this"
  112. def test_websocket_debug_sends_error_for_invalid_request():
  113. app = create_app(runtime_factory=FakeRuntime)
  114. client = TestClient(app)
  115. with client.websocket_connect("/ws/debug") as websocket:
  116. websocket.send_json({"chat_agent": {"model": "fake-model"}})
  117. message = websocket.receive_json()
  118. assert message["type"] == "error"
  119. assert "user_message" in message["message"]
  120. class HistoryCapturingChatClient:
  121. def __init__(self) -> None:
  122. self.calls = 0
  123. self.second_call_messages: list[ChatMessage] = []
  124. async def stream_chat(
  125. self,
  126. messages: list[ChatMessage],
  127. tools: list[dict],
  128. params: AgentParams,
  129. ) -> AsyncIterator[StreamItem]:
  130. self.calls += 1
  131. if self.calls == 1:
  132. yield StreamItem.message_delta("Need event help.")
  133. yield StreamItem.event(
  134. ToolCallEvent(
  135. id="call_1",
  136. name="handoff_note",
  137. arguments={"message": "inspect this"},
  138. raw_arguments='{"message":"inspect this"}',
  139. )
  140. )
  141. return
  142. self.second_call_messages = list(messages)
  143. yield StreamItem.message_delta("Final answer.")
  144. @pytest.mark.asyncio
  145. async def test_runtime_appends_assistant_message_before_tool_reply_history():
  146. request = DebugRunRequest(
  147. user_message="debug this",
  148. system_prompts=["You are a debugger."],
  149. pre_messages=[],
  150. chat_agent=AgentParams(model="fake-model", temperature=0.1, max_tokens=200),
  151. event_agent=EventAgentParams(enabled_tools=["handoff_note"], max_event_loops=2),
  152. )
  153. client = HistoryCapturingChatClient()
  154. runtime = DebugRuntime(client)
  155. outputs = [message async for message in runtime.run(request)]
  156. assert client.calls == 2
  157. assert [message.role for message in client.second_call_messages] == [
  158. "system",
  159. "user",
  160. "assistant",
  161. "tool",
  162. ]
  163. assert client.second_call_messages[2].content == "Need event help."
  164. assert outputs[-1] == {"type": "done"}
  165. @pytest.mark.asyncio
  166. async def test_openai_chat_client_streams_sse_chunks_through_parser():
  167. requests: list[httpx.Request] = []
  168. def handler(request: httpx.Request) -> httpx.Response:
  169. requests.append(request)
  170. payload = json.loads(request.content)
  171. assert payload["stream"] is True
  172. assert payload["model"] == "model-x"
  173. assert payload["messages"] == [{"role": "user", "content": "hi"}]
  174. assert payload["tools"][0]["function"]["name"] == "handoff_note"
  175. assert payload["temperature"] == 0.3
  176. assert payload["max_tokens"] == 50
  177. assert request.headers["authorization"] == "Bearer test-key"
  178. return httpx.Response(
  179. 200,
  180. content=(
  181. b'data: {"choices":[{"delta":{"content":"hi"},"finish_reason":null}]}\n\n'
  182. b"data: [DONE]\n\n"
  183. ),
  184. )
  185. async with httpx.AsyncClient(
  186. transport=httpx.MockTransport(handler),
  187. base_url="https://llm.test/v1",
  188. ) as http_client:
  189. client = OpenAICompatibleChatClient(
  190. api_key="test-key",
  191. base_url="https://llm.test/v1",
  192. default_model="default-model",
  193. request_timeout_seconds=5,
  194. http_client=http_client,
  195. )
  196. items = [
  197. item
  198. async for item in client.stream_chat(
  199. messages=[ChatMessage(role="user", content="hi")],
  200. tools=[
  201. {
  202. "type": "function",
  203. "function": {"name": "handoff_note", "parameters": {}},
  204. }
  205. ],
  206. params=AgentParams(model="model-x", temperature=0.3, max_tokens=50),
  207. )
  208. ]
  209. assert requests[0].url.path == "/v1/chat/completions"
  210. assert [item.content for item in items] == ["hi"]
  211. @pytest.mark.asyncio
  212. async def test_openai_chat_client_uses_default_model_when_request_model_is_blank():
  213. captured_payloads: list[dict] = []
  214. def handler(request: httpx.Request) -> httpx.Response:
  215. captured_payloads.append(json.loads(request.content))
  216. return httpx.Response(
  217. 200,
  218. content=b'data: {"choices":[{"delta":{"content":"hi"},"finish_reason":null}]}\n\n',
  219. )
  220. async with httpx.AsyncClient(
  221. transport=httpx.MockTransport(handler),
  222. base_url="https://llm.test/v1",
  223. ) as http_client:
  224. client = OpenAICompatibleChatClient(
  225. api_key="",
  226. base_url="https://llm.test/v1",
  227. default_model="provider-default",
  228. request_timeout_seconds=5,
  229. http_client=http_client,
  230. )
  231. [
  232. item
  233. async for item in client.stream_chat(
  234. messages=[ChatMessage(role="user", content="hi")],
  235. tools=[],
  236. params=AgentParams(model=" ", temperature=0.3, max_tokens=50),
  237. )
  238. ]
  239. assert captured_payloads[0]["model"] == "provider-default"
  240. @pytest.mark.asyncio
  241. async def test_openai_chat_client_serializes_tool_reply_without_name():
  242. captured_payloads: list[dict] = []
  243. assistant_tool_call = {
  244. "id": "call_1",
  245. "type": "function",
  246. "function": {
  247. "name": "handoff_note",
  248. "arguments": '{"message":"inspect"}',
  249. },
  250. }
  251. def handler(request: httpx.Request) -> httpx.Response:
  252. captured_payloads.append(json.loads(request.content))
  253. return httpx.Response(
  254. 200,
  255. content=b'data: {"choices":[{"delta":{"content":"done"},"finish_reason":null}]}\n\n',
  256. )
  257. async with httpx.AsyncClient(
  258. transport=httpx.MockTransport(handler),
  259. base_url="https://llm.test/v1",
  260. ) as http_client:
  261. client = OpenAICompatibleChatClient(
  262. api_key="",
  263. base_url="https://llm.test/v1",
  264. default_model="provider-default",
  265. request_timeout_seconds=5,
  266. http_client=http_client,
  267. )
  268. [
  269. item
  270. async for item in client.stream_chat(
  271. messages=[
  272. ChatMessage(
  273. role="assistant",
  274. content="",
  275. tool_calls=[assistant_tool_call],
  276. ),
  277. ChatMessage(
  278. role="tool",
  279. content="noted",
  280. name="handoff_note",
  281. tool_call_id="call_1",
  282. ),
  283. ],
  284. tools=[],
  285. params=AgentParams(model="provider-default", temperature=0.3, max_tokens=50),
  286. )
  287. ]
  288. assert captured_payloads[0]["messages"] == [
  289. {
  290. "role": "assistant",
  291. "content": "",
  292. "tool_calls": [assistant_tool_call],
  293. },
  294. {
  295. "role": "tool",
  296. "content": "noted",
  297. "tool_call_id": "call_1",
  298. },
  299. ]
  300. @pytest.mark.asyncio
  301. async def test_openai_chat_client_rejects_tool_message_without_tool_call_id_before_network():
  302. network_called = False
  303. def handler(request: httpx.Request) -> httpx.Response:
  304. nonlocal network_called
  305. network_called = True
  306. return httpx.Response(
  307. 200,
  308. content=b'data: {"choices":[{"delta":{"content":"done"},"finish_reason":null}]}\n\n',
  309. )
  310. async with httpx.AsyncClient(
  311. transport=httpx.MockTransport(handler),
  312. base_url="https://llm.test/v1",
  313. ) as http_client:
  314. client = OpenAICompatibleChatClient(
  315. api_key="",
  316. base_url="https://llm.test/v1",
  317. default_model="provider-default",
  318. request_timeout_seconds=5,
  319. http_client=http_client,
  320. )
  321. with pytest.raises(ValueError, match="tool messages require tool_call_id"):
  322. [
  323. item
  324. async for item in client.stream_chat(
  325. messages=[ChatMessage(role="tool", content="orphan tool result")],
  326. tools=[],
  327. params=AgentParams(model="provider-default", temperature=0.3, max_tokens=50),
  328. )
  329. ]
  330. assert network_called is False
  331. def test_static_pre_message_role_selector_does_not_offer_tool():
  332. html = Path("src/agent_lab/presentation/static/index.html").read_text()
  333. assert '<option value="tool">tool</option>' not in html
  334. def test_static_tools_ui_is_dynamic_and_not_hardcoded_to_handoff_note_checkbox():
  335. html = Path("src/agent_lab/presentation/static/index.html").read_text()
  336. js = Path("src/agent_lab/presentation/static/app.js").read_text()
  337. assert 'id="tool-handoff-note"' not in html
  338. assert "#tool-handoff-note" not in js
  339. assert 'id="tool-list"' in html
  340. assert '"/api/tools"' in js
  341. assert "fetch" in js
  342. def test_static_prompt_workspace_controls_are_available():
  343. html = Path("src/agent_lab/presentation/static/index.html").read_text()
  344. assert 'id="prompt-set-name"' in html
  345. assert 'id="saved-prompt-sets"' in html
  346. assert 'id="save-prompt-set"' in html
  347. assert 'id="load-prompt-set"' in html
  348. assert 'id="delete-prompt-set"' in html
  349. def test_static_prompt_workspace_uses_stable_storage_hooks():
  350. js = Path("src/agent_lab/presentation/static/app.js").read_text()
  351. assert "agent-lab.prompt-sets.v1" in js
  352. assert "function savePromptSet()" in js
  353. assert "function loadPromptSet()" in js
  354. assert "function deletePromptSet()" in js
  355. def test_static_app_handles_backend_round_stats_messages():
  356. js = Path("src/agent_lab/presentation/static/app.js").read_text()
  357. assert 'message.type === "round_stats"' in js
  358. assert "function updateRoundStats(" in js
  359. assert "#stat-ttft" in js
  360. assert "#stat-elapsed" in js
  361. @pytest.mark.asyncio
  362. async def test_openai_chat_client_omits_stream_options_when_usage_disabled():
  363. captured_payloads: list[dict] = []
  364. def handler(request: httpx.Request) -> httpx.Response:
  365. captured_payloads.append(json.loads(request.content))
  366. return httpx.Response(
  367. 200,
  368. content=b'data: {"choices":[{"delta":{"content":"hi"},"finish_reason":null}]}\n\n',
  369. )
  370. async with httpx.AsyncClient(
  371. transport=httpx.MockTransport(handler),
  372. base_url="https://llm.test/v1",
  373. ) as http_client:
  374. client = OpenAICompatibleChatClient(
  375. api_key="",
  376. base_url="https://llm.test/v1",
  377. default_model="provider-default",
  378. request_timeout_seconds=5,
  379. include_usage=False,
  380. http_client=http_client,
  381. )
  382. [
  383. item
  384. async for item in client.stream_chat(
  385. messages=[ChatMessage(role="user", content="hi")],
  386. tools=[],
  387. params=AgentParams(model="provider-default", temperature=0.3, max_tokens=50),
  388. )
  389. ]
  390. assert "stream_options" not in captured_payloads[0]