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;
}