| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067 |
- import json
- import asyncio
- import logging
- import subprocess
- from collections.abc import AsyncIterator
- from html.parser import HTMLParser
- 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 _HtmlNode:
- def __init__(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
- self.tag = tag
- self.attrs = dict(attrs)
- self.children: list["_HtmlNode"] = []
- self.text_parts: list[str] = []
- @property
- def classes(self) -> set[str]:
- return set(self.attrs.get("class", "").split())
- def descendants(self) -> list["_HtmlNode"]:
- nodes: list["_HtmlNode"] = []
- for child in self.children:
- nodes.append(child)
- nodes.extend(child.descendants())
- return nodes
- def text_content(self) -> str:
- return "".join(
- [*self.text_parts, *(child.text_content() for child in self.children)]
- )
- class _HtmlTreeParser(HTMLParser):
- _void_tags = {
- "area",
- "base",
- "br",
- "col",
- "embed",
- "hr",
- "img",
- "input",
- "link",
- "meta",
- "param",
- "source",
- "track",
- "wbr",
- }
- def __init__(self) -> None:
- super().__init__()
- self.root = _HtmlNode("document", [])
- self.stack = [self.root]
- def handle_starttag(
- self,
- tag: str,
- attrs: list[tuple[str, str | None]],
- ) -> None:
- node = _HtmlNode(tag, attrs)
- self.stack[-1].children.append(node)
- if tag not in self._void_tags:
- self.stack.append(node)
- def handle_startendtag(
- self,
- tag: str,
- attrs: list[tuple[str, str | None]],
- ) -> None:
- self.stack[-1].children.append(_HtmlNode(tag, attrs))
- def handle_endtag(self, tag: str) -> None:
- for index in range(len(self.stack) - 1, 0, -1):
- if self.stack[index].tag == tag:
- del self.stack[index:]
- return
- def handle_data(self, data: str) -> None:
- self.stack[-1].text_parts.append(data)
- def _parse_html(html: str) -> _HtmlNode:
- parser = _HtmlTreeParser()
- parser.feed(html)
- return parser.root
- def _html_node_by_id(root: _HtmlNode, element_id: str) -> _HtmlNode:
- node = next(
- (
- node
- for node in [root, *root.descendants()]
- if node.attrs.get("id") == element_id
- ),
- None,
- )
- assert node is not None, f"missing HTML element #{element_id}"
- return node
- def _run_node_json(source: str) -> object:
- completed = subprocess.run(
- ["node", "--input-type=module", "-e", source],
- capture_output=True,
- text=True,
- check=False,
- )
- assert completed.returncode == 0, completed.stderr
- return json.loads(completed.stdout)
- def _console_ui_source(js: str) -> str:
- assert 'const CONSOLE_UI_STORAGE_KEY = "agent-lab-console-ui-v1";' in js
- for function_name in [
- "readConsoleUiState",
- "writeConsoleUiState",
- "setControlSectionExpanded",
- "setDebugDrawerOpen",
- "initializeConsoleUi",
- "updateTopbarStatus",
- ]:
- assert f"function {function_name}(" in js
- return js[
- js.index("function readConsoleUiState(") :
- js.index("function openChatConfig(")
- ]
- def _run_load_session_replay_scenario(scenario: str) -> object:
- js = Path("src/agent_lab/presentation/static/app.js").read_text()
- load_replay = js[
- js.index("async function loadSessionReplay(") :
- js.index("function setSessionStatus(")
- ]
- harness = """
- let currentSessionId = null;
- let sessionReplayLoadToken = 0;
- const sessionTitle = { value: "" };
- const sessionList = { value: "" };
- const mutations = {
- workspace: [],
- transcript: [],
- audit: [],
- usage: [],
- status: [],
- };
- const pending = new Map();
- function fetchJson(url) {
- let resolve;
- let reject;
- const promise = new Promise((resolvePromise, rejectPromise) => {
- resolve = resolvePromise;
- reject = rejectPromise;
- });
- pending.set(url, { resolve, reject });
- return promise;
- }
- function resolveReplay(sessionId) {
- pending.get(`/api/sessions/${sessionId}`).resolve({
- title: `${sessionId} title`,
- config: { marker: sessionId },
- });
- pending.get(`/api/sessions/${sessionId}/messages`).resolve([
- { content: sessionId },
- ]);
- pending.get(`/api/sessions/${sessionId}/audit`).resolve([
- { event: sessionId },
- ]);
- pending.get(`/api/sessions/${sessionId}/usage`).resolve({
- session: { marker: sessionId },
- });
- }
- function restoreWorkspaceSnapshot(config) {
- mutations.workspace.push(config.marker);
- }
- function renderReplayMessages(messages) {
- mutations.transcript.push(messages[0].content);
- }
- function renderAuditReplay(audit) {
- mutations.audit.push(audit[0].event);
- }
- function renderSessionUsage(usage) {
- mutations.usage.push(usage.session.marker);
- }
- function setSessionStatus(status) {
- mutations.status.push(status);
- }
- function isSocketOpen() {
- return false;
- }
- """
- return _run_node_json("\n".join([harness, load_replay, scenario]))
- 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,
- "call_count": 0,
- "fallback_count": 0,
- "tool_count": 0,
- "turn_wall_time_ms": None,
- },
- }
- 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_professional_console_has_semantic_three_region_shell():
- html = Path("src/agent_lab/presentation/static/index.html").read_text()
- root = _parse_html(html)
- layout = _html_node_by_id(root, "console-layout")
- direct_regions = layout.children
- assert layout.tag == "main"
- assert "layout" in layout.classes
- assert [(node.tag, node.attrs.get("aria-label")) for node in direct_regions] == [
- ("aside", "Runtime controls"),
- ("section", "Conversation workspace"),
- ("aside", "Audit and usage"),
- ]
- assert "controls" in direct_regions[0].classes
- assert "workspace" in direct_regions[1].classes
- assert "debug-drawer" in direct_regions[2].classes
- assert direct_regions[2].attrs["id"] == "debug-drawer"
- assert "Agent Lab" in html
- assert "Chat and event runtime console" in html
- for status_id in [
- "current-session-label",
- "current-mode-label",
- "connection-status",
- ]:
- assert "status-badge" in _html_node_by_id(root, status_id).classes
- drawer_toggle = _html_node_by_id(root, "toggle-debug-drawer")
- assert drawer_toggle.attrs["aria-expanded"] == "true"
- assert drawer_toggle.attrs["aria-controls"] == "debug-drawer"
- workspace = direct_regions[1]
- assert [child.attrs.get("class") for child in workspace.children] == [
- "workspace-body",
- "stats",
- "composer",
- ]
- assert _html_node_by_id(workspace, "messages").attrs["class"] == "messages"
- assert _html_node_by_id(workspace, "chat-form").tag == "form"
- drawer_ids = {
- node.attrs["id"]
- for node in [direct_regions[2], *direct_regions[2].descendants()]
- if "id" in node.attrs
- }
- workspace_ids = {
- node.attrs["id"]
- for node in [workspace, *workspace.descendants()]
- if "id" in node.attrs
- }
- for replay_id in [
- "audit-count",
- "audit-summary",
- "open-audit-dialog",
- "session-usage-summary",
- "session-usage-turns",
- "session-usage-calls",
- ]:
- assert replay_id in drawer_ids
- assert replay_id not in workspace_ids
- def test_static_professional_console_control_sections_have_accessible_defaults():
- html = Path("src/agent_lab/presentation/static/index.html").read_text()
- root = _parse_html(html)
- controls = next(
- node
- for node in _html_node_by_id(root, "console-layout").children
- if "controls" in node.classes
- )
- sections = [child for child in controls.children if "control-section" in child.classes]
- toggles = [
- node
- for node in root.descendants()
- if "control-section-toggle" in node.classes
- ]
- assert len(sections) == 4
- assert len(toggles) == 4
- expected_defaults = {
- "session-controls": ("Session", True),
- "tool-controls": ("Tools", True),
- "agent-config-controls": ("Agent Config", False),
- "snapshot-controls": ("Snapshot", False),
- }
- for section, (target_id, (title, expanded)) in zip(
- sections,
- expected_defaults.items(),
- strict=True,
- ):
- section_toggles = [
- node
- for node in section.children
- if "control-section-toggle" in node.classes
- ]
- assert len(section_toggles) == 1
- toggle = section_toggles[0]
- body = _html_node_by_id(section, target_id)
- assert toggle.tag == "button"
- assert toggle.attrs["type"] == "button"
- assert toggle.attrs["data-collapse-target"] == target_id
- assert toggle.attrs["aria-controls"] == target_id
- assert toggle.attrs["aria-expanded"] == str(expanded).lower()
- assert title in toggle.text_content()
- assert ("hidden" in body.attrs) is not expanded
- def test_static_console_ui_section_click_opens_hidden_content_and_persists():
- js = Path("src/agent_lab/presentation/static/app.js").read_text()
- console_ui = _console_ui_source(js)
- result = _run_node_json(
- "\n".join(
- [
- 'const CONSOLE_UI_STORAGE_KEY = "agent-lab-console-ui-v1";',
- """
- function makeElement({ dataset = {}, expanded = "false", hidden = false } = {}) {
- const listeners = {};
- return {
- dataset,
- hidden,
- textContent: "",
- attributes: { "aria-expanded": expanded },
- classList: { toggle() {} },
- addEventListener(type, listener) { listeners[type] = listener; },
- click() { listeners.click(); },
- getAttribute(name) { return this.attributes[name]; },
- setAttribute(name, value) { this.attributes[name] = String(value); },
- };
- }
- const sectionButton = makeElement({
- dataset: { collapseTarget: "agent-config-controls" },
- expanded: "false",
- });
- const sectionBody = makeElement({ hidden: true });
- const drawerButton = makeElement({ expanded: "true" });
- drawerButton.textContent = "Hide Debug";
- const drawer = makeElement();
- const layout = makeElement();
- const elements = {
- "#agent-config-controls": sectionBody,
- "#toggle-debug-drawer": drawerButton,
- "#debug-drawer": drawer,
- "#console-layout": layout,
- };
- const document = {
- querySelector(selector) { return elements[selector] || null; },
- querySelectorAll(selector) {
- return selector === ".control-section-toggle" ? [sectionButton] : [];
- },
- };
- let stored = JSON.stringify({
- debug_drawer_open: false,
- sections: { "agent-config-controls": false },
- });
- globalThis.localStorage = {
- getItem() { return stored; },
- setItem(key, value) { stored = value; },
- };
- """,
- console_ui,
- """
- initializeConsoleUi();
- sectionButton.click();
- console.log(JSON.stringify({
- expanded: sectionButton.getAttribute("aria-expanded"),
- hidden: sectionBody.hidden,
- stored: JSON.parse(stored),
- }));
- """,
- ]
- )
- )
- assert result == {
- "expanded": "true",
- "hidden": False,
- "stored": {
- "debug_drawer_open": False,
- "sections": {"agent-config-controls": True},
- },
- }
- def test_static_console_ui_drawer_click_updates_layout_visibility_and_control():
- js = Path("src/agent_lab/presentation/static/app.js").read_text()
- console_ui = _console_ui_source(js)
- result = _run_node_json(
- "\n".join(
- [
- 'const CONSOLE_UI_STORAGE_KEY = "agent-lab-console-ui-v1";',
- """
- function makeElement({ expanded = "true" } = {}) {
- const listeners = {};
- const classes = new Set();
- return {
- hidden: false,
- textContent: "Hide Debug",
- attributes: { "aria-expanded": expanded },
- classList: {
- toggle(name, force) {
- if (force) classes.add(name); else classes.delete(name);
- },
- contains(name) { return classes.has(name); },
- },
- addEventListener(type, listener) { listeners[type] = listener; },
- click() { listeners.click(); },
- getAttribute(name) { return this.attributes[name]; },
- setAttribute(name, value) { this.attributes[name] = String(value); },
- };
- }
- const drawerButton = makeElement();
- const drawer = makeElement();
- const layout = makeElement();
- const elements = {
- "#toggle-debug-drawer": drawerButton,
- "#debug-drawer": drawer,
- "#console-layout": layout,
- };
- const document = {
- querySelector(selector) { return elements[selector] || null; },
- querySelectorAll() { return []; },
- };
- let stored = JSON.stringify({ sections: { "snapshot-controls": true } });
- globalThis.localStorage = {
- getItem() { return stored; },
- setItem(key, value) { stored = value; },
- };
- function snapshot() {
- return {
- closed: layout.classList.contains("debug-drawer-closed"),
- hidden: drawer.hidden,
- text: drawerButton.textContent,
- expanded: drawerButton.getAttribute("aria-expanded"),
- stored: JSON.parse(stored),
- };
- }
- """,
- console_ui,
- """
- initializeConsoleUi();
- drawerButton.click();
- const closed = snapshot();
- drawerButton.click();
- const opened = snapshot();
- console.log(JSON.stringify({ closed, opened }));
- """,
- ]
- )
- )
- assert result["closed"] == {
- "closed": True,
- "hidden": True,
- "text": "Show Debug",
- "expanded": "false",
- "stored": {
- "sections": {"snapshot-controls": True},
- "debug_drawer_open": False,
- },
- }
- assert result["opened"] == {
- "closed": False,
- "hidden": False,
- "text": "Hide Debug",
- "expanded": "true",
- "stored": {
- "sections": {"snapshot-controls": True},
- "debug_drawer_open": True,
- },
- }
- def test_static_console_ui_restores_valid_state_without_writing_on_initialize():
- js = Path("src/agent_lab/presentation/static/app.js").read_text()
- console_ui = _console_ui_source(js)
- result = _run_node_json(
- "\n".join(
- [
- 'const CONSOLE_UI_STORAGE_KEY = "agent-lab-console-ui-v1";',
- """
- function makeElement({ dataset = {}, expanded = "false", hidden = false } = {}) {
- const listeners = {};
- const classes = new Set();
- return {
- dataset,
- hidden,
- textContent: "",
- attributes: { "aria-expanded": expanded },
- classList: {
- toggle(name, force) {
- if (force) classes.add(name); else classes.delete(name);
- },
- contains(name) { return classes.has(name); },
- },
- addEventListener(type, listener) { listeners[type] = listener; },
- getAttribute(name) { return this.attributes[name]; },
- setAttribute(name, value) { this.attributes[name] = String(value); },
- };
- }
- const agentButton = makeElement({
- dataset: { collapseTarget: "agent-config-controls" },
- expanded: "false",
- });
- const snapshotButton = makeElement({
- dataset: { collapseTarget: "snapshot-controls" },
- expanded: "false",
- });
- const agentBody = makeElement({ hidden: true });
- const snapshotBody = makeElement({ hidden: true });
- const drawerButton = makeElement({ expanded: "true" });
- drawerButton.textContent = "Hide Debug";
- const drawer = makeElement();
- const layout = makeElement();
- const elements = {
- "#agent-config-controls": agentBody,
- "#snapshot-controls": snapshotBody,
- "#toggle-debug-drawer": drawerButton,
- "#debug-drawer": drawer,
- "#console-layout": layout,
- };
- const document = {
- querySelector(selector) { return elements[selector] || null; },
- querySelectorAll(selector) {
- return selector === ".control-section-toggle"
- ? [agentButton, snapshotButton]
- : [];
- },
- };
- let writes = 0;
- globalThis.localStorage = {
- getItem() {
- return JSON.stringify({
- debug_drawer_open: false,
- sections: { "agent-config-controls": true },
- });
- },
- setItem() { writes += 1; },
- };
- """,
- console_ui,
- """
- initializeConsoleUi();
- console.log(JSON.stringify({
- agentExpanded: agentButton.getAttribute("aria-expanded"),
- agentHidden: agentBody.hidden,
- snapshotExpanded: snapshotButton.getAttribute("aria-expanded"),
- snapshotHidden: snapshotBody.hidden,
- drawerExpanded: drawerButton.getAttribute("aria-expanded"),
- drawerHidden: drawer.hidden,
- drawerClosed: layout.classList.contains("debug-drawer-closed"),
- writes,
- }));
- """,
- ]
- )
- )
- assert result == {
- "agentExpanded": "true",
- "agentHidden": False,
- "snapshotExpanded": "false",
- "snapshotHidden": True,
- "drawerExpanded": "false",
- "drawerHidden": True,
- "drawerClosed": True,
- "writes": 0,
- }
- def test_static_console_ui_storage_failures_fall_back_without_throwing():
- js = Path("src/agent_lab/presentation/static/app.js").read_text()
- console_ui = _console_ui_source(js)
- result = _run_node_json(
- "\n".join(
- [
- 'const CONSOLE_UI_STORAGE_KEY = "agent-lab-console-ui-v1";',
- """
- function makeElement({ dataset = {}, expanded = "false", hidden = false } = {}) {
- return {
- dataset,
- hidden,
- textContent: "",
- attributes: { "aria-expanded": expanded },
- classList: { toggle() {} },
- addEventListener() {},
- getAttribute(name) { return this.attributes[name]; },
- setAttribute(name, value) { this.attributes[name] = String(value); },
- };
- }
- const sectionButton = makeElement({
- dataset: { collapseTarget: "snapshot-controls" },
- expanded: "false",
- });
- const sectionBody = makeElement({ hidden: true });
- const drawerButton = makeElement({ expanded: "true" });
- const drawer = makeElement();
- const layout = makeElement();
- const elements = {
- "#snapshot-controls": sectionBody,
- "#toggle-debug-drawer": drawerButton,
- "#debug-drawer": drawer,
- "#console-layout": layout,
- };
- const document = {
- querySelector(selector) { return elements[selector] || null; },
- querySelectorAll(selector) {
- return selector === ".control-section-toggle" ? [sectionButton] : [];
- },
- };
- globalThis.localStorage = {
- getItem() { return "{malformed"; },
- setItem() { throw new Error("storage unavailable"); },
- };
- """,
- console_ui,
- """
- const malformed = readConsoleUiState();
- writeConsoleUiState({ debug_drawer_open: true });
- initializeConsoleUi();
- const malformedDefaults = {
- sectionExpanded: sectionButton.getAttribute("aria-expanded"),
- sectionHidden: sectionBody.hidden,
- drawerExpanded: drawerButton.getAttribute("aria-expanded"),
- drawerHidden: drawer.hidden,
- };
- Object.defineProperty(globalThis, "localStorage", {
- configurable: true,
- get() { throw new Error("storage unavailable"); },
- });
- const unavailable = readConsoleUiState();
- writeConsoleUiState({ sections: {} });
- initializeConsoleUi();
- console.log(JSON.stringify({ malformed, malformedDefaults, unavailable }));
- """,
- ]
- )
- )
- assert result == {
- "malformed": {},
- "malformedDefaults": {
- "sectionExpanded": "false",
- "sectionHidden": True,
- "drawerExpanded": "true",
- "drawerHidden": False,
- },
- "unavailable": {},
- }
- def test_static_topbar_status_updates_for_session_and_tool_mode():
- js = Path("src/agent_lab/presentation/static/app.js").read_text()
- console_ui = _console_ui_source(js)
- result = _run_node_json(
- "\n".join(
- [
- 'const CONSOLE_UI_STORAGE_KEY = "agent-lab-console-ui-v1";',
- """
- const sessionLabel = { textContent: "" };
- const modeLabel = { textContent: "" };
- const toolInvocationMode = { value: "dual_agent" };
- const document = {
- querySelector(selector) {
- return {
- "#current-session-label": sessionLabel,
- "#current-mode-label": modeLabel,
- }[selector] || null;
- },
- };
- let currentSessionId = null;
- """,
- console_ui,
- """
- updateTopbarStatus();
- const initial = { session: sessionLabel.textContent, mode: modeLabel.textContent };
- currentSessionId = "1234567890abcdef";
- toolInvocationMode.value = "chat_agent_tools";
- updateTopbarStatus();
- console.log(JSON.stringify({
- initial,
- updated: { session: sessionLabel.textContent, mode: modeLabel.textContent },
- }));
- """,
- ]
- )
- )
- assert result == {
- "initial": {"session": "No session", "mode": "Dual Agent"},
- "updated": {"session": "Session 12345678", "mode": "ChatAgent Tools"},
- }
- def test_static_console_ui_bootstrap_and_session_paths_refresh_topbar_status():
- js = Path("src/agent_lab/presentation/static/app.js").read_text()
- refresh_tool_mode = js[
- js.index("function refreshToolModeState(") :
- js.index("function updateToolModeHelp(")
- ]
- assert "initializeConsoleUi();" in js[: js.index("function openChatConfig(")]
- assert "updateTopbarStatus();" in refresh_tool_mode
- assert "currentSessionId = session.id;\n updateTopbarStatus();" in js
- assert "currentSessionId = sessionId;\n updateTopbarStatus();" in js
- assert "currentSessionId = message.session_id;\n updateTopbarStatus();" in js
- def test_static_presentation_state_is_excluded_from_request_and_snapshot():
- js = Path("src/agent_lab/presentation/static/app.js").read_text()
- build_snapshot = js[
- js.index("function buildWorkspaceSnapshot()") :
- js.index("function restoreWorkspaceSnapshot(")
- ]
- build_request = js[
- js.index("function buildRequest(") :
- js.index("function selectedTools(")
- ]
- for source in [build_snapshot, build_request]:
- assert "CONSOLE_UI_STORAGE_KEY" not in source
- assert "readConsoleUiState" not in source
- assert "writeConsoleUiState" not in source
- assert "debug_drawer_open" not in source
- def test_static_professional_console_defines_visual_and_responsive_contracts():
- html = Path("src/agent_lab/presentation/static/index.html").read_text()
- css = Path("src/agent_lab/presentation/static/styles.css").read_text()
- assert '/static/styles.css?v=20260714-professional-console' in html
- assert '/static/app.js?v=20260714-professional-console' in html
- for token in [
- "--canvas:",
- "--surface:",
- "--surface-subtle:",
- "--border:",
- "--text:",
- "--muted:",
- "--accent:",
- "--accent-strong:",
- "--danger:",
- "--radius:",
- "--shadow:",
- ]:
- assert token in _css_rule(css, ":root")
- assert "button-primary" in html
- assert "button-secondary" in html
- assert "button-danger" in html
- assert ".button-primary" in css
- assert ".button-secondary" in css
- assert ".button-danger" in css
- assert ".status-badge" in css
- assert ":focus-visible" in css
- assert ".tool-card:has(input:checked)" in css
- assert "display: none" in _css_rule(css, "[hidden]")
- assert "@media (max-width: 900px)" in css
- assert "\nsection + section {" not in css
- 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_event_prompt_refresh_uses_unified_tool_mode_state_path():
- js = Path("src/agent_lab/presentation/static/app.js").read_text()
- render_tools = js[
- js.index("function renderTools(") :
- js.index("function formatRequiredParameters(")
- ]
- set_all_tools = js[
- js.index("function setAllTools(") :
- js.index("function updateToolCount(")
- ]
- restore_snapshot = js[
- js.index("function restoreWorkspaceSnapshot(") :
- js.index("function updateToolModeHelp(")
- ]
- apply_selected_tools = js[
- js.index("function applySelectedTools(") :
- js.index("function initializePromptList(")
- ]
- initialize_prompt_list = js[
- js.index("function initializePromptList(") :
- js.index("function buildPromptItems(")
- ]
- refresh_tool_mode = js[
- js.index("function refreshToolModeState(") :
- js.index("function updateToolModeHelp(")
- ]
- remove_generated_prompts = js[
- js.index("function removeGeneratedEventPromptItems(") :
- js.index("function ensureEventPromptItem(")
- ]
- assert (
- 'toolInvocationMode.addEventListener("change", refreshToolModeState);'
- in js
- )
- assert "refreshToolModeState();" in render_tools
- assert "updateEventPromptItem(" not in render_tools
- assert "refreshToolModeState();" in set_all_tools
- assert "updateEventPromptItem(" not in set_all_tools
- assert "refreshToolModeState();" in restore_snapshot
- assert "refreshToolModeState();" in apply_selected_tools
- assert "refreshToolModeState();" in initialize_prompt_list
- assert "removeGeneratedEventPromptItems();" in refresh_tool_mode
- assert "if (!isChatAgentTools)" in refresh_tool_mode
- assert "updateEventPromptItem(availableTools);" in refresh_tool_mode
- assert 'querySelectorAll(".prompt-item")' in remove_generated_prompts
- assert "isGeneratedEventPromptItem(item)" in remove_generated_prompts
- assert "item.remove();" in remove_generated_prompts
- assert 'textarea.readOnly = promptItem.prompt_kind === "event_agent_system"' in js
- assert "let eventPromptInsertionIndex" in js
- capture_position = js[
- js.index("function captureEventPromptInsertionIndex(") :
- js.index("function removeGeneratedEventPromptItems(")
- ]
- ensure_event_prompt = js[
- js.index("function ensureEventPromptItem(") :
- js.index("function bindPromptItemControls(")
- ]
- restore_prompts = js[
- js.index("function restoreSystemPrompts(") :
- js.index("function restorePreMessages(")
- ]
- assert "return retainedPromptCount;" in capture_position
- assert "captureEventPromptInsertionIndex();" in refresh_tool_mode
- assert "capturedEventPromptIndex !== null" in refresh_tool_mode
- assert "eventPromptInsertionIndex = capturedEventPromptIndex;" in refresh_tool_mode
- assert "deriveEventPromptInsertionIndex(prompts);" in restore_prompts
- assert "eventPromptInsertionIndex = restoredEventPromptIndex;" in restore_prompts
- assert "const insertionTarget" in ensure_event_prompt
- assert "systemPrompts.insertBefore(item, insertionTarget);" in ensure_event_prompt
- assert "systemPrompts.append(item);" not in ensure_event_prompt
- def test_static_event_prompt_order_survives_refresh_and_tool_mode_roundtrip():
- js = Path("src/agent_lab/presentation/static/app.js").read_text()
- generated = "\n".join(
- [
- "You may request EventAgent work using this text protocol.",
- "Available events:",
- "- handoff_note: Handoff a note",
- "<agent_events>",
- "handoff_note",
- "</agent_events>",
- "Use exact event names only, one per line. Do not include parameters.",
- ]
- )
- assert "function captureEventPromptInsertionIndex(" in js
- refresh_tool_mode = js[
- js.index("function refreshToolModeState(") :
- js.index("function updateToolModeHelp(")
- ]
- generated_matcher = js[
- js.index("function isGeneratedEventPromptContent(") :
- js.index("function buildAvailableEventsPrompt(")
- ]
- event_prompt_functions = js[
- js.index("function updateEventPromptItem(") :
- js.index("function bindPromptItemControls(")
- ]
- result = _run_node_json(
- "\n".join(
- [
- refresh_tool_mode,
- generated_matcher,
- event_prompt_functions,
- """
- const generated = [
- "You may request EventAgent work using this text protocol.",
- "Available events:",
- "- handoff_note: Handoff a note",
- "<agent_events>",
- "handoff_note",
- "</agent_events>",
- "Use exact event names only, one per line. Do not include parameters.",
- ].join("\\n");
- const items = [];
- function makePromptItem(promptKind, content) {
- const item = {
- dataset: { promptKind, promptRole: "system" },
- content,
- querySelector() {
- return {
- get value() { return item.content; },
- set value(value) { item.content = value; },
- };
- },
- remove() {
- const index = items.indexOf(item);
- if (index >= 0) items.splice(index, 1);
- },
- };
- return item;
- }
- items.push(
- makePromptItem("custom", "alpha"),
- makePromptItem("event_agent_system", "stale generated prompt"),
- makePromptItem("custom", "beta"),
- makePromptItem("custom", generated),
- makePromptItem("custom", "gamma"),
- );
- const systemPrompts = {
- querySelectorAll() { return [...items]; },
- querySelector() {
- return items.find((item) => item.dataset.promptKind === "event_agent_system") || null;
- },
- insertBefore(item, target) {
- const currentIndex = items.indexOf(item);
- if (currentIndex >= 0) items.splice(currentIndex, 1);
- const targetIndex = target ? items.indexOf(target) : items.length;
- items.splice(targetIndex < 0 ? items.length : targetIndex, 0, item);
- },
- };
- const toolInvocationMode = { value: "dual_agent" };
- const eventAgentModelSettings = { querySelectorAll() { return []; } };
- const availableTools = [{ name: "handoff_note" }];
- let eventPromptInsertionIndex = 0;
- function updateTopbarStatus() {}
- function updateToolModeHelp() {}
- function selectedTools() { return ["handoff_note"]; }
- function buildAvailableEventsPrompt() { return generated; }
- function createSystemPrompt() {
- return makePromptItem("event_agent_system", "");
- }
- function snapshot() {
- return items.map((item) => [item.dataset.promptKind, item.content]);
- }
- refreshToolModeState();
- const afterRefresh = snapshot();
- toolInvocationMode.value = "chat_agent_tools";
- refreshToolModeState();
- refreshToolModeState();
- const whileHidden = snapshot();
- const hiddenIndex = eventPromptInsertionIndex;
- toolInvocationMode.value = "dual_agent";
- refreshToolModeState();
- const afterRoundtrip = snapshot();
- console.log(JSON.stringify({ afterRefresh, whileHidden, hiddenIndex, afterRoundtrip }));
- """,
- ]
- )
- )
- expected_visible = [
- ["custom", "alpha"],
- ["event_agent_system", generated],
- ["custom", "beta"],
- ["custom", "gamma"],
- ]
- assert result["afterRefresh"] == expected_visible
- assert result["whileHidden"] == [
- ["custom", "alpha"],
- ["custom", "beta"],
- ["custom", "gamma"],
- ]
- assert result["hiddenIndex"] == 1
- assert result["afterRoundtrip"] == expected_visible
- def test_static_prompt_restore_derives_first_generated_event_prompt_position():
- js = Path("src/agent_lab/presentation/static/app.js").read_text()
- assert "function deriveEventPromptInsertionIndex(" in js
- normalization = js[
- js.index("function normalizePromptItems(") :
- js.index("function buildAvailableEventsPrompt(")
- ]
- result = _run_node_json(
- "\n".join(
- [
- normalization,
- """
- const generated = [
- "You may request EventAgent work using this text protocol.",
- "Available events:",
- "<agent_events>",
- "handoff_note",
- "</agent_events>",
- "Use exact event names only, one per line. Do not include parameters.",
- ].join("\\n");
- const prompts = [
- { prompt_kind: "custom", content: "alpha" },
- generated,
- { prompt_kind: "custom", content: "beta" },
- { prompt_kind: "event_agent_system", content: generated },
- { prompt_kind: "custom", content: "gamma" },
- ];
- console.log(JSON.stringify({
- insertionIndex: deriveEventPromptInsertionIndex(prompts),
- normalized: normalizePromptItems(prompts),
- }));
- """,
- ]
- )
- )
- assert result["insertionIndex"] == 1
- assert result["normalized"] == [
- {"prompt_kind": "custom", "role": "system", "content": "alpha"},
- {"prompt_kind": "custom", "role": "system", "content": "beta"},
- {"prompt_kind": "custom", "role": "system", "content": "gamma"},
- ]
- def test_static_prompt_restore_discards_generated_event_prompt_derived_state():
- js = Path("src/agent_lab/presentation/static/app.js").read_text()
- ordinary = "Keep this custom prompt with an <agent_events> example."
- exact_name_docs = (
- "Use exact event names only, one per line. Do not include parameters."
- )
- assert "function isGeneratedEventPromptContent(" in js
- normalization = js[
- js.index("function normalizePromptItems(") :
- js.index("function buildAvailableEventsPrompt(")
- ]
- result = _run_node_json(
- "\n".join(
- [
- normalization,
- """
- const generated = [
- "You may request EventAgent work using this text protocol.",
- "Available events:",
- "- handoff_note: Handoff a note",
- "First write the user-facing reply normally. If events are needed, append this block after the visible reply:",
- "<agent_events>",
- "handoff_note",
- "</agent_events>",
- "Use exact event names only, one per line. Do not include parameters.",
- ].join("\\n");
- const ordinary = "Keep this custom prompt with an <agent_events> example.";
- const exactNameDocs = "Use exact event names only, one per line. Do not include parameters.";
- console.log(JSON.stringify({
- matches: [
- isGeneratedEventPromptContent(generated),
- isGeneratedEventPromptContent(ordinary),
- isGeneratedEventPromptContent(exactNameDocs),
- ],
- normalized: normalizePromptItems([
- { prompt_kind: "event_agent_system", role: "system", content: generated },
- generated,
- ordinary,
- { prompt_kind: "custom", role: "system", content: exactNameDocs },
- ]),
- empty: normalizePromptItems([]),
- }));
- """,
- ]
- )
- )
- assert result["matches"] == [True, False, False]
- assert result["normalized"] == [
- {"prompt_kind": "custom", "role": "system", "content": ordinary},
- {"prompt_kind": "custom", "role": "system", "content": exact_name_docs},
- ]
- assert result["empty"] == [
- {"prompt_kind": "custom", "role": "system", "content": ""}
- ]
- def test_static_event_prompt_is_excluded_from_chat_agent_tools_payload():
- js = Path("src/agent_lab/presentation/static/app.js").read_text()
- generated = "\n".join(
- [
- "You may request EventAgent work using this text protocol.",
- "Available events:",
- "- handoff_note: Handoff a note",
- "<agent_events>",
- "handoff_note",
- "</agent_events>",
- "Use exact event names only, one per line. Do not include parameters.",
- ]
- )
- prompt_collection = js[
- js.index("function buildPromptItems(") :
- js.index("function normalizePromptItems(")
- ]
- generated_matcher = js[
- js.index("function isGeneratedEventPromptContent(") :
- js.index("function buildAvailableEventsPrompt(")
- ]
- result = _run_node_json(
- "\n".join(
- [
- prompt_collection,
- generated_matcher,
- """
- const generated = [
- "You may request EventAgent work using this text protocol.",
- "Available events:",
- "- handoff_note: Handoff a note",
- "<agent_events>",
- "handoff_note",
- "</agent_events>",
- "Use exact event names only, one per line. Do not include parameters.",
- ].join("\\n");
- function promptItem(promptKind, content) {
- return {
- dataset: { promptKind, promptRole: "system" },
- querySelector() {
- return { value: content };
- },
- };
- }
- const systemPrompts = {
- querySelectorAll() {
- return [
- promptItem("custom", "ordinary custom"),
- promptItem("event_agent_system", generated),
- promptItem("custom", generated),
- ];
- },
- };
- const toolInvocationMode = { value: "chat_agent_tools" };
- const chatAgentTools = collectSystemPrompts();
- toolInvocationMode.value = "dual_agent";
- const dualAgent = collectSystemPrompts();
- console.log(JSON.stringify({ chatAgentTools, dualAgent }));
- """,
- ]
- )
- )
- assert result["chatAgentTools"] == ["ordinary custom"]
- assert result["dualAgent"] == ["ordinary custom", generated, generated]
- def test_static_tool_mode_disables_only_event_agent_model_settings_group():
- html = Path("src/agent_lab/presentation/static/index.html").read_text()
- js = Path("src/agent_lab/presentation/static/app.js").read_text()
- model_settings_start = html.index('id="event-agent-model-settings"')
- shared_settings_start = html.index('id="max-event-loops"')
- model_settings_html = html[model_settings_start:shared_settings_start]
- refresh_tool_mode = js[
- js.index("function refreshToolModeState(") :
- js.index("function updateToolModeHelp(")
- ]
- for control_id in [
- "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 model_settings_html
- for control_id in [
- "max-event-loops",
- "max-parallel-events",
- "batch-timeout-seconds",
- ]:
- assert f'id="{control_id}"' not in model_settings_html
- assert 'document.querySelector("#event-agent-model-settings")' in js
- assert '.querySelectorAll("input, textarea, select, button")' in refresh_tool_mode
- assert "control.disabled = isChatAgentTools;" in refresh_tool_mode
- 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 "refreshToolModeState();" 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 "20260714-professional-console" 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()
- layout_rule = _css_rule(css, ".layout")
- closed_rule = _css_rule(css, ".layout.debug-drawer-closed")
- workspace_rule = _css_rule(css, ".workspace")
- assert "height: calc(100vh - 64px)" in layout_rule
- assert (
- "grid-template-columns: minmax(260px, 320px) minmax(0, 1fr) "
- "minmax(300px, 380px)"
- ) in layout_rule
- assert "overflow: hidden" in layout_rule
- assert "grid-template-columns: minmax(260px, 320px) minmax(0, 1fr) 0" in closed_rule
- assert "overflow: auto" in _css_rule(css, ".controls")
- assert "grid-template-rows: minmax(0, 1fr) auto auto" in workspace_rule
- assert "overflow: hidden" in _css_rule(css, ".workspace-body")
- assert "overflow: auto" in _css_rule(css, ".messages")
- assert "overflow: auto" in _css_rule(css, ".debug-drawer")
- responsive_css = css[css.index("@media (max-width: 900px)") :]
- assert "height: auto" in responsive_css
- assert "overflow-x: hidden" in responsive_css
- assert ".debug-drawer[hidden]" in responsive_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()
- 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 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 "session.turn_wall_time_ms ?? session.elapsed_ms" in js
- assert "turn.turn_wall_time_ms ?? turn.elapsed_ms" in js
- assert "call.mode" in js
- assert "call.agent" in js
- assert "call.call_kind" in js
- assert "call.used_fallback" in js
- assert "call.tool_latency_ms" in js
- assert "sessionUsageSummary" in js
- assert "Finish current turn before switching sessions" in js
- assert "await loadSessionReplay(session.id, { preserveStatus: true });" in create_session
- assert "await loadSessionReplay(sessionId);" in load_session
- def test_static_session_replay_ignores_superseded_success():
- js = Path("src/agent_lab/presentation/static/app.js").read_text()
- result = _run_load_session_replay_scenario(
- """
- currentSessionId = "old";
- const oldLoad = loadSessionReplay("old");
- currentSessionId = "new";
- const newLoad = loadSessionReplay("new");
- resolveReplay("new");
- await newLoad;
- resolveReplay("old");
- await oldLoad;
- console.log(JSON.stringify({
- ...mutations,
- title: sessionTitle.value,
- selected: sessionList.value,
- }));
- """
- )
- assert result == {
- "workspace": ["new"],
- "transcript": ["new"],
- "audit": ["new"],
- "usage": ["new"],
- "status": ["Session loaded"],
- "title": "new title",
- "selected": "new",
- }
- assert "let sessionReplayLoadToken = 0;" in js
- def test_static_session_replay_ignores_superseded_failure_status():
- result = _run_load_session_replay_scenario(
- """
- currentSessionId = "old";
- const oldLoad = loadSessionReplay("old");
- currentSessionId = "new";
- const newLoad = loadSessionReplay("new");
- resolveReplay("new");
- await newLoad;
- pending.get("/api/sessions/old").reject(new Error("stale failure"));
- await oldLoad;
- console.log(JSON.stringify(mutations.status));
- """
- )
- assert result == ["Session loaded"]
- def test_static_session_replay_rejects_stale_invocation_before_token_advance():
- result = _run_load_session_replay_scenario(
- """
- currentSessionId = "new";
- const newLoad = loadSessionReplay("new");
- const tokenAfterNew = sessionReplayLoadToken;
- const oldLoad = loadSessionReplay("old");
- const oldResult = await Promise.race([
- oldLoad.then(() => "returned"),
- new Promise((resolve) => setTimeout(() => resolve("pending"), 0)),
- ]);
- const tokenAfterOld = sessionReplayLoadToken;
- const oldFetchStarted = pending.has("/api/sessions/old");
- resolveReplay("new");
- await newLoad;
- console.log(JSON.stringify({
- oldResult,
- tokenAfterNew,
- tokenAfterOld,
- oldFetchStarted,
- mutations,
- title: sessionTitle.value,
- selected: sessionList.value,
- }));
- """
- )
- assert result == {
- "oldResult": "returned",
- "tokenAfterNew": 1,
- "tokenAfterOld": 1,
- "oldFetchStarted": False,
- "mutations": {
- "workspace": ["new"],
- "transcript": ["new"],
- "audit": ["new"],
- "usage": ["new"],
- "status": ["Session loaded"],
- },
- "title": "new title",
- "selected": "new",
- }
- def test_static_replay_hides_whitespace_assistant_tool_protocol_records():
- js = Path("src/agent_lab/presentation/static/app.js").read_text()
- render_replay = js[
- js.index("function renderReplayMessages(") :
- js.index("function appendAuditEntry(")
- ]
- result = _run_node_json(
- "\n".join(
- [
- """
- let activeAssistant = null;
- const messagesEl = {
- children: [],
- scrollHeight: 0,
- scrollTop: 0,
- set textContent(value) {
- if (value === "") {
- this.children = [];
- }
- },
- append(item) {
- this.children.push(item);
- },
- };
- const document = {
- createElement() {
- return { className: "", textContent: "" };
- },
- };
- function appendLog(kind, content) {
- messagesEl.append({ className: `message ${kind}`, textContent: content });
- }
- """,
- render_replay,
- """
- renderReplayMessages([
- { role: "assistant", content: " \\n\\t", tool_calls: [{ id: "call-1" }] },
- { role: "assistant", content: "visible", tool_calls: [] },
- ]);
- console.log(JSON.stringify(messagesEl.children.map((item) => item.textContent)));
- """,
- ]
- )
- )
- assert result == ["visible"]
- 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
|