| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730 |
- 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, TokenUsage
- from agent_lab.infrastructure.chat_client import OpenAICompatibleChatClient
- from agent_lab.infrastructure.sqlite_store import SQLiteSessionStore
- from agent_lab.presentation.web import create_app
- from agent_lab.settings import Settings
- def _css_rule(css: str, selector: str) -> str:
- start = css.index(f"{selector} {{")
- end = css.index("}", start)
- return css[start:end]
- 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)
- class PersistentSessionRuntime:
- def __init__(self) -> None:
- self.requests: list[DebugRunRequest] = []
- self.queues: RuntimeQueues | None = None
- self.task: asyncio.Task | None = None
- def start_session(self, request: DebugRunRequest) -> RuntimeQueues:
- self.requests.append(request)
- self.queues = RuntimeQueues()
- self.task = asyncio.create_task(self._run(request))
- return self.queues
- async def _run(self, request: DebugRunRequest) -> None:
- assert self.queues is not None
- await self.queues.output.put({"type": "session_started"})
- await self._emit_turn(1, request.user_message)
- turn_index = 1
- while True:
- message = await self.queues.input.get()
- turn_index += 1
- await self._emit_turn(turn_index, message.content)
- async def _emit_turn(self, turn_index: int, content: str) -> None:
- assert self.queues is not None
- await self.queues.output.put({"type": "turn_started", "turn_index": turn_index})
- await self.queues.output.put({"type": "message_delta", "content": content})
- await self.queues.output.put(
- {"type": "turn_completed", "turn_index": turn_index}
- )
- 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.provider_tool_call(
- 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.max_parallel_events == 4
- assert event_params.batch_timeout_seconds == 15.0
- assert event_params.system_prompt == ""
- @pytest.mark.parametrize("value", [0, 17])
- def test_event_agent_params_rejects_out_of_range_parallelism(value: int):
- with pytest.raises(ValidationError, match="max_parallel_events"):
- EventAgentParams(max_parallel_events=value)
- @pytest.mark.parametrize("value", [0, -1, 121])
- def test_event_agent_params_rejects_invalid_batch_timeout(value: float):
- with pytest.raises(ValidationError, match="batch_timeout_seconds"):
- EventAgentParams(batch_timeout_seconds=value)
- def test_debug_run_request_defaults_to_dual_agent_tool_invocation_mode():
- request = DebugRunRequest.model_validate(_request_payload())
- assert request.tool_invocation_mode == "dual_agent"
- @pytest.mark.parametrize("mode", ["dual_agent", "chat_agent_tools"])
- def test_debug_run_request_accepts_supported_tool_invocation_modes(mode: str):
- payload = _request_payload()
- payload["tool_invocation_mode"] = mode
- request = DebugRunRequest.model_validate(payload)
- assert request.tool_invocation_mode == mode
- def test_debug_run_request_rejects_unknown_tool_invocation_mode():
- payload = _request_payload()
- payload["tool_invocation_mode"] = "unknown"
- with pytest.raises(ValidationError, match="tool_invocation_mode"):
- DebugRunRequest.model_validate(payload)
- 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_default_tool_catalog():
- 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",
- "session.terminate",
- "device.volume.adjust",
- "calendar.schedule.create",
- "knowledge.web.search",
- ]
- assert tools[0]["parameters"]["required"] == ["message"]
- def test_session_api_creates_lists_and_returns_empty_replay_data(tmp_path):
- settings = Settings(database_path=str(tmp_path / "agent_lab.sqlite3"))
- app = create_app(settings=settings, runtime_factory=FakeRuntime)
- client = TestClient(app)
- created = client.post(
- "/api/sessions",
- json={
- "title": "Debug session",
- "config": {"chat_agent": {"model": "chat-model"}},
- },
- )
- assert created.status_code == 200
- session_id = created.json()["id"]
- assert created.json()["title"] == "Debug session"
- sessions = client.get("/api/sessions")
- assert sessions.status_code == 200
- assert sessions.json()[0]["id"] == session_id
- assert sessions.json()[0]["turn_count"] == 0
- detail = client.get(f"/api/sessions/{session_id}")
- assert detail.status_code == 200
- assert detail.json()["config"]["chat_agent"]["model"] == "chat-model"
- assert client.get(f"/api/sessions/{session_id}/messages").json() == []
- assert client.get(f"/api/sessions/{session_id}/audit").json() == []
- usage = client.get(f"/api/sessions/{session_id}/usage").json()
- assert usage == {
- "calls": [],
- "turns": [],
- "session": {
- "prompt_tokens": 0,
- "completion_tokens": 0,
- "total_tokens": 0,
- "cached_tokens": 0,
- "elapsed_ms": 0,
- },
- }
- def test_session_api_returns_persisted_replay_data(tmp_path):
- database_path = tmp_path / "agent_lab.sqlite3"
- settings = Settings(database_path=str(database_path))
- app = create_app(settings=settings, runtime_factory=FakeRuntime)
- client = TestClient(app)
- session_id = client.post("/api/sessions", json={"title": "Replay"}).json()["id"]
- store = SQLiteSessionStore(database_path)
- store.start_turn(session_id, turn_index=1, user_message="debug this")
- store.append_message(
- session_id,
- turn_index=1,
- message=ChatMessage(role="user", content="debug this"),
- )
- store.append_message(
- session_id,
- turn_index=1,
- message=ChatMessage(
- role="assistant",
- content="answer",
- tool_calls=[
- ToolCallEvent(
- id="call-1",
- name="mock_search",
- arguments={"query": "docs"},
- raw_arguments='{"query":"docs"}',
- )
- ],
- ),
- )
- store.append_message(
- session_id,
- turn_index=1,
- message=ChatMessage(
- role="tool",
- content='{"results":[]}',
- name="mock_search",
- tool_call_id="call-1",
- ),
- )
- store.append_audit(
- session_id,
- event="chat_agent_request",
- details={"model": "chat-model"},
- turn_index=1,
- round_index=1,
- )
- store.append_usage(
- session_id,
- turn_index=1,
- round_index=1,
- usage=TokenUsage(prompt_tokens=1, completion_tokens=2, total_tokens=3),
- ttft_ms=10,
- elapsed_ms=20,
- )
- messages = client.get(f"/api/sessions/{session_id}/messages").json()
- assert [message["content"] for message in messages] == [
- "debug this",
- "answer",
- '{"results":[]}',
- ]
- assert messages[0]["tool_calls"] == []
- assert messages[1]["tool_calls"] == [
- {
- "id": "call-1",
- "name": "mock_search",
- "arguments": {"query": "docs"},
- "raw_arguments": '{"query":"docs"}',
- }
- ]
- assert messages[2]["tool_call_id"] == "call-1"
- assert messages[2]["tool_calls"] == []
- assert client.get(f"/api/sessions/{session_id}/audit").json()[0]["details"] == {
- "model": "chat-model"
- }
- usage = client.get(f"/api/sessions/{session_id}/usage").json()
- assert usage["calls"][0]["total_tokens"] == 3
- assert usage["turns"][0]["total_tokens"] == 3
- assert usage["session"]["total_tokens"] == 3
- def test_default_websocket_runtime_factory_accepts_session_store(tmp_path):
- settings = Settings(database_path=str(tmp_path / "agent_lab.sqlite3"))
- app = create_app(settings=settings)
- 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_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_keeps_session_open_for_multiple_turns():
- runtime = PersistentSessionRuntime()
- 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": "turn_started", "turn_index": 1}
- assert websocket.receive_json() == {
- "type": "message_delta",
- "content": "debug this",
- }
- assert websocket.receive_json() == {"type": "turn_completed", "turn_index": 1}
- websocket.send_json({"type": "user_message", "content": "follow-up"})
- assert websocket.receive_json() == {"type": "turn_started", "turn_index": 2}
- assert websocket.receive_json() == {
- "type": "message_delta",
- "content": "follow-up",
- }
- assert websocket.receive_json() == {"type": "turn_completed", "turn_index": 2}
- websocket.close()
- 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_debug_run_request_rejects_assistant_tool_calls_in_pre_messages():
- payload = _request_payload()
- payload["pre_messages"] = [
- {
- "role": "assistant",
- "content": "I will inspect this.",
- "tool_calls": [
- {
- "id": "call_1",
- "name": "mock_search",
- "arguments": {"query": "latency"},
- "raw_arguments": '{"query":"latency"}',
- }
- ],
- }
- ]
- with pytest.raises(
- ValidationError,
- match="pre_messages cannot include assistant tool calls",
- ):
- 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"
- def test_chat_message_preserves_assistant_tool_calls():
- message = ChatMessage(
- role="assistant",
- content="I will inspect both sources.",
- tool_calls=[
- ToolCallEvent(
- id="call_1",
- name="mock_search",
- arguments={"query": "latency docs"},
- raw_arguments='{"query":"latency docs"}',
- ),
- ToolCallEvent(
- id="call_2",
- name="handoff_note",
- arguments={"message": "inspect provider behavior"},
- raw_arguments='{"message":"inspect provider behavior"}',
- ),
- ],
- )
- assert message.model_dump()["tool_calls"] == [
- {
- "id": "call_1",
- "name": "mock_search",
- "arguments": {"query": "latency docs"},
- "raw_arguments": '{"query":"latency docs"}',
- },
- {
- "id": "call_2",
- "name": "handoff_note",
- "arguments": {"message": "inspect provider behavior"},
- "raw_arguments": '{"message":"inspect provider behavior"}',
- },
- ]
- def test_chat_message_rejects_tool_calls_on_non_assistant_role():
- with pytest.raises(ValidationError, match="tool_calls require assistant role"):
- ChatMessage(
- role="user",
- content="invalid",
- tool_calls=[
- ToolCallEvent(
- id="call_1",
- name="mock_search",
- arguments={},
- raw_arguments="{}",
- )
- ],
- )
- def test_chat_message_rejects_tool_role_without_tool_call_id():
- with pytest.raises(ValidationError, match="tool messages require tool_call_id"):
- ChatMessage(role="tool", content="orphan result")
- @pytest.mark.parametrize("role", ["system", "user", "assistant"])
- def test_chat_message_rejects_tool_call_id_on_non_tool_roles(role: str):
- with pytest.raises(ValidationError, match="tool_call_id requires tool role"):
- ChatMessage(role=role, content="invalid", tool_call_id="call_1")
- def test_chat_message_rejects_duplicate_assistant_tool_call_ids():
- with pytest.raises(ValidationError, match="duplicate assistant tool-call ID: call_1"):
- ChatMessage(
- role="assistant",
- content="checking twice",
- tool_calls=[
- ToolCallEvent(
- id="call_1",
- name="mock_search",
- arguments={"query": "first"},
- raw_arguments='{"query":"first"}',
- ),
- ToolCallEvent(
- id="call_1",
- name="handoff_note",
- arguments={"message": "second"},
- raw_arguments='{"message":"second"}',
- ),
- ],
- )
- 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,
- tool_choice: dict | None = None,
- ) -> 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.text_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 "tool_choice" not in payload
- 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 if item.kind == "message_delta"] == ["hi"]
- assert [item.raw_chunk for item in items if item.kind == "raw_chunk"] == [
- {"choices": [{"delta": {"content": "hi"}, "finish_reason": None}]}
- ]
- @pytest.mark.asyncio
- async def test_openai_chat_client_serializes_explicit_tool_choice():
- captured_payloads: list[dict] = []
- forced_choice = {
- "type": "function",
- "function": {"name": "handoff_note"},
- }
- def handler(request: httpx.Request) -> httpx.Response:
- captured_payloads.append(json.loads(request.content))
- return httpx.Response(200, content=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="",
- 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="prepare handoff")],
- tools=[
- {
- "type": "function",
- "function": {"name": "handoff_note", "parameters": {}},
- }
- ],
- params=AgentParams(model="provider-default"),
- tool_choice=forced_choice,
- )
- ]
- assert captured_payloads[0]["tool_choice"] == forced_choice
- @pytest.mark.asyncio
- async def test_openai_chat_client_flushes_one_provider_tool_call_at_done():
- def handler(request: httpx.Request) -> httpx.Response:
- return httpx.Response(
- 200,
- content=(
- b'data: {"choices":[{"delta":{"tool_calls":[{"index":0,'
- b'"id":"call_1","function":{"name":"mock_search",'
- b'"arguments":"{\\"query\\":\\""}}]},"finish_reason":null}]}\n\n'
- b'data: {"choices":[{"delta":{"tool_calls":[{"index":0,'
- b'"function":{"arguments":"latency docs\\"}"}}]},'
- b'"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="",
- base_url="https://llm.test/v1",
- default_model="provider-default",
- request_timeout_seconds=5,
- http_client=http_client,
- )
- items = [
- item
- async for item in client.stream_chat(
- messages=[ChatMessage(role="user", content="find docs")],
- tools=[
- {
- "type": "function",
- "function": {"name": "mock_search", "parameters": {}},
- }
- ],
- params=AgentParams(model="provider-default"),
- )
- ]
- provider_calls = [
- item.event for item in items if item.kind == "provider_tool_call"
- ]
- assert provider_calls == [
- ToolCallEvent(
- id="call_1",
- name="mock_search",
- arguments={"query": "latency docs"},
- raw_arguments='{"query":"latency docs"}',
- )
- ]
- @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_serializes_provider_tool_transcript():
- 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":"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,
- )
- [
- item
- async for item in client.stream_chat(
- messages=[
- ChatMessage(role="user", content="inspect both"),
- ChatMessage(
- role="assistant",
- content="I will inspect both sources.",
- tool_calls=[
- ToolCallEvent(
- id="call_1",
- name="mock_search",
- arguments={"query": "latency docs"},
- raw_arguments='{"query":"latency docs"}',
- ),
- ToolCallEvent(
- id="call_2",
- name="handoff_note",
- arguments={"message": "inspect provider behavior"},
- raw_arguments='{"message":"inspect provider behavior"}',
- ),
- ],
- ),
- ChatMessage(
- role="tool",
- content='{"results":[]}',
- name="mock_search",
- tool_call_id="call_1",
- ),
- ChatMessage(
- role="tool",
- content='{"message":"handled"}',
- name="handoff_note",
- tool_call_id="call_2",
- ),
- ChatMessage(role="user", content="continue"),
- ],
- tools=[],
- params=AgentParams(
- model="provider-default",
- temperature=0.3,
- max_tokens=50,
- ),
- )
- ]
- assert captured_payloads[0]["messages"] == [
- {"role": "user", "content": "inspect both"},
- {
- "role": "assistant",
- "content": "I will inspect both sources.",
- "tool_calls": [
- {
- "id": "call_1",
- "type": "function",
- "function": {
- "name": "mock_search",
- "arguments": '{"query":"latency docs"}',
- },
- },
- {
- "id": "call_2",
- "type": "function",
- "function": {
- "name": "handoff_note",
- "arguments": '{"message":"inspect provider behavior"}',
- },
- },
- ],
- },
- {
- "role": "tool",
- "content": '{"results":[]}',
- "tool_call_id": "call_1",
- },
- {
- "role": "tool",
- "content": '{"message":"handled"}',
- "tool_call_id": "call_2",
- },
- {"role": "user", "content": "continue"},
- ]
- async def _assert_tool_transcript_rejected(
- messages: list[ChatMessage],
- error_match: str,
- ) -> None:
- network_called = False
- def handler(request: httpx.Request) -> httpx.Response:
- nonlocal network_called
- network_called = True
- return httpx.Response(200)
- 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=error_match):
- [
- item
- async for item in client.stream_chat(
- messages=messages,
- tools=[],
- params=AgentParams(model="provider-default"),
- )
- ]
- assert network_called is False
- @pytest.mark.asyncio
- async def test_openai_chat_client_rejects_orphan_tool_reply_before_network():
- await _assert_tool_transcript_rejected(
- [
- ChatMessage(
- role="tool",
- content="orphan",
- tool_call_id="call_1",
- )
- ],
- "orphan tool reply: call_1",
- )
- @pytest.mark.asyncio
- async def test_openai_chat_client_rejects_mismatched_tool_call_id_before_network():
- await _assert_tool_transcript_rejected(
- [
- ChatMessage(
- role="assistant",
- content="",
- tool_calls=[
- ToolCallEvent(
- id="call_1",
- name="mock_search",
- arguments={},
- raw_arguments="{}",
- )
- ],
- ),
- ChatMessage(role="tool", content="wrong", tool_call_id="call_2"),
- ],
- "mismatched tool_call_id: call_2",
- )
- @pytest.mark.asyncio
- async def test_openai_chat_client_rejects_duplicate_tool_reply_before_network():
- await _assert_tool_transcript_rejected(
- [
- ChatMessage(
- role="assistant",
- content="",
- tool_calls=[
- ToolCallEvent(
- id="call_1",
- name="mock_search",
- arguments={},
- raw_arguments="{}",
- )
- ],
- ),
- ChatMessage(role="tool", content="first", tool_call_id="call_1"),
- ChatMessage(role="tool", content="duplicate", tool_call_id="call_1"),
- ],
- "duplicate tool reply: call_1",
- )
- @pytest.mark.asyncio
- async def test_openai_chat_client_rejects_non_tool_message_with_unresolved_calls():
- await _assert_tool_transcript_rejected(
- [
- ChatMessage(
- role="assistant",
- content="checking",
- tool_calls=[
- ToolCallEvent(
- id="call_1",
- name="mock_search",
- arguments={},
- raw_arguments="{}",
- ),
- ToolCallEvent(
- id="call_2",
- name="handoff_note",
- arguments={},
- raw_arguments="{}",
- ),
- ],
- ),
- ChatMessage(role="tool", content="done", tool_call_id="call_1"),
- ChatMessage(role="user", content="continue too early"),
- ],
- "unresolved tool calls before user message: call_2",
- )
- @pytest.mark.asyncio
- async def test_openai_chat_client_rejects_unfinished_tool_calls_at_end():
- await _assert_tool_transcript_rejected(
- [
- ChatMessage(
- role="assistant",
- content="checking",
- tool_calls=[
- ToolCallEvent(
- id="call_1",
- name="mock_search",
- arguments={},
- raw_arguments="{}",
- )
- ],
- )
- ],
- "unresolved tool calls at end: call_1",
- )
- @pytest.mark.asyncio
- async def test_openai_chat_client_rejects_reused_tool_call_id_across_transcript():
- await _assert_tool_transcript_rejected(
- [
- ChatMessage(
- role="assistant",
- content="first round",
- tool_calls=[
- ToolCallEvent(
- id="call_1",
- name="mock_search",
- arguments={},
- raw_arguments="{}",
- )
- ],
- ),
- ChatMessage(role="tool", content="done", tool_call_id="call_1"),
- ChatMessage(
- role="assistant",
- content="second round",
- tool_calls=[
- ToolCallEvent(
- id="call_1",
- name="handoff_note",
- arguments={},
- raw_arguments="{}",
- )
- ],
- ),
- ],
- "duplicate assistant tool-call ID across transcript: call_1",
- )
- async def _assert_tool_choice_rejected(
- tools: list[dict],
- tool_choice: dict,
- error_match: str,
- ) -> None:
- network_called = False
- def handler(request: httpx.Request) -> httpx.Response:
- nonlocal network_called
- network_called = True
- return httpx.Response(200)
- 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=error_match):
- [
- item
- async for item in client.stream_chat(
- messages=[ChatMessage(role="user", content="use a tool")],
- tools=tools,
- params=AgentParams(model="provider-default"),
- tool_choice=tool_choice,
- )
- ]
- assert network_called is False
- @pytest.mark.asyncio
- async def test_openai_chat_client_rejects_forced_choice_without_tools():
- await _assert_tool_choice_rejected(
- tools=[],
- tool_choice={
- "type": "function",
- "function": {"name": "handoff_note"},
- },
- error_match="forced tool choice requires tools",
- )
- @pytest.mark.asyncio
- async def test_openai_chat_client_rejects_forced_choice_for_unknown_tool():
- await _assert_tool_choice_rejected(
- tools=[
- {
- "type": "function",
- "function": {"name": "mock_search", "parameters": {}},
- }
- ],
- tool_choice={
- "type": "function",
- "function": {"name": "handoff_note"},
- },
- error_match="forced tool choice references unknown tool: handoff_note",
- )
- 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_workspace_snapshot_controls_are_available_outside_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]
- sidebar_html = html[:chat_dialog_start]
- assert 'id="open-prompt-config"' not in html
- assert '<dialog id="prompt-dialog"' not in html
- assert 'id="workspace-snapshot-name"' in sidebar_html
- assert 'id="saved-workspace-snapshots"' in sidebar_html
- assert 'id="save-workspace-snapshot"' in sidebar_html
- assert 'id="load-workspace-snapshot"' in sidebar_html
- assert 'id="delete-workspace-snapshot"' in sidebar_html
- assert 'id="workspace-snapshot-name"' not in chat_dialog_html
- assert 'id="saved-workspace-snapshots"' not in chat_dialog_html
- assert 'id="save-workspace-snapshot"' not in chat_dialog_html
- assert 'id="system-prompts"' in chat_dialog_html
- assert 'id="pre-messages"' in chat_dialog_html
- def test_static_session_ui_controls_and_replay_panels_are_available():
- 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]
- event_dialog_html = html[event_dialog_start:]
- sidebar_html = html[:chat_dialog_start]
- for control_id in [
- "session-title",
- "session-list",
- "new-session",
- "refresh-sessions",
- "load-session",
- "session-status",
- ]:
- assert f'id="{control_id}"' in sidebar_html
- assert f'id="{control_id}"' not in chat_dialog_html
- assert f'id="{control_id}"' not in event_dialog_html
- for replay_id in [
- "audit-replay",
- "audit-count",
- "session-usage-summary",
- "session-usage-turns",
- "session-usage-calls",
- ]:
- assert f'id="{replay_id}"' in 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 'data-prompt-role="system"' in html
- assert "prompt-type" 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 "dataset.promptRole" in js
- assert "prompt-title" 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 ".prompt-meta" in css
- assert ".prompt-type" in css
- assert ".prompt-actions button" 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_tool_mode_and_batch_controls_explain_active_configuration():
- 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-invocation-mode"' in html
- assert '<option value="dual_agent" selected>' in html
- assert '<option value="chat_agent_tools">' in html
- assert 'id="tool-mode-help"' in html
- assert 'id="max-parallel-events"' in html
- assert 'value="4"' in html
- assert 'id="batch-timeout-seconds"' in html
- assert 'value="15"' in html
- assert "missing-argument fallback" in js
- assert "EventAgent model, temperature, max tokens, system prompt, and extra body are inactive" in js
- assert "enabled tools, loop budget, batch concurrency, and timeout remain active" in js
- assert "function updateToolModeHelp()" in js
- def test_static_tool_mode_config_roundtrips_request_snapshot_and_session_restore():
- js = Path("src/agent_lab/presentation/static/app.js").read_text()
- build_snapshot = js[
- js.index("function buildWorkspaceSnapshot()") :
- js.index("function restoreWorkspaceSnapshot(")
- ]
- restore_snapshot = js[
- js.index("function restoreWorkspaceSnapshot(") :
- js.index("function buildExtraBody(")
- ]
- build_request = js[
- js.index("function buildRequest(") :
- js.index("function selectedTools(")
- ]
- load_replay = js[
- js.index("async function loadSessionReplay(") :
- js.index("function setSessionStatus(")
- ]
- create_session = js[
- js.index("async function createSession()") :
- js.index("async function loadSelectedSession()")
- ]
- for source in [build_snapshot, build_request]:
- assert 'tool_invocation_mode: document.querySelector("#tool-invocation-mode").value' in source
- assert 'max_parallel_events: Number(document.querySelector("#max-parallel-events").value)' in source
- assert 'batch_timeout_seconds: Number(document.querySelector("#batch-timeout-seconds").value)' in source
- assert 'snapshot.tool_invocation_mode || "dual_agent"' in restore_snapshot
- assert 'eventAgent.max_parallel_events ?? 4' in restore_snapshot
- assert 'eventAgent.batch_timeout_seconds ?? 15' in restore_snapshot
- assert "updateToolModeHelp();" in restore_snapshot
- assert '`/api/sessions/${sessionId}`' in load_replay
- assert "const [session, messages, audit, usage] = await Promise.all([" in load_replay
- assert "restoreWorkspaceSnapshot(session.config || {});" in load_replay
- assert "config: buildWorkspaceSnapshot()" in create_session
- 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 "appendAuditEntry(message)" in js
- assert 'appendLog("audit"' not in js
- assert "function renderAuditDetail(" in js
- assert "chat_agent_request" in js
- assert "event_agent_response" in js
- assert "chat_message_stream_started" in js
- assert "chat_message_stream_finished" in js
- assert ".audit-event" in css
- assert ".audit-detail" in css
- def test_static_audit_replay_uses_event_stream_and_detail_panel():
- 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 '<link rel="stylesheet" href="/static/styles.css?v=' in html
- assert "tool-mode-session-restore" in html
- assert 'id="audit-dialog"' in html
- assert 'id="open-audit-dialog"' in html
- assert 'id="close-audit-dialog"' in html
- assert 'id="audit-summary"' in html
- assert 'id="audit-replay" class="audit-stream"' in html
- assert 'id="audit-detail"' in html
- assert "auditDialog.showModal()" in js
- assert "auditDialog.close()" in js
- assert "auditEntryKey(entry)" in js
- assert "selectAuditEntry(key)" in js
- assert 'meta.className = "audit-detail-meta"' in js
- assert 'appendMetaField(meta, "Since User Message"' in js
- assert "auditRelativeTime(entry)" in js
- assert 'const list = document.createElement("ul")' in js
- assert "appendKeyValueSection(auditDetail, \"Model Parameters\"" in js
- assert "appendRepliesSection(auditDetail, details.replies)" in js
- assert "appendRawChunksSection(auditDetail, \"Raw LLM Chunks\"" in js
- assert "appendRawModelChunksSection(auditDetail, details.raw_model_chunks)" in js
- assert "Enabled This Round" in js
- assert "Configured Events" in js
- assert "details.configured_events" in js
- assert "enabled this round" in js
- assert ".audit-modal" in css
- assert ".audit-modal-body" in css
- assert ".audit-modal-body section + section" in css
- assert ".audit-detail-title" in css
- assert ".detail-tags code" in css
- assert ".audit-detail-meta span" not in css
- assert ".detail-tags span" not in css
- assert "display: block" in _css_rule(css, ".audit-detail")
- assert "display: grid" not in _css_rule(css, ".audit-detail")
- assert "display: block" in _css_rule(css, ".audit-detail-header")
- assert "display: grid" not in _css_rule(css, ".audit-detail-header")
- assert "display: inline-flex" in _css_rule(css, ".audit-detail-meta div")
- assert ".audit-marker::before" in css
- assert ".audit-event.is-selected" in css
- def test_static_layout_keeps_controls_and_composer_fixed_with_scrollable_panes():
- css = Path("src/agent_lab/presentation/static/styles.css").read_text()
- assert "height: calc(100vh - 56px)" in css
- assert ".layout" in css and "overflow: hidden" in css
- assert ".controls" in css and "overflow: auto" in css
- assert ".workspace {\n display: grid;\n grid-template-rows: minmax(0, 1fr) auto auto;" in css
- assert ".workspace-body" in css and "overflow: hidden" in css
- assert ".messages" in css and "overflow: auto" in css
- assert ".audit-stream" in css and "overflow: auto" 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_app_reuses_websocket_for_session_turns():
- js = Path("src/agent_lab/presentation/static/app.js").read_text()
- assert "function isSocketOpen()" in js
- assert "socket.send(JSON.stringify({ type: \"user_message\"" in js
- assert "session_id: currentSessionId || undefined" in js
- assert "message.session_id" in js
- assert 'message.type === "turn_completed"' in js
- assert 'message.type === "done"' in js
- def test_static_app_loads_session_replay_and_usage():
- js = Path("src/agent_lab/presentation/static/app.js").read_text()
- assert "function loadSessions(" in js
- assert "function createSession(" in js
- assert "function setSessionStatus(" in js
- assert '"Refreshing sessions"' in js
- assert '"Creating session"' in js
- assert '"Session created"' in js
- assert 'sessionStatus.textContent = message' in js
- assert "function loadSessionReplay(" in js
- assert 'fetchJson("/api/sessions")' in js
- assert 'method: "POST"' in js
- assert '`/api/sessions/${sessionId}/messages`' in js
- assert '`/api/sessions/${sessionId}/audit`' in js
- assert '`/api/sessions/${sessionId}/usage`' in js
- assert "function renderAuditReplay(" in js
- assert "function renderSessionUsage(" in js
- assert "sessionUsageSummary" in js
- assert "Finish current turn before switching sessions" in js
- def test_static_session_switch_guard_uses_active_turn_not_open_socket():
- js = Path("src/agent_lab/presentation/static/app.js").read_text()
- create_session = js[js.index("async function createSession()") : js.index("async function loadSelectedSession()")]
- load_session = js[js.index("async function loadSelectedSession()") : js.index("async function loadSessionReplay(")]
- assert "function hasActiveTurn()" in js
- assert "turnActive = isRunning" in js
- assert "if (hasActiveTurn())" in create_session
- assert "if (hasActiveTurn())" in load_session
- assert "if (isSocketOpen())" not in create_session
- assert "if (isSocketOpen())" not in load_session
- assert "closeSocket();" in create_session
- assert "closeSocket();" in load_session
- assert "const activeSocket = socket" in js
- assert "if (socket !== activeSocket)" in js
- def test_static_workspace_snapshot_uses_stable_storage_hooks():
- js = Path("src/agent_lab/presentation/static/app.js").read_text()
- assert "agent-lab.workspace-snapshots.v1" in js
- assert "agent-lab.prompt-sets.v1" in js
- assert "function saveWorkspaceSnapshot()" in js
- assert "function loadWorkspaceSnapshot()" in js
- assert "function deleteWorkspaceSnapshot()" in js
- assert "function buildWorkspaceSnapshot()" in js
- assert "prompt_items: buildPromptItems()" in js
- assert "enabled_tools: selectedTools()" in js
- assert "extra_body: buildExtraBody(\"chat\")" in js
- assert "extra_body: buildExtraBody(\"event\")" 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}
- @pytest.mark.asyncio
- @pytest.mark.parametrize(
- ("reserved_key", "override_value"),
- [
- ("model", "overridden-model"),
- ("messages", []),
- ("tools", []),
- (
- "tool_choice",
- {"type": "function", "function": {"name": "other_tool"}},
- ),
- ("stream", False),
- ("stream_options", {"include_usage": False}),
- ("temperature", 1.0),
- ("max_tokens", 1),
- ],
- )
- async def test_openai_chat_client_rejects_reserved_extra_body_fields_before_network(
- reserved_key: str,
- override_value: object,
- ):
- network_called = False
- def handler(request: httpx.Request) -> httpx.Response:
- nonlocal network_called
- network_called = True
- return httpx.Response(200)
- 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=f"extra_body contains reserved field: {reserved_key}",
- ):
- [
- item
- async for item in client.stream_chat(
- messages=[ChatMessage(role="user", content="hi")],
- tools=[],
- params=AgentParams(extra_body={reserved_key: override_value}),
- )
- ]
- assert network_called is False
|