| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691 |
- import json
- import asyncio
- import logging
- from collections.abc import AsyncIterator
- from pathlib import Path
- import httpx
- import pytest
- from fastapi.testclient import TestClient
- from pydantic import ValidationError
- from agent_lab.application.contracts import AgentParams, DebugRunRequest, EventAgentParams
- from agent_lab.application.queues import RuntimeQueues
- from agent_lab.application.runtime import DebugRuntime
- from agent_lab.domain.events import ToolCallEvent
- from agent_lab.domain.messages import ChatMessage, StreamItem
- from agent_lab.infrastructure.chat_client import OpenAICompatibleChatClient
- from agent_lab.presentation.web import create_app
- class FakeRuntime:
- def __init__(self) -> None:
- self.requests: list[DebugRunRequest] = []
- async def run(self, request: DebugRunRequest) -> AsyncIterator[dict]:
- self.requests.append(request)
- yield {"type": "session_started"}
- yield {"type": "message_delta", "content": "hello"}
- yield {"type": "done"}
- class QueueAwareRuntime:
- def __init__(self) -> None:
- self.requests: list[DebugRunRequest] = []
- self.queues: RuntimeQueues | None = None
- self.task: asyncio.Task | None = None
- def start(self, request: DebugRunRequest) -> RuntimeQueues:
- self.requests.append(request)
- self.queues = RuntimeQueues()
- self.task = asyncio.create_task(self._run())
- return self.queues
- async def _run(self) -> None:
- assert self.queues is not None
- await self.queues.output.put({"type": "session_started"})
- message = await self.queues.input.get()
- await self.queues.output.put(
- {"type": "message_delta", "content": message.content}
- )
- await self.queues.output.put({"type": "done"})
- async def aclose(self) -> None:
- if self.task is not None and not self.task.done():
- self.task.cancel()
- await asyncio.gather(self.task, return_exceptions=True)
- def _request_payload() -> dict:
- return {
- "user_message": "debug this",
- "system_prompts": ["You are a debugger."],
- "pre_messages": [{"role": "user", "content": "previous turn"}],
- "chat_agent": {
- "model": "fake-model",
- "temperature": 0.1,
- "max_tokens": 200,
- },
- "event_agent": {
- "enabled_tools": ["handoff_note"],
- "max_event_loops": 2,
- },
- }
- def _event_tool_call_from_tools(
- tools: list[dict],
- messages: list[ChatMessage],
- ) -> StreamItem:
- tool_name = tools[0]["function"]["name"]
- content = ""
- for role in ("assistant", "user"):
- content = next(
- (
- message.content
- for message in reversed(messages)
- if message.role == role and message.content.strip()
- ),
- "",
- )
- if content:
- break
- arguments = {
- "message": content,
- "query": content,
- "title": content,
- }
- return StreamItem.event(
- ToolCallEvent(
- id="event_agent_call_1",
- name=tool_name,
- arguments=arguments,
- raw_arguments=json.dumps(arguments),
- )
- )
- def test_agent_params_defaults_include_extra_body_and_event_agent_defaults_to_one_round():
- chat_params = AgentParams()
- event_params = EventAgentParams()
- assert chat_params.extra_body == {
- "thinking": {"type": "disabled"},
- "enable_search": False,
- "search_options": {"forced_search": False},
- }
- assert event_params.extra_body == chat_params.extra_body
- assert event_params.max_event_loops == 1
- assert event_params.system_prompt == ""
- def test_health_returns_ok():
- app = create_app(runtime_factory=FakeRuntime)
- client = TestClient(app)
- response = client.get("/health")
- assert response.status_code == 200
- assert response.json() == {"status": "ok"}
- def test_api_tools_returns_handoff_note_metadata():
- app = create_app(runtime_factory=FakeRuntime)
- client = TestClient(app)
- response = client.get("/api/tools")
- assert response.status_code == 200
- tools = response.json()
- assert [tool["name"] for tool in tools] == [
- "handoff_note",
- "mock_search",
- "mock_ticket",
- ]
- assert tools[0]["parameters"]["required"] == ["message"]
- def test_websocket_debug_streams_runtime_messages():
- runtime = FakeRuntime()
- app = create_app(runtime_factory=lambda: runtime)
- client = TestClient(app)
- with client.websocket_connect("/ws/debug") as websocket:
- websocket.send_json(_request_payload())
- assert websocket.receive_json() == {"type": "session_started"}
- assert websocket.receive_json() == {
- "type": "message_delta",
- "content": "hello",
- }
- assert websocket.receive_json() == {"type": "done"}
- assert runtime.requests[0].user_message == "debug this"
- assert runtime.requests[0].pre_messages[0].content == "previous turn"
- def test_websocket_debug_logs_session_lifecycle(caplog):
- runtime = FakeRuntime()
- app = create_app(runtime_factory=lambda: runtime)
- client = TestClient(app)
- with caplog.at_level(logging.INFO, logger="agent_lab.presentation.web"):
- with client.websocket_connect("/ws/debug") as websocket:
- websocket.send_json(_request_payload())
- assert websocket.receive_json() == {"type": "session_started"}
- assert websocket.receive_json() == {
- "type": "message_delta",
- "content": "hello",
- }
- assert websocket.receive_json() == {"type": "done"}
- assert "websocket session accepted" in caplog.text
- assert "websocket request accepted" in caplog.text
- assert "websocket session closed" in caplog.text
- def test_websocket_debug_enqueues_user_messages_during_running_session():
- runtime = QueueAwareRuntime()
- app = create_app(runtime_factory=lambda: runtime)
- client = TestClient(app)
- with client.websocket_connect("/ws/debug") as websocket:
- websocket.send_json(_request_payload())
- assert websocket.receive_json() == {"type": "session_started"}
- websocket.send_json({"type": "user_message", "content": "follow-up"})
- assert websocket.receive_json() == {
- "type": "message_delta",
- "content": "follow-up",
- }
- assert websocket.receive_json() == {"type": "done"}
- assert runtime.requests[0].user_message == "debug this"
- def test_websocket_debug_sends_error_for_invalid_request():
- app = create_app(runtime_factory=FakeRuntime)
- client = TestClient(app)
- with client.websocket_connect("/ws/debug") as websocket:
- websocket.send_json({"chat_agent": {"model": "fake-model"}})
- message = websocket.receive_json()
- assert message["type"] == "error"
- assert "user_message" in message["message"]
- def test_debug_run_request_rejects_invalid_pre_message_role():
- payload = _request_payload()
- payload["pre_messages"] = [{"role": "developer", "content": "invalid"}]
- with pytest.raises(ValidationError) as exc_info:
- DebugRunRequest.model_validate(payload)
- assert "role" in str(exc_info.value)
- def test_debug_run_request_rejects_tool_pre_messages():
- payload = _request_payload()
- payload["pre_messages"] = [
- {
- "role": "tool",
- "content": "orphan result",
- "tool_call_id": "call_1",
- }
- ]
- with pytest.raises(ValidationError, match="pre_messages cannot include tool messages"):
- DebugRunRequest.model_validate(payload)
- def test_chat_message_allows_internal_tool_replies():
- message = ChatMessage(
- role="tool",
- content='{"message":"handled"}',
- name="handoff_note",
- tool_call_id="call_1",
- )
- assert message.role == "tool"
- assert message.tool_call_id == "call_1"
- class HistoryCapturingChatClient:
- def __init__(self) -> None:
- self.calls = 0
- self.second_call_messages: list[ChatMessage] = []
- async def stream_chat(
- self,
- messages: list[ChatMessage],
- tools: list[dict],
- params: AgentParams,
- ) -> AsyncIterator[StreamItem]:
- if tools:
- yield _event_tool_call_from_tools(tools, messages)
- return
- self.calls += 1
- if self.calls == 1:
- yield StreamItem.message_delta("Need event help.")
- yield StreamItem.event(
- ToolCallEvent(
- id="call_1",
- name="handoff_note",
- arguments={},
- raw_arguments="{}",
- )
- )
- return
- self.second_call_messages = list(messages)
- yield StreamItem.message_delta("Final answer.")
- @pytest.mark.asyncio
- async def test_runtime_appends_event_summary_without_tool_reply_history():
- request = DebugRunRequest(
- user_message="debug this",
- system_prompts=["You are a debugger."],
- pre_messages=[],
- chat_agent=AgentParams(model="fake-model", temperature=0.1, max_tokens=200),
- event_agent=EventAgentParams(enabled_tools=["handoff_note"], max_event_loops=2),
- )
- client = HistoryCapturingChatClient()
- runtime = DebugRuntime(client)
- outputs = [message async for message in runtime.run(request)]
- assert client.calls == 2
- assert [message.role for message in client.second_call_messages] == [
- "system",
- "system",
- "user",
- "assistant",
- "user",
- ]
- assert "Available events:" in client.second_call_messages[1].content
- assert client.second_call_messages[3].content == "Need event help."
- assert not any(message.role == "tool" for message in client.second_call_messages)
- assert client.second_call_messages[4].content.startswith("EventAgent results:\n")
- assert client.second_call_messages[4].name == "event_agent"
- assert outputs[-1] == {"type": "done"}
- @pytest.mark.asyncio
- async def test_openai_chat_client_streams_sse_chunks_through_parser():
- requests: list[httpx.Request] = []
- def handler(request: httpx.Request) -> httpx.Response:
- requests.append(request)
- payload = json.loads(request.content)
- assert payload["stream"] is True
- assert payload["model"] == "model-x"
- assert payload["messages"] == [{"role": "user", "content": "hi"}]
- assert payload["tools"][0]["function"]["name"] == "handoff_note"
- assert payload["temperature"] == 0.3
- assert payload["max_tokens"] == 50
- assert request.headers["authorization"] == "Bearer test-key"
- return httpx.Response(
- 200,
- content=(
- b'data: {"choices":[{"delta":{"content":"hi"},"finish_reason":null}]}\n\n'
- b"data: [DONE]\n\n"
- ),
- )
- async with httpx.AsyncClient(
- transport=httpx.MockTransport(handler),
- base_url="https://llm.test/v1",
- ) as http_client:
- client = OpenAICompatibleChatClient(
- api_key="test-key",
- base_url="https://llm.test/v1",
- default_model="default-model",
- request_timeout_seconds=5,
- http_client=http_client,
- )
- items = [
- item
- async for item in client.stream_chat(
- messages=[ChatMessage(role="user", content="hi")],
- tools=[
- {
- "type": "function",
- "function": {"name": "handoff_note", "parameters": {}},
- }
- ],
- params=AgentParams(model="model-x", temperature=0.3, max_tokens=50),
- )
- ]
- assert requests[0].url.path == "/v1/chat/completions"
- assert [item.content for item in items] == ["hi"]
- @pytest.mark.asyncio
- async def test_openai_chat_client_uses_default_model_when_request_model_is_blank():
- captured_payloads: list[dict] = []
- def handler(request: httpx.Request) -> httpx.Response:
- captured_payloads.append(json.loads(request.content))
- return httpx.Response(
- 200,
- content=b'data: {"choices":[{"delta":{"content":"hi"},"finish_reason":null}]}\n\n',
- )
- async with httpx.AsyncClient(
- transport=httpx.MockTransport(handler),
- base_url="https://llm.test/v1",
- ) as http_client:
- client = OpenAICompatibleChatClient(
- api_key="",
- base_url="https://llm.test/v1",
- default_model="provider-default",
- request_timeout_seconds=5,
- http_client=http_client,
- )
- [
- item
- async for item in client.stream_chat(
- messages=[ChatMessage(role="user", content="hi")],
- tools=[],
- params=AgentParams(model=" ", temperature=0.3, max_tokens=50),
- )
- ]
- assert captured_payloads[0]["model"] == "provider-default"
- @pytest.mark.asyncio
- async def test_openai_chat_client_rejects_internal_tool_messages_before_network():
- network_called = False
- def handler(request: httpx.Request) -> httpx.Response:
- nonlocal network_called
- network_called = True
- return httpx.Response(
- 200,
- content=b'data: {"choices":[{"delta":{"content":"done"},"finish_reason":null}]}\n\n',
- )
- async with httpx.AsyncClient(
- transport=httpx.MockTransport(handler),
- base_url="https://llm.test/v1",
- ) as http_client:
- client = OpenAICompatibleChatClient(
- api_key="",
- base_url="https://llm.test/v1",
- default_model="provider-default",
- request_timeout_seconds=5,
- http_client=http_client,
- )
- with pytest.raises(ValueError, match="tool messages are internal"):
- [
- item
- async for item in client.stream_chat(
- messages=[
- ChatMessage(
- role="tool",
- content="internal tool result",
- tool_call_id="call_1",
- )
- ],
- tools=[],
- params=AgentParams(model="provider-default", temperature=0.3, max_tokens=50),
- )
- ]
- assert network_called is False
- def test_static_pre_message_role_selector_does_not_offer_tool():
- html = Path("src/agent_lab/presentation/static/index.html").read_text()
- assert '<option value="tool">tool</option>' not in html
- def test_static_tools_ui_is_dynamic_and_not_hardcoded_to_handoff_note_checkbox():
- html = Path("src/agent_lab/presentation/static/index.html").read_text()
- js = Path("src/agent_lab/presentation/static/app.js").read_text()
- assert 'id="tool-handoff-note"' not in html
- assert "#tool-handoff-note" not in js
- assert 'id="tool-list"' in html
- assert '"/api/tools"' in js
- assert "fetch" in js
- def test_static_prompt_workspace_controls_are_available_in_chat_agent_config():
- html = Path("src/agent_lab/presentation/static/index.html").read_text()
- chat_dialog_start = html.index('<dialog id="chat-config-dialog"')
- event_dialog_start = html.index('<dialog id="event-config-dialog"')
- chat_dialog_html = html[chat_dialog_start:event_dialog_start]
- assert 'id="open-prompt-config"' not in html
- assert '<dialog id="prompt-dialog"' not in html
- assert 'id="prompt-set-name"' in html
- assert 'id="saved-prompt-sets"' in html
- assert 'id="save-prompt-set"' in html
- assert 'id="load-prompt-set"' in html
- assert 'id="delete-prompt-set"' in html
- assert 'id="prompt-set-name"' in chat_dialog_html
- assert 'id="system-prompts"' in chat_dialog_html
- assert 'id="pre-messages"' in chat_dialog_html
- def test_static_chat_agent_prompt_is_a_single_sortable_list_with_event_instructions():
- html = Path("src/agent_lab/presentation/static/index.html").read_text()
- js = Path("src/agent_lab/presentation/static/app.js").read_text()
- css = Path("src/agent_lab/presentation/static/styles.css").read_text()
- assert 'id="chat-system-prompt"' not in html
- assert 'id="chat-event-prompt-preview"' not in html
- assert 'class="prompt-list stack"' in html
- assert 'class="prompt-item"' in html
- assert "function buildAvailableEventsPrompt(" in js
- assert "Available events:" in js
- assert "function updateEventPromptItem(" in js
- assert "function createSystemPrompt(" in js
- assert "function movePromptItem(" in js
- assert 'draggable = true' in js
- assert "delete-system-prompt" in js
- assert 'prompt_kind: "event_agent_system"' in js
- assert "system_prompts: collectSystemPrompts()" in js
- assert 'system_prompt: ""' in js
- assert ".prompt-item" in css
- assert ".prompt-item.dragging" in css
- assert "#open-prompt-config" not in js
- assert "promptDialog" not in js
- def test_static_app_script_is_versioned_and_click_binding_is_guarded():
- html = Path("src/agent_lab/presentation/static/index.html").read_text()
- js = Path("src/agent_lab/presentation/static/app.js").read_text()
- assert '<script src="/static/app.js?v=' in html
- assert "function bindClick(" in js
- assert 'bindClick("#open-chat-config-panel", openChatConfig)' in js
- assert 'bindClick("#open-event-config-panel", openEventConfig)' in js
- def test_static_agent_config_controls_are_aligned_for_chat_and_event_agents():
- html = Path("src/agent_lab/presentation/static/index.html").read_text()
- js = Path("src/agent_lab/presentation/static/app.js").read_text()
- assert '<dialog id="chat-config-dialog"' in html
- assert '<dialog id="event-config-dialog"' in html
- assert html.count('id="open-chat-config-panel"') == 1
- assert html.count('id="open-event-config-panel"') == 1
- assert 'id="open-chat-config"' not in html
- assert 'id="open-event-config"' not in html
- assert '#open-chat-config"' not in js
- assert '#open-event-config"' not in js
- assert '#open-chat-config-panel"' in js
- assert '#open-event-config-panel"' in js
- assert 'id="close-chat-config"' in html
- assert 'id="close-event-config"' in html
- assert "chatConfigDialog.showModal()" in js
- assert "eventConfigDialog.showModal()" in js
- assert "chatConfigDialog.close()" in js
- assert "eventConfigDialog.close()" in js
- for control_id in [
- "model",
- "temperature",
- "max-tokens",
- "chat-thinking-disabled",
- "chat-enable-search",
- "chat-forced-search",
- "event-model",
- "event-temperature",
- "event-max-tokens",
- "event-system-prompt",
- "event-thinking-disabled",
- "event-enable-search",
- "event-forced-search",
- ]:
- assert f'id="{control_id}"' in html
- assert 'extra_body: buildExtraBody("chat")' in js
- assert 'extra_body: buildExtraBody("event")' in js
- assert 'system_prompt: ""' in js
- assert 'system_prompt: document.querySelector("#event-system-prompt")' in js
- def test_static_tools_ui_shows_tool_metadata_and_selection_actions():
- html = Path("src/agent_lab/presentation/static/index.html").read_text()
- js = Path("src/agent_lab/presentation/static/app.js").read_text()
- assert 'id="tool-count"' in html
- assert 'id="select-all-tools"' in html
- assert 'id="clear-tools"' in html
- assert "tool-card" in js
- assert "tool-description" in js
- assert "tool-required" in js
- assert "function updateToolCount()" in js
- def test_static_app_handles_audit_messages():
- js = Path("src/agent_lab/presentation/static/app.js").read_text()
- css = Path("src/agent_lab/presentation/static/styles.css").read_text()
- assert 'message.type === "audit"' in js
- assert 'appendLog("audit"' in js
- assert ".audit" in css
- def test_static_app_shows_wait_state_and_immediate_user_echo():
- html = Path("src/agent_lab/presentation/static/index.html").read_text()
- js = Path("src/agent_lab/presentation/static/app.js").read_text()
- css = Path("src/agent_lab/presentation/static/styles.css").read_text()
- assert 'id="run-button"' in html
- assert 'const runButton = document.querySelector("#run-button")' in js
- assert 'appendLog("user", submittedMessage)' in js
- assert 'statusEl.textContent = "Waiting for first token"' in js
- assert "function startElapsedTimer()" in js
- assert "function stopElapsedTimer()" in js
- assert "function setRunState(" in js
- assert "if (firstTokenAt) {\n hasBackendRoundStats = true;" in js
- assert "hasBackendRoundStats && firstTokenAt" in js
- assert ".user" in css
- def test_static_prompt_workspace_uses_stable_storage_hooks():
- js = Path("src/agent_lab/presentation/static/app.js").read_text()
- assert "agent-lab.prompt-sets.v1" in js
- assert "function savePromptSet()" in js
- assert "function loadPromptSet()" in js
- assert "function deletePromptSet()" in js
- def test_static_app_handles_backend_round_stats_messages():
- js = Path("src/agent_lab/presentation/static/app.js").read_text()
- assert 'message.type === "round_stats"' in js
- assert "function updateRoundStats(" in js
- assert "#stat-ttft" in js
- assert "#stat-elapsed" in js
- @pytest.mark.asyncio
- async def test_openai_chat_client_omits_stream_options_when_usage_disabled():
- captured_payloads: list[dict] = []
- def handler(request: httpx.Request) -> httpx.Response:
- captured_payloads.append(json.loads(request.content))
- return httpx.Response(
- 200,
- content=b'data: {"choices":[{"delta":{"content":"hi"},"finish_reason":null}]}\n\n',
- )
- async with httpx.AsyncClient(
- transport=httpx.MockTransport(handler),
- base_url="https://llm.test/v1",
- ) as http_client:
- client = OpenAICompatibleChatClient(
- api_key="",
- base_url="https://llm.test/v1",
- default_model="provider-default",
- request_timeout_seconds=5,
- include_usage=False,
- http_client=http_client,
- )
- [
- item
- async for item in client.stream_chat(
- messages=[ChatMessage(role="user", content="hi")],
- tools=[],
- params=AgentParams(model="provider-default", temperature=0.3, max_tokens=50),
- )
- ]
- assert "stream_options" not in captured_payloads[0]
- @pytest.mark.asyncio
- async def test_openai_chat_client_merges_agent_extra_body_into_payload():
- captured_payloads: list[dict] = []
- def handler(request: httpx.Request) -> httpx.Response:
- captured_payloads.append(json.loads(request.content))
- return httpx.Response(
- 200,
- content=b'data: {"choices":[{"delta":{"content":"hi"},"finish_reason":null}]}\n\n',
- )
- async with httpx.AsyncClient(
- transport=httpx.MockTransport(handler),
- base_url="https://llm.test/v1",
- ) as http_client:
- client = OpenAICompatibleChatClient(
- api_key="",
- base_url="https://llm.test/v1",
- default_model="provider-default",
- request_timeout_seconds=5,
- http_client=http_client,
- )
- [
- item
- async for item in client.stream_chat(
- messages=[ChatMessage(role="user", content="hi")],
- tools=[],
- params=AgentParams(
- model="provider-default",
- temperature=0.3,
- max_tokens=50,
- extra_body={
- "thinking": {"type": "disabled"},
- "enable_search": False,
- "search_options": {"forced_search": False},
- },
- ),
- )
- ]
- assert captured_payloads[0]["thinking"] == {"type": "disabled"}
- assert captured_payloads[0]["enable_search"] is False
- assert captured_payloads[0]["search_options"] == {"forced_search": False}
|