test_websocket_api.py 14 KB

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