test_websocket_api.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364
  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_websocket_debug_streams_runtime_messages():
  43. runtime = FakeRuntime()
  44. app = create_app(runtime_factory=lambda: runtime)
  45. client = TestClient(app)
  46. with client.websocket_connect("/ws/debug") as websocket:
  47. websocket.send_json(_request_payload())
  48. assert websocket.receive_json() == {"type": "session_started"}
  49. assert websocket.receive_json() == {
  50. "type": "message_delta",
  51. "content": "hello",
  52. }
  53. assert websocket.receive_json() == {"type": "done"}
  54. assert runtime.requests[0].user_message == "debug this"
  55. assert runtime.requests[0].pre_messages[0].content == "previous turn"
  56. def test_websocket_debug_sends_error_for_invalid_request():
  57. app = create_app(runtime_factory=FakeRuntime)
  58. client = TestClient(app)
  59. with client.websocket_connect("/ws/debug") as websocket:
  60. websocket.send_json({"chat_agent": {"model": "fake-model"}})
  61. message = websocket.receive_json()
  62. assert message["type"] == "error"
  63. assert "user_message" in message["message"]
  64. class HistoryCapturingChatClient:
  65. def __init__(self) -> None:
  66. self.calls = 0
  67. self.second_call_messages: list[ChatMessage] = []
  68. async def stream_chat(
  69. self,
  70. messages: list[ChatMessage],
  71. tools: list[dict],
  72. params: AgentParams,
  73. ) -> AsyncIterator[StreamItem]:
  74. self.calls += 1
  75. if self.calls == 1:
  76. yield StreamItem.message_delta("Need event help.")
  77. yield StreamItem.event(
  78. ToolCallEvent(
  79. id="call_1",
  80. name="handoff_note",
  81. arguments={"message": "inspect this"},
  82. raw_arguments='{"message":"inspect this"}',
  83. )
  84. )
  85. return
  86. self.second_call_messages = list(messages)
  87. yield StreamItem.message_delta("Final answer.")
  88. @pytest.mark.asyncio
  89. async def test_runtime_appends_assistant_message_before_tool_reply_history():
  90. request = DebugRunRequest(
  91. user_message="debug this",
  92. system_prompts=["You are a debugger."],
  93. pre_messages=[],
  94. chat_agent=AgentParams(model="fake-model", temperature=0.1, max_tokens=200),
  95. event_agent=EventAgentParams(enabled_tools=["handoff_note"], max_event_loops=2),
  96. )
  97. client = HistoryCapturingChatClient()
  98. runtime = DebugRuntime(client)
  99. outputs = [message async for message in runtime.run(request)]
  100. assert client.calls == 2
  101. assert [message.role for message in client.second_call_messages] == [
  102. "system",
  103. "user",
  104. "assistant",
  105. "tool",
  106. ]
  107. assert client.second_call_messages[2].content == "Need event help."
  108. assert outputs[-1] == {"type": "done"}
  109. @pytest.mark.asyncio
  110. async def test_openai_chat_client_streams_sse_chunks_through_parser():
  111. requests: list[httpx.Request] = []
  112. def handler(request: httpx.Request) -> httpx.Response:
  113. requests.append(request)
  114. payload = json.loads(request.content)
  115. assert payload["stream"] is True
  116. assert payload["model"] == "model-x"
  117. assert payload["messages"] == [{"role": "user", "content": "hi"}]
  118. assert payload["tools"][0]["function"]["name"] == "handoff_note"
  119. assert payload["temperature"] == 0.3
  120. assert payload["max_tokens"] == 50
  121. assert request.headers["authorization"] == "Bearer test-key"
  122. return httpx.Response(
  123. 200,
  124. content=(
  125. b'data: {"choices":[{"delta":{"content":"hi"},"finish_reason":null}]}\n\n'
  126. b"data: [DONE]\n\n"
  127. ),
  128. )
  129. async with httpx.AsyncClient(
  130. transport=httpx.MockTransport(handler),
  131. base_url="https://llm.test/v1",
  132. ) as http_client:
  133. client = OpenAICompatibleChatClient(
  134. api_key="test-key",
  135. base_url="https://llm.test/v1",
  136. default_model="default-model",
  137. request_timeout_seconds=5,
  138. http_client=http_client,
  139. )
  140. items = [
  141. item
  142. async for item in client.stream_chat(
  143. messages=[ChatMessage(role="user", content="hi")],
  144. tools=[
  145. {
  146. "type": "function",
  147. "function": {"name": "handoff_note", "parameters": {}},
  148. }
  149. ],
  150. params=AgentParams(model="model-x", temperature=0.3, max_tokens=50),
  151. )
  152. ]
  153. assert requests[0].url.path == "/v1/chat/completions"
  154. assert [item.content for item in items] == ["hi"]
  155. @pytest.mark.asyncio
  156. async def test_openai_chat_client_uses_default_model_when_request_model_is_blank():
  157. captured_payloads: list[dict] = []
  158. def handler(request: httpx.Request) -> httpx.Response:
  159. captured_payloads.append(json.loads(request.content))
  160. return httpx.Response(
  161. 200,
  162. content=b'data: {"choices":[{"delta":{"content":"hi"},"finish_reason":null}]}\n\n',
  163. )
  164. async with httpx.AsyncClient(
  165. transport=httpx.MockTransport(handler),
  166. base_url="https://llm.test/v1",
  167. ) as http_client:
  168. client = OpenAICompatibleChatClient(
  169. api_key="",
  170. base_url="https://llm.test/v1",
  171. default_model="provider-default",
  172. request_timeout_seconds=5,
  173. http_client=http_client,
  174. )
  175. [
  176. item
  177. async for item in client.stream_chat(
  178. messages=[ChatMessage(role="user", content="hi")],
  179. tools=[],
  180. params=AgentParams(model=" ", temperature=0.3, max_tokens=50),
  181. )
  182. ]
  183. assert captured_payloads[0]["model"] == "provider-default"
  184. @pytest.mark.asyncio
  185. async def test_openai_chat_client_serializes_tool_reply_without_name():
  186. captured_payloads: list[dict] = []
  187. assistant_tool_call = {
  188. "id": "call_1",
  189. "type": "function",
  190. "function": {
  191. "name": "handoff_note",
  192. "arguments": '{"message":"inspect"}',
  193. },
  194. }
  195. def handler(request: httpx.Request) -> httpx.Response:
  196. captured_payloads.append(json.loads(request.content))
  197. return httpx.Response(
  198. 200,
  199. content=b'data: {"choices":[{"delta":{"content":"done"},"finish_reason":null}]}\n\n',
  200. )
  201. async with httpx.AsyncClient(
  202. transport=httpx.MockTransport(handler),
  203. base_url="https://llm.test/v1",
  204. ) as http_client:
  205. client = OpenAICompatibleChatClient(
  206. api_key="",
  207. base_url="https://llm.test/v1",
  208. default_model="provider-default",
  209. request_timeout_seconds=5,
  210. http_client=http_client,
  211. )
  212. [
  213. item
  214. async for item in client.stream_chat(
  215. messages=[
  216. ChatMessage(
  217. role="assistant",
  218. content="",
  219. tool_calls=[assistant_tool_call],
  220. ),
  221. ChatMessage(
  222. role="tool",
  223. content="noted",
  224. name="handoff_note",
  225. tool_call_id="call_1",
  226. ),
  227. ],
  228. tools=[],
  229. params=AgentParams(model="provider-default", temperature=0.3, max_tokens=50),
  230. )
  231. ]
  232. assert captured_payloads[0]["messages"] == [
  233. {
  234. "role": "assistant",
  235. "content": "",
  236. "tool_calls": [assistant_tool_call],
  237. },
  238. {
  239. "role": "tool",
  240. "content": "noted",
  241. "tool_call_id": "call_1",
  242. },
  243. ]
  244. @pytest.mark.asyncio
  245. async def test_openai_chat_client_rejects_tool_message_without_tool_call_id_before_network():
  246. network_called = False
  247. def handler(request: httpx.Request) -> httpx.Response:
  248. nonlocal network_called
  249. network_called = True
  250. return httpx.Response(
  251. 200,
  252. content=b'data: {"choices":[{"delta":{"content":"done"},"finish_reason":null}]}\n\n',
  253. )
  254. async with httpx.AsyncClient(
  255. transport=httpx.MockTransport(handler),
  256. base_url="https://llm.test/v1",
  257. ) as http_client:
  258. client = OpenAICompatibleChatClient(
  259. api_key="",
  260. base_url="https://llm.test/v1",
  261. default_model="provider-default",
  262. request_timeout_seconds=5,
  263. http_client=http_client,
  264. )
  265. with pytest.raises(ValueError, match="tool messages require tool_call_id"):
  266. [
  267. item
  268. async for item in client.stream_chat(
  269. messages=[ChatMessage(role="tool", content="orphan tool result")],
  270. tools=[],
  271. params=AgentParams(model="provider-default", temperature=0.3, max_tokens=50),
  272. )
  273. ]
  274. assert network_called is False
  275. def test_static_pre_message_role_selector_does_not_offer_tool():
  276. html = Path("src/agent_lab/presentation/static/index.html").read_text()
  277. assert '<option value="tool">tool</option>' not in html
  278. @pytest.mark.asyncio
  279. async def test_openai_chat_client_omits_stream_options_when_usage_disabled():
  280. captured_payloads: list[dict] = []
  281. def handler(request: httpx.Request) -> httpx.Response:
  282. captured_payloads.append(json.loads(request.content))
  283. return httpx.Response(
  284. 200,
  285. content=b'data: {"choices":[{"delta":{"content":"hi"},"finish_reason":null}]}\n\n',
  286. )
  287. async with httpx.AsyncClient(
  288. transport=httpx.MockTransport(handler),
  289. base_url="https://llm.test/v1",
  290. ) as http_client:
  291. client = OpenAICompatibleChatClient(
  292. api_key="",
  293. base_url="https://llm.test/v1",
  294. default_model="provider-default",
  295. request_timeout_seconds=5,
  296. include_usage=False,
  297. http_client=http_client,
  298. )
  299. [
  300. item
  301. async for item in client.stream_chat(
  302. messages=[ChatMessage(role="user", content="hi")],
  303. tools=[],
  304. params=AgentParams(model="provider-default", temperature=0.3, max_tokens=50),
  305. )
  306. ]
  307. assert "stream_options" not in captured_payloads[0]