diff --git a/backend/apps/agents/agents.py b/backend/apps/agents/agents.py index a4e0b831..de1e4e1b 100644 --- a/backend/apps/agents/agents.py +++ b/backend/apps/agents/agents.py @@ -276,6 +276,12 @@ async def generate_group_meta(session_id: str, body: dict): tool_calls = body.get("tool_calls", []) if not group_id or not tool_calls: raise HTTPException(status_code=400, detail="group_id and tool_calls are required") + # Same disk fallback as GET /sessions/{id}: after a backend restart every settled chat is on disk, not in memory, and this used to 500 on the first tool group the renderer labelled. + if agent_manager.get_session(session_id) is None: + try: + await agent_manager.resume_session(session_id) + except ValueError: + raise HTTPException(status_code=404, detail="Session not found") # Dedup: share an in-flight Future across callers; refinement requests bypass since they may want fresh results. is_refinement = body.get("is_refinement", False) diff --git a/backend/tests/test_group_meta_lazy_load.py b/backend/tests/test_group_meta_lazy_load.py new file mode 100644 index 00000000..8110e592 --- /dev/null +++ b/backend/tests/test_group_meta_lazy_load.py @@ -0,0 +1,59 @@ +"""generate-group-meta must find a chat that only lives on disk, the way GET /sessions/{id} does. + +Seen in Eric's production console 2026-09-01: after a backend restart the renderer labelled a tool +group in an open chat, the route looked only in memory, `metadata.generate_group_meta` raised +ValueError, and the 500 surfaced as a CORS error (starlette's error middleware sits outside the CORS +middleware, so a raw 500 carries no CORS headers).""" +import asyncio +import pytest +from fastapi import HTTPException +import backend.apps.agents.agents as agents_mod + + +def p_run(coro): + return asyncio.run(coro) + + +def test_a_chat_that_is_only_on_disk_is_loaded_then_labelled(monkeypatch) -> None: + calls: list = [] + monkeypatch.setattr(agents_mod.agent_manager, "get_session", lambda sid: None) + + async def fake_resume(sid): + calls.append(("resume", sid)) + return object() + + async def fake_generate(sid, group_id, tool_calls, results_summary=None, is_refinement=False): + calls.append(("generate", sid, group_id)) + return {"name": "Reads", "icon": ""} + + monkeypatch.setattr(agents_mod.agent_manager, "resume_session", fake_resume) + monkeypatch.setattr(agents_mod.agent_manager, "generate_group_meta", fake_generate) + out = p_run(agents_mod.generate_group_meta("abc123", {"group_id": "g1", "tool_calls": [{"tool": "Read"}]})) + assert out["name"] == "Reads" + assert calls == [("resume", "abc123"), ("generate", "abc123", "g1")] + + +def test_a_chat_that_exists_nowhere_is_an_honest_404_not_a_500(monkeypatch) -> None: + monkeypatch.setattr(agents_mod.agent_manager, "get_session", lambda sid: None) + + async def fake_resume(sid): + raise ValueError(f"Session {sid} not found") + + monkeypatch.setattr(agents_mod.agent_manager, "resume_session", fake_resume) + with pytest.raises(HTTPException) as exc: + p_run(agents_mod.generate_group_meta("nope", {"group_id": "g1", "tool_calls": [{"tool": "Read"}]})) + assert exc.value.status_code == 404 + + +def test_an_in_memory_chat_never_touches_the_disk(monkeypatch) -> None: + monkeypatch.setattr(agents_mod.agent_manager, "get_session", lambda sid: object()) + + async def fake_resume(sid): + raise AssertionError("resume_session must not run for a chat already in memory") + + async def fake_generate(sid, group_id, tool_calls, results_summary=None, is_refinement=False): + return {"name": "ok"} + + monkeypatch.setattr(agents_mod.agent_manager, "resume_session", fake_resume) + monkeypatch.setattr(agents_mod.agent_manager, "generate_group_meta", fake_generate) + assert p_run(agents_mod.generate_group_meta("mem", {"group_id": "g", "tool_calls": [{"tool": "Read"}]}))["name"] == "ok" diff --git a/frontend/public/index.html b/frontend/public/index.html index a651cecf..82db7714 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -23,7 +23,7 @@ font-src 'self' data: file: https://fonts.gstatic.com; img-src 'self' data: blob: file: http: https:; media-src 'self' data: blob: http: https:; - connect-src 'self' file: http://localhost:* http://127.0.0.1:* ws://localhost:* ws://127.0.0.1:* https://api.openswarm.com https://*.openswarm.com https://openswarm.com https://api.github.com; + connect-src 'self' data: file: http://localhost:* http://127.0.0.1:* ws://localhost:* ws://127.0.0.1:* https://api.openswarm.com https://*.openswarm.com https://openswarm.com https://api.github.com; frame-src 'self' file: http: https: http://localhost:* http://127.0.0.1:*; worker-src 'self' blob:; object-src 'none'; diff --git a/frontend/src/app/pages/AgentChat/tool-ui/WidgetCopyChip.tsx b/frontend/src/app/pages/AgentChat/tool-ui/WidgetCopyChip.tsx index be35623d..069b5540 100644 --- a/frontend/src/app/pages/AgentChat/tool-ui/WidgetCopyChip.tsx +++ b/frontend/src/app/pages/AgentChat/tool-ui/WidgetCopyChip.tsx @@ -55,7 +55,8 @@ const WidgetCopyChip: React.FC = ({ component, props, conta const node = containerRef.current; if (node) { // Visuals (chart, stats, map, image...) copy as a real image; 2x for retina-crisp pastes. - const dataUrl = await toPng(node as HTMLElement, { pixelRatio: 2 }); + // skipFonts: the Google Fonts stylesheets are CSP-blocked for fetch, so embedding them only logs errors and changes nothing. + const dataUrl = await toPng(node as HTMLElement, { pixelRatio: 2, skipFonts: true }); const blob = await (await fetch(dataUrl)).blob(); await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]); flashCopied(); diff --git a/frontend/src/shared/browserCommandHandler.ts b/frontend/src/shared/browserCommandHandler.ts index ab42a827..02913e62 100644 --- a/frontend/src/shared/browserCommandHandler.ts +++ b/frontend/src/shared/browserCommandHandler.ts @@ -1202,7 +1202,7 @@ async function enumerateCandidates(wv: BrowserWebview): Promise { await walkSession(child.sessionId, _AX_CHILD_TIMEOUT_MS, 'child frame'); } if (framesDropped > 0) { - console.log(`[cdp] enumerateCandidates capped at ${_MAX_TOTAL_FRAMES} frames; dropped ${framesDropped}`); + console.debug(`[cdp] enumerateCandidates capped at ${_MAX_TOTAL_FRAMES} frames; dropped ${framesDropped}`); } return candidates; }