Merge eric/v2 into arnav/tests

Brings in service layer refactor, frontend updates, session state additions,
telemetry unification, and UI hook refinements. Resolved .gitignore conflict
by combining coverage report entries (arnav/tests) with pyc/cache/editor
noise entries (eric/v2).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Arnav Naval
2026-05-05 20:29:02 -05:00
co-authored by Cursor
96 changed files with 8320 additions and 5534 deletions
+27
View File
@@ -30,12 +30,39 @@ backend/.venv/
.account-factory
openswarm-cloud
.openswarm-cloud
# Top-level only — Haik's PostHog dashboard webapp clone. Anchored with
# leading slash so this doesn't accidentally ignore backend/apps/analytics/.
/analytics
.claude/
# Local-only operator helpers (never commit)
scripts/set-fly-*.sh
# Python bytecode (regenerates on every import)
__pycache__/
*.pyc
*.pyo
# Test/lint caches (all auto-rebuild on next run)
.pytest_cache/
.mypy_cache/
.ruff_cache/
# Coverage reports (generated by scripts/test.sh and CI)
.coverage
.coverage.*
backend/coverage_html/
backend/coverage.xml
htmlcov/
# Editor noise — per-developer, never useful in repo
.idea/
.vscode/
*.swp
*~
.envrc
# OS detritus
Thumbs.db
ehthumbs.db
desktop.ini
frontend/tsconfig.tsbuildinfo
-428
View File
@@ -1,428 +0,0 @@
"""Owned agent loop — replaces claude_agent_sdk's query() function.
Generalizes the pattern from browser_agent.py (lines 243-334) into a
provider-agnostic, streaming, HITL-aware tool-use loop.
"""
from __future__ import annotations
import json
import logging
import time
from typing import Any, Callable, Awaitable
from uuid import uuid4
from backend.apps.agents.providers.base import (
BaseProvider, ContentBlock, ModelResponse, ProviderMessage,
StreamEvent, ToolCall, ToolSchema,
)
logger = logging.getLogger(__name__)
# Type aliases for callbacks
ToolExecutor = Callable[[str, dict], Awaitable[list[dict]]]
# hitl_handler(tool_name, tool_input) -> (approved, updated_input_or_None)
HITLHandler = Callable[[str, dict], Awaitable[tuple[bool, dict | None]]]
# ws_emitter(event_type, data) -> None
WSEmitter = Callable[[str, dict], Awaitable[None]]
class AgentLoop:
"""Provider-agnostic agent loop with streaming and HITL support.
The loop:
1. Sends user message to the model
2. Streams the response (emitting WebSocket events)
3. If the model requests tool use:
a. For each tool call: check HITL permission → execute → collect result
b. Append tool results → go to step 2
4. If the model stops (end_turn/max_tokens): done
"""
def __init__(
self,
session_id: str,
provider: BaseProvider,
model: str,
system_prompt: str | None,
tools: list[ToolSchema],
tool_executor: ToolExecutor,
hitl_handler: HITLHandler,
ws_emitter: WSEmitter,
max_turns: int | None = None,
cwd: str | None = None,
):
self.session_id = session_id
self.provider = provider
self.model = model
self.system_prompt = system_prompt
self.tools = tools
self.tool_executor = tool_executor
self.hitl_handler = hitl_handler
self.ws_emitter = ws_emitter
self.max_turns = max_turns
self.cwd = cwd
# Conversation history in provider-agnostic format
self.messages: list[ProviderMessage] = []
# Token tracking
self.total_input_tokens = 0
self.total_output_tokens = 0
async def run(self, user_content: Any) -> None:
"""Run the agent loop for a single user turn."""
# Append user message
user_msg = self.provider.format_user_message(user_content)
self.messages.append(user_msg)
turn = 0
while True:
if self.max_turns and turn >= self.max_turns:
logger.info(f"Agent {self.session_id}: max turns ({self.max_turns}) reached")
break
turn += 1
# Stream the model response and collect it
response = await self._stream_and_collect()
# Track usage
self.total_input_tokens += response.usage.get("input_tokens", 0)
self.total_output_tokens += response.usage.get("output_tokens", 0)
# Append assistant message to conversation history
assistant_msg = self.provider.format_assistant_message(response)
self.messages.append(assistant_msg)
# If no tool use, we're done
if response.stop_reason != "tool_use":
break
# Execute tools
tool_results = await self._execute_tools(response)
if not tool_results:
break
# Append tool results
self.messages.append(ProviderMessage(role="tool_result", content=tool_results))
async def _stream_and_collect(self) -> ModelResponse:
"""Stream model output, emit WebSocket events, collect full response."""
collected_content: list[ContentBlock] = []
collected_usage: dict[str, int] = {}
stop_reason = "end_turn"
# Track streaming state for WS emissions
stream_text_msg_id: str | None = None
stream_tool_msg_ids: dict[int, str] = {} # block index -> msg_id
block_index_map: dict[int, str] = {} # block index -> msg_id
# Buffers for collecting content
text_buffers: dict[int, str] = {}
json_buffers: dict[int, str] = {}
tool_names: dict[int, str] = {}
tool_ids: dict[int, str] = {}
block_types: dict[int, str] = {}
# Wall-clock start time per content block (server-side stamps).
# Used to compute elapsed_ms for thinking blocks so the persisted
# ThinkingBubble can show the duration after streaming ends.
block_start_ts: dict[int, float] = {}
thinking_total_ms: int = 0
thinking_total_chars: int = 0
async for event in self.provider.stream_message(
model=self.model,
system=self.system_prompt,
messages=self.messages,
tools=self.tools,
):
if event.type == "content_block_start":
if event.block_type == "text":
if stream_text_msg_id is None:
stream_text_msg_id = uuid4().hex
await self.ws_emitter("agent:stream_start", {
"message_id": stream_text_msg_id,
"role": "assistant",
})
block_index_map[event.index] = stream_text_msg_id
block_types[event.index] = "text"
text_buffers[event.index] = ""
elif event.block_type == "tool_use":
tool_msg_id = uuid4().hex
stream_tool_msg_ids[event.index] = tool_msg_id
block_index_map[event.index] = tool_msg_id
block_types[event.index] = "tool_use"
tool_names[event.index] = event.tool_name
tool_ids[event.index] = event.tool_id
json_buffers[event.index] = ""
await self.ws_emitter("agent:stream_start", {
"message_id": tool_msg_id,
"role": "tool_call",
"tool_name": event.tool_name,
})
elif event.block_type == "thinking":
# Extended-thinking content block. Emit a distinct
# WS stream with role="thinking" so the frontend
# renders the live ThinkingBubble pill (rising
# token counter, auto-collapse on first text). Each
# thinking block gets its own message id — multiple
# interleaved thinking/text blocks remain
# individually addressable.
thinking_msg_id = uuid4().hex
block_index_map[event.index] = thinking_msg_id
block_types[event.index] = "thinking"
text_buffers[event.index] = ""
# Server-stamp the start so we can compute exact
# elapsed_ms server-side at content_block_stop. Using
# time.time() (not monotonic) is fine here — we only
# subtract two values from the same clock.
block_start_ts[event.index] = time.time()
await self.ws_emitter("agent:stream_start", {
"message_id": thinking_msg_id,
"role": "thinking",
})
elif event.type == "content_block_delta":
msg_id = block_index_map.get(event.index)
if not msg_id:
continue
if event.delta_type == "text_delta":
text_buffers.setdefault(event.index, "")
text_buffers[event.index] += event.text
await self.ws_emitter("agent:stream_delta", {
"message_id": msg_id,
"delta": event.text,
})
elif event.delta_type == "input_json_delta":
json_buffers.setdefault(event.index, "")
json_buffers[event.index] += event.text
await self.ws_emitter("agent:stream_delta", {
"message_id": msg_id,
"delta": event.text,
})
elif event.delta_type == "thinking_delta":
# Reuse the text buffer for thinking — same shape
# (accumulated str), different sink.
text_buffers.setdefault(event.index, "")
text_buffers[event.index] += event.text
await self.ws_emitter("agent:stream_delta", {
"message_id": msg_id,
"delta": event.text,
})
elif event.type == "content_block_stop":
msg_id = block_index_map.get(event.index)
bt = block_types.get(event.index, "")
if bt == "text":
collected_content.append(
ContentBlock(type="text", text=text_buffers.get(event.index, ""))
)
elif bt == "tool_use":
try:
tool_input = json.loads(json_buffers.get(event.index, "{}"))
except json.JSONDecodeError:
tool_input = {}
collected_content.append(ContentBlock(
type="tool_use",
tool_call=ToolCall(
id=tool_ids.get(event.index, uuid4().hex),
name=tool_names.get(event.index, ""),
input=tool_input,
),
))
elif bt == "thinking":
thinking_text = text_buffers.get(event.index, "")
collected_content.append(
ContentBlock(type="thinking", text=thinking_text)
)
# Accumulate per-block duration + char count for the
# eventual persisted Message. We sum across multiple
# thinking blocks in the same turn so a complex
# interleaved (think → tool → think → answer) turn
# still reports total time spent reasoning.
start_ts = block_start_ts.get(event.index)
if start_ts is not None:
thinking_total_ms += int((time.time() - start_ts) * 1000)
thinking_total_chars += len(thinking_text)
# Send stream_end for tool + thinking blocks (text block
# ends at message_stop). Thinking ends here so the
# frontend can transition the pill from "live" to
# "Thought for Ns" the moment the model stops thinking,
# even if it then keeps streaming text.
if msg_id and (bt == "tool_use" or bt == "thinking"):
payload: dict[str, Any] = {"message_id": msg_id}
if bt == "thinking":
# Server-stamped truth so the persisted bubble
# doesn't fall back to "Thoughts" — and so the
# live bubble freezes on the exact server-side
# duration instead of the client's clock.
block_start = block_start_ts.get(event.index)
if block_start is not None:
block_elapsed = int((time.time() - block_start) * 1000)
payload["elapsed_ms"] = block_elapsed
# Token estimate for THIS block (chars/3.6 ≈
# Anthropic BPE for English prose). Matches the
# heuristic the live UI used so the freeze
# value doesn't visually jump.
block_text = text_buffers.get(event.index, "")
if block_text:
payload["tokens"] = max(1, round(len(block_text) / 3.6))
await self.ws_emitter("agent:stream_end", payload)
elif event.type == "usage":
# Accumulate token usage from provider stream
for k, v in event.usage.items():
collected_usage[k] = collected_usage.get(k, 0) + v
elif event.type == "message_stop":
# Check if any tool calls means stop_reason is tool_use
has_tool_use = any(b.type == "tool_use" for b in collected_content)
if has_tool_use:
stop_reason = "tool_use"
# End text stream
if stream_text_msg_id:
await self.ws_emitter("agent:stream_end", {
"message_id": stream_text_msg_id,
})
# Build and emit the collected messages
await self._emit_collected_messages(
collected_content, stream_text_msg_id, stream_tool_msg_ids,
thinking_elapsed_ms=thinking_total_ms,
thinking_total_chars=thinking_total_chars,
)
return ModelResponse(
content=collected_content,
stop_reason=stop_reason,
usage=collected_usage,
)
async def _emit_collected_messages(
self,
content: list[ContentBlock],
text_msg_id: str | None,
tool_msg_ids: dict[int, str],
thinking_elapsed_ms: int = 0,
thinking_total_chars: int = 0,
) -> None:
"""Emit finalized agent:message events for the collected response."""
from backend.apps.agents.models import Message
# Emit thinking blocks (extended thinking). Persisted as their own
# messages so a session reload still shows the reasoning trail.
# Multiple thinking blocks per turn are concatenated into a single
# persisted message — the streaming UI already showed each block
# individually, this is just for the historical record.
thinking_parts = [b.text for b in content if b.type == "thinking" and b.text]
if thinking_parts:
joined = "\n\n".join(thinking_parts)
# Stamp duration + token estimate so the persisted bubble can
# show "Thought for Ns · M tokens" on reload instead of the
# generic "Thoughts" fallback. Use the server-side accumulated
# times so multi-block turns aggregate correctly.
msg = Message(
role="thinking",
content=joined,
elapsed_ms=thinking_elapsed_ms or None,
tokens=max(1, round(thinking_total_chars / 3.6)) if thinking_total_chars else None,
)
await self.ws_emitter("agent:message", {
"message": msg.model_dump(mode="json"),
})
# Emit text message
text_parts = [b.text for b in content if b.type == "text" and b.text]
if text_parts:
msg = Message(
id=text_msg_id or uuid4().hex,
role="assistant",
content="\n".join(text_parts),
)
await self.ws_emitter("agent:message", {
"message": msg.model_dump(mode="json"),
})
# Emit tool call messages
tool_blocks = [b for b in content if b.type == "tool_use" and b.tool_call]
tool_id_list = sorted(tool_msg_ids.items(), key=lambda x: x[0])
for i, block in enumerate(tool_blocks):
tc = block.tool_call
msg_id = tool_id_list[i][1] if i < len(tool_id_list) else uuid4().hex
msg = Message(
id=msg_id,
role="tool_call",
content={
"id": tc.id,
"tool": tc.name,
"input": tc.input,
},
)
await self.ws_emitter("agent:message", {
"message": msg.model_dump(mode="json"),
})
async def _execute_tools(self, response: ModelResponse) -> list[dict]:
"""Execute all tool calls from a response, respecting HITL permissions.
Returns a list of tool result dicts formatted for the provider.
"""
from backend.apps.agents.models import Message
results = []
for block in response.content:
if block.type != "tool_use" or not block.tool_call:
continue
tc = block.tool_call
start_time = time.time()
# HITL permission check
approved, updated_input = await self.hitl_handler(tc.name, tc.input)
if not approved:
result_content = [{"type": "text", "text": "Tool use was denied by the user."}]
else:
tool_input = updated_input if updated_input else tc.input
try:
result_content = await self.tool_executor(tc.name, tool_input)
except Exception as e:
logger.warning(f"Tool execution error: {tc.name}: {e}")
result_content = [{"type": "text", "text": f"Error executing {tc.name}: {e}"}]
elapsed_ms = int((time.time() - start_time) * 1000)
# Emit tool result to frontend
result_text = ""
for block_item in result_content:
if isinstance(block_item, dict) and block_item.get("type") == "text":
result_text = block_item.get("text", "")
break
result_msg = Message(
role="tool_result",
content={
"text": result_text[:15000] if result_text else "Done.",
"tool_name": tc.name,
"elapsed_ms": elapsed_ms,
},
)
await self.ws_emitter("agent:message", {
"message": result_msg.model_dump(mode="json"),
})
# Format for provider
results.append(
self.provider.format_tool_result(tc.id, result_content)
)
return results
File diff suppressed because it is too large Load Diff
+9 -6
View File
@@ -287,8 +287,9 @@ async def subscriptions_poll(body: dict):
extra_data=body.get("extra_data"),
)
if result.get("success"):
from backend.apps.analytics.collector import record as _analytics
_analytics("subscription.connected", {"provider": provider})
from backend.apps.service.client import sync as _sync
from backend.apps.settings.settings import load_settings
_sync(load_settings().model_dump())
return result
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@@ -310,8 +311,9 @@ async def subscriptions_exchange(body: dict):
try:
result = await exchange_oauth(provider, code, redirect_uri, code_verifier, state)
if result.get("success"):
from backend.apps.analytics.collector import record as _analytics
_analytics("subscription.connected", {"provider": provider})
from backend.apps.service.client import sync as _sync
from backend.apps.settings.settings import load_settings
_sync(load_settings().model_dump())
return result
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@@ -490,8 +492,9 @@ async def subscriptions_disconnect(body: dict):
if conn and conn.get("id"):
async with httpx.AsyncClient(timeout=10.0) as client:
await client.delete(f"{NINE_ROUTER_API}/providers/{conn['id']}")
from backend.apps.analytics.collector import record as _analytics
_analytics("subscription.disconnected", {"provider": provider})
from backend.apps.service.client import sync as _sync
from backend.apps.settings.settings import load_settings
_sync(load_settings().model_dump())
return {"ok": True}
return {"ok": False, "error": "Connection not found"}
except Exception as e:
+27 -7
View File
@@ -761,6 +761,23 @@ async def execute_browser_tool(
return result
def _extract_domain(url: str) -> str | None:
"""Extract the apex domain from a URL (acme-corp.notion.so → notion.so).
Returns None for non-http URLs."""
try:
from urllib.parse import urlparse
parsed = urlparse(url)
host = parsed.hostname or ""
if not host or host in ("localhost", "127.0.0.1", ""):
return None
parts = host.split(".")
if len(parts) >= 2:
return ".".join(parts[-2:])
return host
except Exception:
return None
def _format_tool_result(result: dict, tool_name: str) -> list[dict]:
"""Convert a browser command result dict into Anthropic API content blocks."""
if "error" in result:
@@ -1238,6 +1255,14 @@ async def run_browser_agent(
recent_tool_calls = recent_tool_calls[-_LOOP_WINDOW_SIZE * 2:]
content_blocks = _format_tool_result(result, tu.name)
try:
url = result.get("url") or (tu.input or {}).get("url")
if url:
domain = _extract_domain(str(url))
if domain and domain not in session.browser_domains:
session.browser_domains.append(domain)
except Exception:
pass
if is_loop:
loop_trigger_count += 1
repeat_count = sum(1 for c in recent_tool_calls if c == call_key)
@@ -1321,7 +1346,7 @@ async def run_browser_agent(
)
session.status = "completed"
agent_manager._fire_session_completed(session)
agent_manager._sync_session_close(session)
await ws_manager.send_to_session(session_id, "agent:status", {
"session_id": session_id,
"status": "completed",
@@ -1404,12 +1429,7 @@ async def run_browser_agents(
Each task dict has: { browser_id (optional), task, url (optional) }
Returns a list of result dicts, one per task.
"""
from backend.apps.analytics.collector import record as _analytics
_analytics("feature.used", {
"feature": "browser_agent.launched",
"task_count": len(tasks),
"model": model,
}, dashboard_id=dashboard_id)
pass # Browser agent launch captured via session dump
pre_selected = set(pre_selected_browser_ids or [])
-398
View File
@@ -1,398 +0,0 @@
#!/usr/bin/env python3
"""
Minimal stdio MCP server that exposes browser interaction tools.
Launched as a subprocess by the Claude Agent SDK. Proxies tool calls
to the OpenSwarm backend via HTTP, which bridges them to the Electron
frontend via WebSocket where the actual webview lives.
"""
import base64
import json
import sys
import os
import urllib.request
import urllib.error
from io import BytesIO
try:
from PIL import Image
HAS_PIL = True
except ImportError:
HAS_PIL = False
BACKEND_PORT = os.environ.get("OPENSWARM_PORT", "8324")
BACKEND_URL = f"http://127.0.0.1:{BACKEND_PORT}/api/browser/command"
TAB_ID_PROP = {
"type": "string",
"description": "Optional tab ID within the browser card. If omitted, targets the active tab.",
}
TOOLS = [
{
"name": "BrowserScreenshot",
"description": (
"Capture a screenshot of the browser page. Returns the screenshot as a "
"base64-encoded PNG image. Use this to see what is currently displayed."
),
"inputSchema": {
"type": "object",
"properties": {
"browser_id": {
"type": "string",
"description": "The browser card ID to capture. Use the ID from the selected browser card context.",
},
"tab_id": TAB_ID_PROP,
},
"required": ["browser_id"],
},
},
{
"name": "BrowserGetText",
"description": (
"Get the visible text content of the browser page. Returns the page's "
"innerText (up to 15000 characters)."
),
"inputSchema": {
"type": "object",
"properties": {
"browser_id": {
"type": "string",
"description": "The browser card ID.",
},
"tab_id": TAB_ID_PROP,
},
"required": ["browser_id"],
},
},
{
"name": "BrowserNavigate",
"description": "Navigate the browser to a URL.",
"inputSchema": {
"type": "object",
"properties": {
"browser_id": {
"type": "string",
"description": "The browser card ID.",
},
"tab_id": TAB_ID_PROP,
"url": {
"type": "string",
"description": "The URL to navigate to.",
},
},
"required": ["browser_id", "url"],
},
},
{
"name": "BrowserClick",
"description": (
"Click an element in the browser page identified by a CSS selector."
),
"inputSchema": {
"type": "object",
"properties": {
"browser_id": {
"type": "string",
"description": "The browser card ID.",
},
"tab_id": TAB_ID_PROP,
"selector": {
"type": "string",
"description": "CSS selector of the element to click.",
},
},
"required": ["browser_id", "selector"],
},
},
{
"name": "BrowserType",
"description": (
"Type text into an input element in the browser page. Clears the "
"existing value first, then types the new text."
),
"inputSchema": {
"type": "object",
"properties": {
"browser_id": {
"type": "string",
"description": "The browser card ID.",
},
"tab_id": TAB_ID_PROP,
"selector": {
"type": "string",
"description": "CSS selector of the input element.",
},
"text": {
"type": "string",
"description": "The text to type.",
},
},
"required": ["browser_id", "selector", "text"],
},
},
{
"name": "BrowserEvaluate",
"description": (
"Evaluate a JavaScript expression in the browser page and return the result. "
"The expression is run via executeJavaScript on the webview."
),
"inputSchema": {
"type": "object",
"properties": {
"browser_id": {
"type": "string",
"description": "The browser card ID.",
},
"tab_id": TAB_ID_PROP,
"expression": {
"type": "string",
"description": "JavaScript expression to evaluate.",
},
},
"required": ["browser_id", "expression"],
},
},
{
"name": "BrowserGetElements",
"description": (
"Get a list of interactive elements on the page with their CSS selectors. "
"Returns clickable elements, inputs, links, and buttons with selector paths "
"you can use with BrowserClick and BrowserType. Call this BEFORE attempting "
"to click or type so you know which selectors are valid."
),
"inputSchema": {
"type": "object",
"properties": {
"browser_id": {
"type": "string",
"description": "The browser card ID.",
},
"tab_id": TAB_ID_PROP,
"selector": {
"type": "string",
"description": (
"Optional CSS selector to scope the search "
"(e.g. 'form', '#main'). Defaults to 'body'."
),
},
},
"required": ["browser_id"],
},
},
{
"name": "BrowserScroll",
"description": (
"Scroll the page up or down. Automatically finds the correct scrollable "
"container (works on SPAs like Notion, Gmail, etc. that use nested scroll "
"containers instead of window-level scrolling). Returns scroll position info "
"including whether top/bottom has been reached."
),
"inputSchema": {
"type": "object",
"properties": {
"browser_id": {
"type": "string",
"description": "The browser card ID.",
},
"tab_id": TAB_ID_PROP,
"direction": {
"type": "string",
"enum": ["up", "down"],
"description": "Scroll direction. Defaults to 'down'.",
},
"amount": {
"type": "number",
"description": "Pixels to scroll. Defaults to 500.",
},
},
"required": ["browser_id"],
},
},
{
"name": "BrowserWait",
"description": (
"Wait for a specified duration. Useful after navigation or actions that "
"trigger page loads, animations, or async content rendering. "
"Min 100ms, max 10000ms."
),
"inputSchema": {
"type": "object",
"properties": {
"browser_id": {
"type": "string",
"description": "The browser card ID.",
},
"tab_id": TAB_ID_PROP,
"milliseconds": {
"type": "number",
"description": "Duration to wait in milliseconds. Defaults to 1000.",
},
},
"required": ["browser_id"],
},
},
]
def send_response(id_, result=None, error=None):
msg = {"jsonrpc": "2.0", "id": id_}
if error is not None:
msg["error"] = error
else:
msg["result"] = result
sys.stdout.write(json.dumps(msg) + "\n")
sys.stdout.flush()
def send_notification(method, params=None):
msg = {"jsonrpc": "2.0", "method": method}
if params is not None:
msg["params"] = params
sys.stdout.write(json.dumps(msg) + "\n")
sys.stdout.flush()
def call_backend(action: str, browser_id: str, params: dict | None = None, tab_id: str = "") -> dict:
payload = json.dumps({
"action": action,
"browser_id": browser_id,
"tab_id": tab_id,
"params": params or {},
}).encode()
req = urllib.request.Request(
BACKEND_URL,
data=payload,
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read().decode())
except urllib.error.HTTPError as e:
body = e.read().decode() if e.fp else str(e)
return {"error": f"HTTP {e.code}: {body}"}
except Exception as e:
return {"error": str(e)}
MAX_IMAGE_B64_BYTES = 400_000
def compress_screenshot(b64_png: str) -> tuple[str, str] | None:
"""Resize and re-encode as JPEG to stay under the stdio buffer limit."""
if not HAS_PIL:
return None
try:
raw = base64.b64decode(b64_png)
img = Image.open(BytesIO(raw))
max_width = 1024
if img.width > max_width:
ratio = max_width / img.width
img = img.resize((max_width, int(img.height * ratio)), Image.LANCZOS)
buf = BytesIO()
img.convert("RGB").save(buf, format="JPEG", quality=45)
return base64.b64encode(buf.getvalue()).decode(), "image/jpeg"
except Exception:
return None
def handle_tool_call(tool_name: str, arguments: dict) -> dict:
browser_id = arguments.get("browser_id", "")
tab_id = arguments.get("tab_id", "")
if not browser_id:
return {"content": [{"type": "text", "text": "Error: browser_id is required"}], "isError": True}
action_map = {
"BrowserScreenshot": "screenshot",
"BrowserGetText": "get_text",
"BrowserNavigate": "navigate",
"BrowserClick": "click",
"BrowserType": "type",
"BrowserEvaluate": "evaluate",
"BrowserGetElements": "get_elements",
"BrowserScroll": "scroll",
"BrowserWait": "wait",
}
action = action_map.get(tool_name)
if not action:
return {"content": [{"type": "text", "text": f"Unknown tool: {tool_name}"}], "isError": True}
params = {k: v for k, v in arguments.items() if k not in ("browser_id", "tab_id")}
result = call_backend(action, browser_id, params, tab_id=tab_id)
if "error" in result:
return {"content": [{"type": "text", "text": f"Error: {result['error']}"}], "isError": True}
if action == "screenshot" and result.get("image"):
image_data = result["image"]
mime_type = "image/png"
if len(image_data) > MAX_IMAGE_B64_BYTES:
compressed = compress_screenshot(image_data)
if compressed:
image_data, mime_type = compressed
if len(image_data) > MAX_IMAGE_B64_BYTES:
return {
"content": [
{"type": "text", "text": (
f"Screenshot too large to return ({len(image_data)} bytes base64). "
f"URL: {result.get('url', 'unknown')}. "
"Use BrowserGetText to read the page content instead."
)},
],
}
return {
"content": [
{"type": "image", "data": image_data, "mimeType": mime_type},
{"type": "text", "text": f"Screenshot captured. URL: {result.get('url', 'unknown')}"},
],
}
text = result.get("text", result.get("data", json.dumps(result)))
return {"content": [{"type": "text", "text": str(text)}]}
def main():
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
msg = json.loads(line)
except json.JSONDecodeError:
continue
method = msg.get("method")
id_ = msg.get("id")
params = msg.get("params", {})
if method == "initialize":
send_response(id_, {
"protocolVersion": "2024-11-05",
"capabilities": {"tools": {}},
"serverInfo": {
"name": "openswarm-browser",
"version": "1.0.0",
},
})
elif method == "notifications/initialized":
pass
elif method == "tools/list":
send_response(id_, {"tools": TOOLS})
elif method == "tools/call":
tool_name = params.get("name", "")
arguments = params.get("arguments", {})
result = handle_tool_call(tool_name, arguments)
send_response(id_, result)
elif method == "ping":
send_response(id_, {})
elif id_ is not None:
send_response(id_, error={"code": -32601, "message": f"Method not found: {method}"})
if __name__ == "__main__":
main()
-360
View File
@@ -1,360 +0,0 @@
"""Standalone MCP client manager for agent sessions.
Replaces claude_agent_sdk's internal MCP server management.
One MCPClientManager instance per agent session — manages connections
to stdio/http/sse MCP servers, discovers tools, and routes tool calls.
"""
from __future__ import annotations
import asyncio
import json
import logging
from contextlib import AsyncExitStack
from dataclasses import dataclass, field
from typing import Any
from backend.apps.agents.providers.base import ToolSchema
logger = logging.getLogger(__name__)
@dataclass
class MCPConnection:
"""A live connection to an MCP server."""
server_name: str
session: Any # mcp.ClientSession
tools: list[ToolSchema] = field(default_factory=list)
class MCPClientManager:
"""Manages connections to MCP servers for a single agent session."""
def __init__(self):
self._connections: dict[str, MCPConnection] = {}
self._exit_stack = AsyncExitStack()
self._started = False
async def __aenter__(self):
await self._exit_stack.__aenter__()
self._started = True
return self
async def __aexit__(self, *exc):
await self.disconnect_all()
try:
await self._exit_stack.__aexit__(*exc)
except (BaseExceptionGroup, ExceptionGroup, Exception) as e:
# MCP subprocess cleanup errors are non-fatal
logger.warning(f"MCP cleanup error (non-fatal): {e}")
self._started = False
async def connect(self, server_name: str, config: dict, timeout: float = 30.0) -> list[ToolSchema]:
"""Connect to an MCP server and return its available tools.
The tools are returned with names prefixed as mcp__<server_name>__<tool_name>.
"""
transport = config.get("type", "stdio")
try:
if transport == "stdio":
coro = self._connect_stdio(server_name, config)
elif transport == "sse":
coro = self._connect_sse(server_name, config)
elif transport == "http":
coro = self._connect_http(server_name, config)
else:
logger.warning(f"Unsupported MCP transport: {transport} for {server_name}")
return []
conn = await asyncio.wait_for(coro, timeout=timeout)
self._connections[server_name] = conn
logger.info(f"MCP connected: {server_name} ({len(conn.tools)} tools)")
return conn.tools
except asyncio.TimeoutError:
logger.warning(f"MCP server {server_name} connection timed out after {timeout}s")
return []
except Exception as e:
logger.warning(f"Failed to connect MCP server {server_name}: {e}")
return []
async def _connect_stdio(self, server_name: str, config: dict) -> MCPConnection:
"""Connect to a stdio MCP server (spawns a subprocess)."""
from mcp import ClientSession
from mcp.client.stdio import stdio_client, StdioServerParameters
command = config.get("command", "")
args = config.get("args", [])
env = config.get("env")
params = StdioServerParameters(
command=command,
args=args,
env=env,
)
transport = await self._exit_stack.enter_async_context(
stdio_client(params)
)
read_stream, write_stream = transport
session = await self._exit_stack.enter_async_context(
ClientSession(read_stream, write_stream)
)
await session.initialize()
result = await session.list_tools()
tools = [
ToolSchema(
name=f"mcp__{server_name}__{t.name}",
description=t.description or "",
input_schema=t.inputSchema if hasattr(t, "inputSchema") else (t.input_schema if hasattr(t, "input_schema") else {}),
)
for t in result.tools
]
return MCPConnection(server_name=server_name, session=session, tools=tools)
async def _connect_sse(self, server_name: str, config: dict) -> MCPConnection:
"""Connect to an SSE MCP server."""
from mcp import ClientSession
from mcp.client.sse import sse_client
url = config.get("url", "")
headers = config.get("headers")
transport = await self._exit_stack.enter_async_context(
sse_client(url=url, headers=headers, timeout=30, sse_read_timeout=300)
)
read_stream, write_stream = transport
session = await self._exit_stack.enter_async_context(
ClientSession(read_stream, write_stream)
)
await session.initialize()
result = await session.list_tools()
tools = [
ToolSchema(
name=f"mcp__{server_name}__{t.name}",
description=t.description or "",
input_schema=t.inputSchema if hasattr(t, "inputSchema") else (t.input_schema if hasattr(t, "input_schema") else {}),
)
for t in result.tools
]
return MCPConnection(server_name=server_name, session=session, tools=tools)
async def _connect_http(self, server_name: str, config: dict) -> MCPConnection:
"""Connect to a Streamable HTTP MCP server.
Falls back to SSE if streamable HTTP fails.
"""
url = config.get("url", "")
headers = config.get("headers")
# Try streamable HTTP first, fall back to SSE
try:
return await self._connect_http_streamable(server_name, url, headers)
except Exception as e:
logger.info(f"Streamable HTTP failed for {server_name}, trying SSE: {e}")
return await self._connect_sse(server_name, config)
async def _connect_http_streamable(
self, server_name: str, url: str, headers: dict | None,
) -> MCPConnection:
"""Connect via Streamable HTTP (JSON-RPC POST)."""
import httpx
from mcp import ClientSession
# Use httpx for streamable HTTP — keep client alive in the exit stack
client = await self._exit_stack.enter_async_context(
httpx.AsyncClient(timeout=30.0)
)
h = {
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
**(headers or {}),
}
# Initialize
init_resp = await client.post(url, headers=h, json={
"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {
"protocolVersion": "2025-03-26",
"capabilities": {},
"clientInfo": {"name": "self-swarm", "version": "0.1.0"},
},
})
if init_resp.status_code not in (200, 201):
raise ConnectionError(f"MCP initialize failed: {init_resp.status_code}")
session_id = init_resp.headers.get("mcp-session-id", "")
if session_id:
h["mcp-session-id"] = session_id
# Notify initialized
await client.post(url, headers=h, json={
"jsonrpc": "2.0", "method": "notifications/initialized",
})
# List tools
list_resp = await client.post(url, headers=h, json={
"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {},
})
if list_resp.status_code not in (200, 201):
raise ConnectionError(f"MCP tools/list failed: {list_resp.status_code}")
ct = list_resp.headers.get("content-type", "")
if "text/event-stream" in ct:
data = self._parse_sse_json(list_resp.text)
else:
data = list_resp.json()
if not data:
raise ConnectionError("Empty response from MCP server")
tools_list = data.get("result", {}).get("tools", [])
tools = [
ToolSchema(
name=f"mcp__{server_name}__{t.get('name', '')}",
description=t.get("description", ""),
input_schema=t.get("inputSchema", t.get("input_schema", {})),
)
for t in tools_list
]
# Store the HTTP client info for call_tool
conn = MCPConnection(server_name=server_name, session=None, tools=tools)
conn._http_client = client # type: ignore[attr-defined]
conn._http_url = url # type: ignore[attr-defined]
conn._http_headers = h # type: ignore[attr-defined]
conn._next_id = 3 # type: ignore[attr-defined]
return conn
@staticmethod
def _parse_sse_json(text: str) -> dict | None:
"""Extract JSON from an SSE response body."""
for line in text.splitlines():
stripped = line.strip()
if stripped.startswith("data:"):
payload = stripped[len("data:"):].strip()
if payload:
try:
return json.loads(payload)
except json.JSONDecodeError:
continue
try:
return json.loads(text)
except json.JSONDecodeError:
return None
async def call_tool(
self, server_name: str, tool_name: str, arguments: dict,
) -> list[dict]:
"""Call a tool on a specific MCP server.
Args:
server_name: The MCP server name (e.g. "google-workspace")
tool_name: The bare tool name (without mcp__prefix)
arguments: Tool input arguments
Returns:
List of content blocks: [{"type": "text", "text": "..."}]
"""
conn = self._connections.get(server_name)
if not conn:
return [{"type": "text", "text": f"MCP server {server_name} not connected"}]
try:
if conn.session is not None:
# stdio or SSE — use MCP ClientSession
result = await conn.session.call_tool(tool_name, arguments)
return self._format_mcp_result(result)
elif hasattr(conn, "_http_client"):
# Streamable HTTP — use JSON-RPC
return await self._call_tool_http(conn, tool_name, arguments)
else:
return [{"type": "text", "text": f"No session for MCP server {server_name}"}]
except Exception as e:
logger.warning(f"MCP tool call failed: {server_name}/{tool_name}: {e}")
return [{"type": "text", "text": f"Error calling {tool_name}: {e}"}]
async def _call_tool_http(
self, conn: MCPConnection, tool_name: str, arguments: dict,
) -> list[dict]:
"""Call a tool via Streamable HTTP."""
client = conn._http_client # type: ignore[attr-defined]
url = conn._http_url # type: ignore[attr-defined]
headers = conn._http_headers # type: ignore[attr-defined]
req_id = conn._next_id # type: ignore[attr-defined]
conn._next_id = req_id + 1 # type: ignore[attr-defined]
resp = await client.post(url, headers=headers, json={
"jsonrpc": "2.0",
"id": req_id,
"method": "tools/call",
"params": {"name": tool_name, "arguments": arguments},
}, timeout=300.0)
ct = resp.headers.get("content-type", "")
if "text/event-stream" in ct:
data = self._parse_sse_json(resp.text)
else:
data = resp.json()
if not data:
return [{"type": "text", "text": "Empty response from MCP server"}]
if "error" in data:
return [{"type": "text", "text": f"MCP error: {data['error']}"}]
result = data.get("result", {})
content = result.get("content", [])
return content if content else [{"type": "text", "text": json.dumps(result)}]
@staticmethod
def _format_mcp_result(result: Any) -> list[dict]:
"""Convert an MCP CallToolResult to content blocks."""
if hasattr(result, "content"):
blocks = []
for item in result.content:
if hasattr(item, "text"):
blocks.append({"type": "text", "text": item.text})
elif hasattr(item, "data"):
blocks.append({
"type": "image",
"source": {
"type": "base64",
"media_type": getattr(item, "mimeType", "image/png"),
"data": item.data,
},
})
else:
blocks.append({"type": "text", "text": str(item)})
return blocks if blocks else [{"type": "text", "text": "Done."}]
return [{"type": "text", "text": str(result)}]
def get_all_tool_schemas(self) -> list[ToolSchema]:
"""Return tool schemas from all connected MCP servers."""
schemas = []
for conn in self._connections.values():
schemas.extend(conn.tools)
return schemas
def parse_mcp_tool_name(self, full_name: str) -> tuple[str, str] | None:
"""Parse mcp__<server>__<tool> into (server_name, tool_name).
Returns None if the name doesn't match the MCP naming convention.
"""
import re
m = re.match(r"mcp__([^_]+(?:-[^_]+)*)__(.+)", full_name)
if m:
return m.group(1), m.group(2)
return None
async def disconnect_all(self):
"""Disconnect all MCP servers. Called on session end."""
self._connections.clear()
# The AsyncExitStack handles actual cleanup of transports/sessions
+2 -2
View File
@@ -25,7 +25,7 @@ import re
from typing import Any
from backend.apps.agents.providers.registry import resolve_aux_model
from backend.apps.settings.credentials import get_anthropic_client
from backend.apps.settings.credentials import get_anthropic_client_for_model
from backend.apps.settings.settings import load_settings
from backend.apps.tools_lib.tools_lib import _load_all as load_all_tools
@@ -243,7 +243,7 @@ def _decorate(llm_suggestion: dict, available: list[CuratedEntry]) -> dict | Non
async def _call_classifier(settings, prompt: str, available: list[CuratedEntry]) -> dict:
"""One aux-model call, returns validated JSON {is_vague, suggestions}."""
aux_model, _base = await resolve_aux_model(settings, preferred_tier="haiku")
client = get_anthropic_client(settings)
client = get_anthropic_client_for_model(settings, aux_model)
catalog_lines = "\n".join(
f"- id: {e['id']} | {e['title']}{e['description']}"
+44
View File
@@ -56,6 +56,10 @@ class Message(BaseModel):
# number frozen on the persisted bubble matches what the user saw
# rising during the stream. Pure display, not billing.
tokens: Optional[int] = None
# tool_count drives the "3 tools used" segment on the thinking pill.
tool_count: Optional[int] = None
# combined input + output + children tokens for the turn (overloaded name).
input_tokens: Optional[int] = None
class MessageBranch(BaseModel):
id: str = Field(default_factory=lambda: uuid4().hex)
@@ -81,10 +85,42 @@ class AgentSession(BaseModel):
allowed_tools: list[str] = Field(default_factory=list)
max_turns: Optional[int] = None
cwd: Optional[str] = None
# Origin remote and branch resolved at session start. Persisted so a
# resumed session reattaches to the same project even if the user has
# since `cd`'d elsewhere; also surfaced in the session list UI so the
# user can tell two sessions apart by repo.
repo_url: Optional[str] = None
branch: Optional[str] = None
created_at: datetime = Field(default_factory=datetime.now)
closed_at: Optional[datetime] = None
# Wall-clock of the first stream event from the agent SDK. Set once
# at the start of the first turn so resumed sessions can show "first
# response was at HH:MM" in the session list without rescanning the
# message log.
first_response_at: Optional[datetime] = None
# Operational log of HITL approval decisions, one entry per request:
# {tool, behavior, decision_ms}. Persisted alongside the session so a
# reload restores the full approval timeline (which calls were
# approved, denied, and how long each took).
approval_decisions: list[dict] = Field(default_factory=list)
cost_usd: float = 0.0
tokens: dict[str, int] = Field(default_factory=lambda: {"input": 0, "output": 0})
# Total wall-clock ms the agent spent in `status="running"`. Accumulates
# across turns; persists across resume. Used by the session-close
# report so we can report "agent active time" alongside total session
# duration. Off by default so legacy sessions deserialize cleanly.
agent_active_ms: int = 0
# Accumulated wall-clock ms spent on each model. Updated when the
# active model changes (model switch) or on close. Surfaced in the
# session header so the user can see "Sonnet: 45s · Haiku: 12s"
# without scanning turns by hand.
time_per_model: dict[str, int] = Field(default_factory=dict)
# Per-tool latency rollup: { tool_name: { count, total_ms, max_ms } }.
# Populated as tools complete. Surfaced in the session "tools used"
# row so the user can see which tool calls were slow without
# opening every turn.
tool_latencies: dict[str, dict] = Field(default_factory=dict)
browser_domains: list[str] = Field(default_factory=list)
messages: list[Message] = Field(default_factory=list)
pending_approvals: list[ApprovalRequest] = Field(default_factory=list)
branches: dict[str, "MessageBranch"] = Field(default_factory=lambda: {"main": MessageBranch(id="main")})
@@ -94,6 +130,14 @@ class AgentSession(BaseModel):
browser_id: Optional[str] = None
parent_session_id: Optional[str] = None
needs_fork: bool = False
# Stronger than needs_fork: when True, the next turn drops `resume=`
# entirely and replays history into a brand-new sdk_session_id. This
# is the only way to make the bundled CLI re-read mcp_servers from
# the rebuilt options dict — `fork_session=True` only forks the
# conversation tree, it inherits the original transport's MCP server
# set. Set after MCPActivate when prior turns exist so the newly
# activated server's tools actually reach the model.
needs_fresh_session: bool = False
# Set when MCPActivate (or analogous activation) wants the agent to
# auto-continue immediately after the current turn ends — without
# requiring the user to type another message. The agent loop reads
-290
View File
@@ -1,290 +0,0 @@
"""Anthropic provider adapter using the native Anthropic SDK."""
from __future__ import annotations
import json
import logging
from typing import Any, AsyncIterator
import anthropic
from backend.apps.agents.providers.base import (
BaseProvider, ContentBlock, ModelResponse, ProviderMessage,
StreamEvent, ToolCall, ToolSchema,
)
logger = logging.getLogger(__name__)
MODEL_MAP = {
"sonnet": "claude-sonnet-4-6",
"opus": "claude-opus-4-6",
"haiku": "claude-haiku-4-5",
}
class AnthropicProvider(BaseProvider):
"""Provider adapter for Anthropic's Messages API."""
def __init__(
self,
api_key: str | None = None,
auth_token: str | None = None,
base_url: str | None = None,
):
kwargs: dict[str, Any] = {}
if auth_token:
kwargs["auth_token"] = auth_token
elif api_key:
kwargs["api_key"] = api_key
if base_url:
kwargs["base_url"] = base_url
self.client = anthropic.AsyncAnthropic(**kwargs)
def get_model_id(self, short_name: str) -> str:
return MODEL_MAP.get(short_name, short_name)
def clean_tool_schema(self, schema: ToolSchema) -> dict:
return {
"name": schema.name,
"description": schema.description,
"input_schema": schema.input_schema,
}
def format_tool_result(self, tool_use_id: str, content: list[dict]) -> dict:
return {
"type": "tool_result",
"tool_use_id": tool_use_id,
"content": content,
}
def format_user_message(self, content: Any) -> ProviderMessage:
return ProviderMessage(role="user", content=content)
def format_assistant_message(self, response: ModelResponse) -> ProviderMessage:
blocks = []
for block in response.content:
if block.type == "text":
blocks.append({"type": "text", "text": block.text})
elif block.type == "tool_use" and block.tool_call:
blocks.append({
"type": "tool_use",
"id": block.tool_call.id,
"name": block.tool_call.name,
"input": block.tool_call.input,
})
return ProviderMessage(role="assistant", content=blocks)
def _build_messages(self, messages: list[ProviderMessage]) -> list[dict]:
"""Convert ProviderMessages to Anthropic API format."""
result = []
for msg in messages:
if msg.role == "tool_result":
# Tool results: content is a list of tool_result dicts
if isinstance(msg.content, list):
result.append({"role": "user", "content": msg.content})
else:
result.append({"role": "user", "content": [msg.content]})
elif msg.role == "assistant":
result.append({"role": "assistant", "content": msg.content})
elif msg.role == "user":
result.append({"role": "user", "content": msg.content})
return result
async def create_message(
self,
model: str,
system: str | None,
messages: list[ProviderMessage],
tools: list[ToolSchema],
max_tokens: int = 8192,
) -> ModelResponse:
kwargs: dict[str, Any] = {
"model": self.get_model_id(model),
"max_tokens": max_tokens,
"messages": self._build_messages(messages),
}
if system:
kwargs["system"] = system
if tools:
kwargs["tools"] = [self.clean_tool_schema(t) for t in tools]
resp = await self.client.messages.create(**kwargs)
content = []
for block in resp.content:
if block.type == "text":
content.append(ContentBlock(type="text", text=block.text))
elif block.type == "tool_use":
content.append(ContentBlock(
type="tool_use",
tool_call=ToolCall(
id=block.id,
name=block.name,
input=block.input,
),
))
return ModelResponse(
content=content,
stop_reason="tool_use" if resp.stop_reason == "tool_use" else "end_turn",
usage={
"input_tokens": resp.usage.input_tokens,
"output_tokens": resp.usage.output_tokens,
},
)
async def stream_message(
self,
model: str,
system: str | None,
messages: list[ProviderMessage],
tools: list[ToolSchema],
max_tokens: int = 8192,
) -> AsyncIterator[StreamEvent]:
kwargs: dict[str, Any] = {
"model": self.get_model_id(model),
"max_tokens": max_tokens,
"messages": self._build_messages(messages),
}
if system:
kwargs["system"] = system
if tools:
kwargs["tools"] = [self.clean_tool_schema(t) for t in tools]
# Use create() with stream=True for raw SSE events
kwargs["stream"] = True
raw_stream = await self.client.messages.create(**kwargs)
current_block_type: dict[int, str] = {}
current_tool_name: dict[int, str] = {}
current_tool_id: dict[int, str] = {}
current_text: dict[int, str] = {}
current_json: dict[int, str] = {}
async for event in raw_stream:
event_type = getattr(event, "type", "")
if event_type == "content_block_start":
index = event.index
block = event.content_block
block_type = block.type
current_block_type[index] = block_type
if block_type == "text":
current_text[index] = ""
yield StreamEvent(
type="content_block_start",
index=index,
block_type="text",
)
elif block_type == "tool_use":
current_tool_name[index] = block.name
current_tool_id[index] = block.id
current_json[index] = ""
yield StreamEvent(
type="content_block_start",
index=index,
block_type="tool_use",
tool_name=block.name,
tool_id=block.id,
)
elif block_type == "thinking":
# Extended-thinking content block. We track the
# accumulated text in current_text just like a normal
# text block, but tag it as "thinking" so the agent
# loop emits a distinct WS event the frontend can
# render in the ThinkingBubble pill.
current_text[index] = ""
yield StreamEvent(
type="content_block_start",
index=index,
block_type="thinking",
)
elif event_type == "content_block_delta":
index = event.index
delta = event.delta
delta_type = delta.type
if delta_type == "text_delta":
current_text.setdefault(index, "")
current_text[index] += delta.text
yield StreamEvent(
type="content_block_delta",
index=index,
delta_type="text_delta",
text=delta.text,
)
elif delta_type == "input_json_delta":
current_json.setdefault(index, "")
current_json[index] += delta.partial_json
yield StreamEvent(
type="content_block_delta",
index=index,
delta_type="input_json_delta",
text=delta.partial_json,
)
elif delta_type == "thinking_delta":
# Extended-thinking text streamed as it's produced.
# Forward as a thinking_delta so the agent loop can
# ship it to the frontend without conflating with
# the assistant text stream.
text_chunk = getattr(delta, "thinking", "") or ""
current_text.setdefault(index, "")
current_text[index] += text_chunk
yield StreamEvent(
type="content_block_delta",
index=index,
delta_type="thinking_delta",
text=text_chunk,
)
# Note: signature_delta (the cryptographic signature on
# thinking blocks) is intentionally ignored — we don't
# display it and it isn't needed for replay since we
# never re-send thinking blocks to the model.
elif event_type == "content_block_stop":
yield StreamEvent(type="content_block_stop", index=event.index)
elif event_type == "message_delta":
# Extract output token usage from the final delta
usage_data = {}
delta_usage = getattr(event, "usage", None)
if delta_usage:
output_tokens = getattr(delta_usage, "output_tokens", 0)
if output_tokens:
usage_data["output_tokens"] = output_tokens
if usage_data:
yield StreamEvent(type="usage", usage=usage_data)
elif event_type == "message_start":
# Extract input token usage from the message start
msg = getattr(event, "message", None)
if msg:
msg_usage = getattr(msg, "usage", None)
if msg_usage:
usage_data = {}
input_tokens = getattr(msg_usage, "input_tokens", 0)
output_tokens = getattr(msg_usage, "output_tokens", 0)
if input_tokens:
usage_data["input_tokens"] = input_tokens
if output_tokens:
usage_data["output_tokens"] = output_tokens
if usage_data:
yield StreamEvent(type="usage", usage=usage_data)
yield StreamEvent(type="message_stop")
async def stream_and_collect(
self,
model: str,
system: str | None,
messages: list[ProviderMessage],
tools: list[ToolSchema],
max_tokens: int = 8192,
) -> tuple[AsyncIterator[StreamEvent], ModelResponse]:
"""Helper: stream events and also return the full collected response.
Not used directly — the AgentLoop handles collection.
"""
raise NotImplementedError("Use stream_message() directly; AgentLoop collects.")
-135
View File
@@ -1,135 +0,0 @@
"""Provider-agnostic base classes for multi-model support.
All provider adapters (Anthropic, OpenAI, Gemini, OpenAI-compatible)
implement BaseProvider, translating their native APIs into these
common data structures.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any, AsyncIterator
@dataclass
class ToolSchema:
"""Provider-agnostic tool definition."""
name: str
description: str
input_schema: dict[str, Any]
@dataclass
class ToolCall:
"""A tool invocation requested by the model."""
id: str
name: str
input: dict[str, Any]
@dataclass
class ContentBlock:
"""A block of content from the model response."""
type: str # "text" | "tool_use" | "thinking"
text: str = ""
tool_call: ToolCall | None = None
@dataclass
class ModelResponse:
"""Complete (non-streaming) response from a provider."""
content: list[ContentBlock]
stop_reason: str # "end_turn" | "tool_use" | "max_tokens"
usage: dict[str, int] = field(default_factory=dict)
@dataclass
class StreamEvent:
"""A single streaming event, normalized across providers.
The event types match what the frontend already expects via WebSocket:
content_block_start, content_block_delta, content_block_stop, message_stop.
"""
type: str
index: int = 0
block_type: str = "" # "text" | "tool_use" | "thinking"
delta_type: str = "" # "text_delta" | "input_json_delta" | "thinking_delta"
text: str = ""
tool_name: str = ""
tool_id: str = ""
usage: dict[str, int] = field(default_factory=dict)
@dataclass
class ProviderMessage:
"""Provider-agnostic message for conversation history.
Each provider adapter converts these to/from its native format.
"""
role: str # "user" | "assistant" | "tool_result"
content: Any # str, list[dict], or provider-specific content
class BaseProvider(ABC):
"""Abstract base for LLM provider adapters."""
@abstractmethod
async def stream_message(
self,
model: str,
system: str | None,
messages: list[ProviderMessage],
tools: list[ToolSchema],
max_tokens: int = 8192,
) -> AsyncIterator[StreamEvent]:
"""Stream a model response, yielding normalized StreamEvents."""
...
@abstractmethod
async def create_message(
self,
model: str,
system: str | None,
messages: list[ProviderMessage],
tools: list[ToolSchema],
max_tokens: int = 8192,
) -> ModelResponse:
"""Non-streaming message creation."""
...
@abstractmethod
def format_tool_result(
self,
tool_use_id: str,
content: list[dict],
) -> dict:
"""Format a tool result in this provider's expected message format."""
...
@abstractmethod
def format_user_message(self, content: Any) -> ProviderMessage:
"""Wrap user content (str or multimodal blocks) into a ProviderMessage."""
...
@abstractmethod
def format_assistant_message(self, response: ModelResponse) -> ProviderMessage:
"""Convert a ModelResponse into a ProviderMessage for conversation history."""
...
@abstractmethod
def get_model_id(self, short_name: str) -> str:
"""Resolve a short model name to the full API model ID."""
...
def clean_tool_schema(self, schema: ToolSchema) -> dict:
"""Convert a ToolSchema to the provider's native tool format.
Default: Anthropic-style format. Override for providers that need
different formats or schema cleaning (e.g. Gemini).
"""
return {
"name": schema.name,
"description": schema.description,
"input_schema": schema.input_schema,
}
@@ -1,330 +0,0 @@
"""OpenAI-compatible provider adapter.
Works with ANY endpoint that speaks the OpenAI Chat Completions API:
OpenAI, OpenRouter, Together, Groq, Fireworks, Mistral, Ollama, vLLM, etc.
"""
from __future__ import annotations
import json
import logging
from typing import Any, AsyncIterator
from uuid import uuid4
from openai import AsyncOpenAI
from backend.apps.agents.providers.base import (
BaseProvider, ContentBlock, ModelResponse, ProviderMessage,
StreamEvent, ToolCall, ToolSchema,
)
logger = logging.getLogger(__name__)
class OpenAICompatProvider(BaseProvider):
"""Provider adapter for any OpenAI-compatible API endpoint."""
def __init__(
self,
api_key: str = "",
base_url: str | None = None,
):
kwargs: dict[str, Any] = {}
# Always set api_key — use "none" as placeholder if empty (some endpoints don't need real keys)
kwargs["api_key"] = api_key if api_key else "none"
if base_url:
kwargs["base_url"] = base_url
self.client = AsyncOpenAI(**kwargs)
def get_model_id(self, short_name: str) -> str:
# Pass through — user selects exact model ID
return short_name
def clean_tool_schema(self, schema: ToolSchema) -> dict:
"""Convert to OpenAI function calling format."""
return {
"type": "function",
"function": {
"name": schema.name,
"description": schema.description,
"parameters": schema.input_schema,
},
}
def format_tool_result(self, tool_use_id: str, content: list[dict]) -> dict:
"""Format tool result as OpenAI expects."""
# OpenAI wants a single string for tool results
text_parts = []
for block in content:
if block.get("type") == "text":
text_parts.append(block.get("text", ""))
elif block.get("type") == "image":
text_parts.append("[image]")
else:
text_parts.append(json.dumps(block))
return {
"role": "tool",
"tool_call_id": tool_use_id,
"content": "\n".join(text_parts) if text_parts else "Done.",
}
def format_user_message(self, content: Any) -> ProviderMessage:
"""Convert user content to OpenAI format."""
if isinstance(content, str):
return ProviderMessage(role="user", content=content)
# Multimodal content (text + images)
if isinstance(content, list):
parts = []
for block in content:
if isinstance(block, dict):
if block.get("type") == "text":
parts.append({"type": "text", "text": block["text"]})
elif block.get("type") == "image":
source = block.get("source", {})
media_type = source.get("media_type", "image/png")
data = source.get("data", "")
parts.append({
"type": "image_url",
"image_url": {"url": f"data:{media_type};base64,{data}"},
})
elif isinstance(block, str):
parts.append({"type": "text", "text": block})
return ProviderMessage(role="user", content=parts)
return ProviderMessage(role="user", content=str(content))
def format_assistant_message(self, response: ModelResponse) -> ProviderMessage:
"""Convert ModelResponse to OpenAI assistant message format."""
text_parts = []
tool_calls = []
for block in response.content:
if block.type == "text":
text_parts.append(block.text)
elif block.type == "tool_use" and block.tool_call:
tool_calls.append({
"id": block.tool_call.id,
"type": "function",
"function": {
"name": block.tool_call.name,
"arguments": json.dumps(block.tool_call.input),
},
})
msg: dict[str, Any] = {"role": "assistant"}
if text_parts:
msg["content"] = "\n".join(text_parts)
else:
msg["content"] = None
if tool_calls:
msg["tool_calls"] = tool_calls
return ProviderMessage(role="assistant", content=msg)
def _build_messages(
self,
system: str | None,
messages: list[ProviderMessage],
) -> list[dict]:
"""Convert ProviderMessages to OpenAI API format."""
result = []
if system:
result.append({"role": "system", "content": system})
for msg in messages:
if msg.role == "assistant":
# Assistant messages are already in OpenAI format from format_assistant_message
if isinstance(msg.content, dict) and "role" in msg.content:
result.append(msg.content)
else:
# Raw content blocks from provider-agnostic format
text_parts = []
tool_calls = []
if isinstance(msg.content, list):
for block in msg.content:
if isinstance(block, dict):
if block.get("type") == "text":
text_parts.append(block["text"])
elif block.get("type") == "tool_use":
tool_calls.append({
"id": block.get("id", uuid4().hex),
"type": "function",
"function": {
"name": block.get("name", ""),
"arguments": json.dumps(block.get("input", {})),
},
})
api_msg: dict[str, Any] = {
"role": "assistant",
"content": "\n".join(text_parts) if text_parts else None,
}
if tool_calls:
api_msg["tool_calls"] = tool_calls
result.append(api_msg)
elif msg.role == "tool_result":
# Tool results: content is a list of tool result dicts
if isinstance(msg.content, list):
for tr in msg.content:
if isinstance(tr, dict) and "tool_call_id" in tr:
result.append(tr)
elif isinstance(msg.content, dict) and "tool_call_id" in msg.content:
result.append(msg.content)
elif msg.role == "user":
result.append({"role": "user", "content": msg.content})
return result
async def create_message(
self,
model: str,
system: str | None,
messages: list[ProviderMessage],
tools: list[ToolSchema],
max_tokens: int = 8192,
) -> ModelResponse:
kwargs: dict[str, Any] = {
"model": self.get_model_id(model),
"max_tokens": max_tokens,
"messages": self._build_messages(system, messages),
}
if tools:
kwargs["tools"] = [self.clean_tool_schema(t) for t in tools]
resp = await self.client.chat.completions.create(**kwargs)
choice = resp.choices[0]
message = choice.message
content: list[ContentBlock] = []
if message.content:
content.append(ContentBlock(type="text", text=message.content))
if message.tool_calls:
for tc in message.tool_calls:
try:
args = json.loads(tc.function.arguments)
except json.JSONDecodeError:
args = {}
content.append(ContentBlock(
type="tool_use",
tool_call=ToolCall(
id=tc.id,
name=tc.function.name,
input=args,
),
))
stop = "end_turn"
if choice.finish_reason == "tool_calls":
stop = "tool_use"
elif message.tool_calls:
stop = "tool_use"
usage_dict = {}
if resp.usage:
usage_dict = {
"input_tokens": resp.usage.prompt_tokens,
"output_tokens": resp.usage.completion_tokens,
}
return ModelResponse(content=content, stop_reason=stop, usage=usage_dict)
async def stream_message(
self,
model: str,
system: str | None,
messages: list[ProviderMessage],
tools: list[ToolSchema],
max_tokens: int = 8192,
) -> AsyncIterator[StreamEvent]:
kwargs: dict[str, Any] = {
"model": self.get_model_id(model),
"max_tokens": max_tokens,
"messages": self._build_messages(system, messages),
"stream": True,
"stream_options": {"include_usage": True},
}
if tools:
kwargs["tools"] = [self.clean_tool_schema(t) for t in tools]
stream = await self.client.chat.completions.create(**kwargs)
# Track streaming state to emit normalized events
text_started = False
text_index = 0
tool_indices: dict[int, dict] = {} # openai tool_call index -> {name, id, json_buf}
next_block_index = 0
async for chunk in stream:
if not chunk.choices:
# Usage-only chunk at the end
if chunk.usage:
yield StreamEvent(type="usage", usage={
"input_tokens": chunk.usage.prompt_tokens or 0,
"output_tokens": chunk.usage.completion_tokens or 0,
})
continue
delta = chunk.choices[0].delta
finish_reason = chunk.choices[0].finish_reason
# Text content
if delta.content is not None:
if not text_started:
text_started = True
text_index = next_block_index
next_block_index += 1
yield StreamEvent(
type="content_block_start",
index=text_index,
block_type="text",
)
yield StreamEvent(
type="content_block_delta",
index=text_index,
delta_type="text_delta",
text=delta.content,
)
# Tool calls
if delta.tool_calls:
for tc_delta in delta.tool_calls:
tc_idx = tc_delta.index
if tc_idx not in tool_indices:
# New tool call starting
if text_started:
yield StreamEvent(type="content_block_stop", index=text_index)
text_started = False
block_idx = next_block_index
next_block_index += 1
tool_indices[tc_idx] = {
"block_index": block_idx,
"id": tc_delta.id or uuid4().hex,
"name": tc_delta.function.name if tc_delta.function else "",
"json_buf": "",
}
yield StreamEvent(
type="content_block_start",
index=block_idx,
block_type="tool_use",
tool_name=tool_indices[tc_idx]["name"],
tool_id=tool_indices[tc_idx]["id"],
)
info = tool_indices[tc_idx]
if tc_delta.function and tc_delta.function.name:
info["name"] = tc_delta.function.name
if tc_delta.function and tc_delta.function.arguments:
info["json_buf"] += tc_delta.function.arguments
yield StreamEvent(
type="content_block_delta",
index=info["block_index"],
delta_type="input_json_delta",
text=tc_delta.function.arguments,
)
# Finish
if finish_reason is not None:
if text_started:
yield StreamEvent(type="content_block_stop", index=text_index)
for info in tool_indices.values():
yield StreamEvent(type="content_block_stop", index=info["block_index"])
yield StreamEvent(type="message_stop")
+158 -234
View File
@@ -1,14 +1,8 @@
"""Provider registry and model catalog.
NOTE: `create_provider`, `BaseProvider`, `AnthropicProvider`, `OpenAICompatProvider`,
and the native `AgentLoop` are currently unused. The live agent path is
`claude_agent_sdk` via `agent_manager._run_agent_loop`. Kept as a foundation
for a potential future native multi-provider loop.
Multi-model subscription support routes non-Anthropic models through 9Router's
`/v1/messages` endpoint by passing prefixed model IDs (e.g. `cx/gpt-5.4`,
`gc/gemini-2.5-pro`). 9Router's translator converts the Anthropic-format
request into the provider's native format transparently.
Live agent path goes through claude_agent_sdk via agent_manager._run_agent_loop.
Non-Anthropic models route through 9Router's /v1/messages endpoint with
prefixed ids (cx/gpt-5.4, gc/gemini-3-pro-preview).
"""
from __future__ import annotations
@@ -16,8 +10,6 @@ from __future__ import annotations
import logging
from typing import Any, TYPE_CHECKING
from backend.apps.agents.providers.base import BaseProvider
if TYPE_CHECKING:
from backend.apps.settings.models import AppSettings
@@ -52,13 +44,16 @@ logger = logging.getLogger(__name__)
# Note: `gpt-5.4` is NOT available on this path — it's API-key-only.
# The Codex subscription's flagship is gpt-5.3-codex.
# - gc/ (Gemini CLI subscription) uses gemini-3-pro-preview / 3-flash-preview
# (thinking-capable) and gemini-2.5-pro / 2.5-flash (stable).
# (Gemini 3 family — thinking-capable). 2.5 models removed.
# Gemini 3 thought signatures handled via skip_thought_signature_validator.
BUILTIN_MODELS: dict[str, list[dict[str, Any]]] = {
# Anthropic: current-gen trio. Sonnet 4.6 (Feb 17 2026), Opus 4.6
# (Feb 5 2026), Haiku 4.5 (Oct 2025). All three are the current
# production flagships in their respective size tiers.
# Anthropic: Sonnet 4.6 (Feb 17 2026), Opus 4.6 (Feb 5 2026),
# Haiku 4.5 (Oct 2025). Opus 4.7 was briefly exposed but pulled —
# the Claude Code SDK currently elides plaintext thinking deltas
# for 4.7 (encrypted/redacted blocks only), which broke the
# "Thought for Ns" pill UX. Re-add once Anthropic ships the
# plaintext summarizer for 4.7.
"Anthropic": [
# Adaptive entries: route is chosen at call time based on
# settings.connection_mode (openswarm-pro → proxy; api_key → direct;
@@ -81,7 +76,7 @@ BUILTIN_MODELS: dict[str, list[dict[str, Any]]] = {
"model_id": "claude-opus-4-6", "router_model_id": "cc/claude-opus-4-6", "api": "anthropic", "reasoning": True, "route": "cc"},
{"value": "haiku-cc", "label": "Claude Haiku 4.5", "context_window": 200_000,
"model_id": "claude-haiku-4-5", "router_model_id": "cc/claude-haiku-4-5-20251001", "api": "anthropic", "reasoning": True, "route": "cc"},
{"value": "sonnet-api", "label": "Claude Sonnet 4.6 (API key)", "context_window": 1_000_000,
"model_id": "claude-sonnet-4-6", "router_model_id": "claude-sonnet-4-6", "api": "anthropic", "reasoning": True, "route": "api"},
{"value": "opus-api", "label": "Claude Opus 4.6 (API key)", "context_window": 1_000_000,
@@ -91,20 +86,42 @@ BUILTIN_MODELS: dict[str, list[dict[str, Any]]] = {
],
"OpenAI": [
# GPT-5.5 — newest ChatGPT flagship (May 2026). Available via
# the Codex subscription path on 9Router 0.4.x catalogs; on
# 0.3.60 (our pin) the cx/ catalog stops at gpt-5.4, so the
# subscription-routed entry will 404 until we bump. The
# API-key entry below works today against api.openai.com.
{"value": "gpt-5.5", "label": "GPT-5.5",
"context_window": 1_000_000, "router_model_id": "cx/gpt-5.5",
"api": "codex", "subscription_only": True, "reasoning": True},
{"value": "gpt-5.4", "label": "GPT-5.4",
"context_window": 1_000_000, "router_model_id": "cx/gpt-5.4",
"api": "codex", "subscription_only": True, "reasoning": True},
{"value": "gpt-5.4-mini", "label": "GPT-5.4 Mini",
"context_window": 400_000, "router_model_id": "cx/gpt-5.4-mini",
"api": "codex", "subscription_only": True, "reasoning": True},
# GPT-5.3 Codex variants. The bare `gpt-5.3-codex` adapts reasoning
# effort from session.thinking_level. The -high / -xhigh suffixes
# are distinct codex tunes from OpenAI optimized for longer-horizon
# coding (xhigh = max-quality, slowest). Both are surfaced for users
# who want to pin effort independently of the global thinking knob.
{"value": "gpt-5.3-codex", "label": "GPT-5.3 Codex",
"context_window": 400_000, "router_model_id": "cx/gpt-5.3-codex",
"api": "codex", "subscription_only": True, "reasoning": True},
{"value": "gpt-5.3-codex-high", "label": "GPT-5.3 Codex High",
"context_window": 400_000, "router_model_id": "cx/gpt-5.3-codex-high",
"api": "codex", "subscription_only": True, "reasoning": True},
{"value": "gpt-5.3-codex-xhigh", "label": "GPT-5.3 Codex Extra High",
"context_window": 400_000, "router_model_id": "cx/gpt-5.3-codex-xhigh",
"api": "codex", "subscription_only": True, "reasoning": True},
# Pinned-API-key entries: bypass 9Router and call api.openai.com
# directly with openai_api_key. Model ids match what OpenAI's API
# accepts (no cx/ prefix). Surfaced when openai_api_key is set —
# gives a metered alternative to the ChatGPT-Plus subscription
# route. Same -api suffix convention as the Anthropic mirrors.
{"value": "gpt-5.5-api", "label": "GPT-5.5 (API key)",
"context_window": 1_000_000, "router_model_id": "gpt-5.5", "model_id": "gpt-5.5",
"api": "openai", "reasoning": True, "route": "api"},
{"value": "gpt-5.4-api", "label": "GPT-5.4 (API key)",
"context_window": 1_000_000, "router_model_id": "gpt-5.4", "model_id": "gpt-5.4",
"api": "openai", "reasoning": True, "route": "api"},
@@ -114,6 +131,12 @@ BUILTIN_MODELS: dict[str, list[dict[str, Any]]] = {
{"value": "gpt-5.3-codex-api", "label": "GPT-5.3 Codex (API key)",
"context_window": 400_000, "router_model_id": "gpt-5.3-codex", "model_id": "gpt-5.3-codex",
"api": "openai", "reasoning": True, "route": "api"},
{"value": "gpt-5.3-codex-high-api", "label": "GPT-5.3 Codex High (API key)",
"context_window": 400_000, "router_model_id": "gpt-5.3-codex-high", "model_id": "gpt-5.3-codex-high",
"api": "openai", "reasoning": True, "route": "api"},
{"value": "gpt-5.3-codex-xhigh-api", "label": "GPT-5.3 Codex Extra High (API key)",
"context_window": 400_000, "router_model_id": "gpt-5.3-codex-xhigh", "model_id": "gpt-5.3-codex-xhigh",
"api": "openai", "reasoning": True, "route": "api"},
],
# Google: Gemini via Gemini CLI subscription. Both 3.x (thinking-
# capable) and 2.5 (stable) are offered. Gemini 3 models have
@@ -125,35 +148,39 @@ BUILTIN_MODELS: dict[str, list[dict[str, Any]]] = {
# cost of the model not being able to build on prior reasoning
# across turns — but all tools work and thinking is visible.
"Google": [
# Gemini 3.1 Pro — newest flagship (Apr 2026), routes to
# `gc/gemini-3.1-pro-preview` for the subscription path. Same
# thoughtSignature caveat applies; resolve_model_id_for_sdk's
# Antigravity map handles the multi-step routing.
{"value": "gemini-3.1-pro", "label": "Gemini 3.1 Pro",
"context_window": 1_000_000, "router_model_id": "gc/gemini-3.1-pro-preview",
"api": "gemini-cli", "subscription_only": True, "reasoning": True},
{"value": "gemini-3.1-flash-lite", "label": "Gemini 3.1 Flash Lite",
"context_window": 1_000_000, "router_model_id": "gc/gemini-3.1-flash-lite-preview",
"api": "gemini-cli", "subscription_only": True, "reasoning": True},
{"value": "gemini-3-pro", "label": "Gemini 3 Pro",
"context_window": 1_000_000, "router_model_id": "gc/gemini-3-pro-preview",
"api": "gemini-cli", "subscription_only": True, "reasoning": True},
{"value": "gemini-3-flash", "label": "Gemini 3 Flash",
"context_window": 1_000_000, "router_model_id": "gc/gemini-3-flash-preview",
"api": "gemini-cli", "subscription_only": True, "reasoning": True},
{"value": "gemini-2.5-pro", "label": "Gemini 2.5 Pro",
"context_window": 1_000_000, "router_model_id": "gc/gemini-2.5-pro",
"api": "gemini-cli", "subscription_only": True},
{"value": "gemini-2.5-flash", "label": "Gemini 2.5 Flash",
"context_window": 1_000_000, "router_model_id": "gc/gemini-2.5-flash",
"api": "gemini-cli", "subscription_only": True},
# Pinned-API-key entries for Google AI Studio (api="gemini"). Bypass
# both 9Router (which routes via Gemini CLI/Antigravity OAuth) and
# any subscription path; call generativelanguage.googleapis.com
# directly with google_api_key. Free-tier quota is generous (~1K
# requests/day) and lives separately from the OAuth lanes.
{"value": "gemini-3.1-pro-api", "label": "Gemini 3.1 Pro (API key)",
"context_window": 1_000_000, "router_model_id": "gemini-3.1-pro-preview", "model_id": "gemini-3.1-pro-preview",
"api": "gemini", "reasoning": True, "route": "api"},
{"value": "gemini-3.1-flash-lite-api", "label": "Gemini 3.1 Flash Lite (API key)",
"context_window": 1_000_000, "router_model_id": "gemini-3.1-flash-lite-preview", "model_id": "gemini-3.1-flash-lite-preview",
"api": "gemini", "reasoning": True, "route": "api"},
{"value": "gemini-3-pro-api", "label": "Gemini 3 Pro (API key)",
"context_window": 1_000_000, "router_model_id": "gemini-3-pro-preview", "model_id": "gemini-3-pro-preview",
"api": "gemini", "reasoning": True, "route": "api"},
{"value": "gemini-3-flash-api", "label": "Gemini 3 Flash (API key)",
"context_window": 1_000_000, "router_model_id": "gemini-3-flash-preview", "model_id": "gemini-3-flash-preview",
"api": "gemini", "reasoning": True, "route": "api"},
{"value": "gemini-2.5-pro-api", "label": "Gemini 2.5 Pro (API key)",
"context_window": 1_000_000, "router_model_id": "gemini-2.5-pro", "model_id": "gemini-2.5-pro",
"api": "gemini", "route": "api"},
{"value": "gemini-2.5-flash-api", "label": "Gemini 2.5 Flash (API key)",
"context_window": 1_000_000, "router_model_id": "gemini-2.5-flash", "model_id": "gemini-2.5-flash",
"api": "gemini", "route": "api"},
],
}
@@ -189,9 +216,16 @@ def thinking_params_for(api: str, level: str, model_id: str = "") -> dict | None
return {"thinking": {"type": "disabled"}}
if api == "codex":
return {"reasoning": {"effort": "none"}}
# Gemini: lowest available level
# Gemini: thinkingBudget=0 truly disables reasoning (no
# thoughtSignature emitted). Critical for multi-step tool turns
# — without this Gemini 2.5/3.x still emits signatures even at
# the lowest "level," which then break the next request with
# "Thought signature is not valid" 400 because the SDK has no
# way to round-trip them. The translator at 9Router 0.3.60
# explicitly checks `thinkingBudget == 0` to skip emitting
# thinking config, which is what we want.
if api == "gemini-cli":
return {"thinkingConfig": {"thinkingLevel": "LOW"}}
return {"thinkingConfig": {"thinkingBudget": 0}}
return None
# Claude 4.6 models use adaptive thinking (no manual budget). For older
@@ -291,18 +325,39 @@ def resolve_model_id_for_sdk(short_name: str, settings: AppSettings) -> str:
return entry.get("model_id", short_name)
# Gemini: prefer lanes with higher quota in order —
# 1. AI Studio apikey (free 1K/day, separate from any OAuth limit)
# 2. Antigravity OAuth (preview, 5-10× the Gemini CLI free tier)
# 3. Gemini CLI OAuth (free tier, ~5 RPM — last resort)
# 2. Antigravity OAuth (preview, 5-10× the Gemini CLI free tier).
# CRITICAL: Antigravity's wrapper around Google's API doesn't
# enforce the strict thoughtSignature continuity check that
# breaks multi-step tool turns through Gemini CLI. Without
# this lane, agent turns that combine thinking + tool use get
# "Thought signature is not valid" 400s on every follow-up
# request because the claude_agent_sdk has no hook to round-
# trip Gemini-specific signatures.
# 3. Gemini CLI OAuth (free tier, ~5 RPM — last resort, breaks
# on multi-step agent turns).
#
# Antigravity exposes differently-named Gemini models than Gemini CLI:
# gc/gemini-3-pro-preview → ag/gemini-3.1-pro-high
# gc/gemini-3-flash-preview → ag/gemini-3-flash
# gc/gemini-2.5-pro → (not available on Antigravity)
# gc/gemini-2.5-flash → (not available on Antigravity)
# When Antigravity lacks a model we fall back to gc/.
# gc/gemini-3-pro-preview → ag/gemini-3.1-pro-high (DISABLED —
# Google returns 404 not_found_error on this even with an
# active Antigravity connection; tier-side access gate.)
# gc/gemini-3-flash-preview → ag/gemini-3-flash (works)
# Models Antigravity doesn't have (or that 404) fall through to gc/.
_ANTIGRAVITY_MAP = {
"gemini-3-pro-preview": "gemini-3.1-pro-high",
# Disabled until 9Router exposes per-model availability so we
# can verify pro-high is actually serviceable before routing.
# "gemini-3-pro-preview": "gemini-3.1-pro-high",
"gemini-3-flash-preview": "gemini-3-flash",
# Gemini 3.1 family — same thoughtSignature problem as 3.0:
# gc/ enforces continuity, the Anthropic SDK has no hook to
# round-trip the signature, every multi-step tool turn 400s
# with "Thought signature is not valid". Routing through
# ag/ (Antigravity wrapper around Google's API) sidesteps
# the validator. AG is flagged deprecated upstream — we keep
# using it on 9router 0.3.60 (our pin) as the only working
# multi-step Gemini path; will revisit once the SDK gets
# signature passthrough or 9router lands a Gemini-CLI fix.
"gemini-3.1-pro-preview": "gemini-3.1-pro-high",
"gemini-3.1-flash-lite-preview": "gemini-3-flash",
}
if entry.get("api") == "gemini-cli":
rid = entry.get("router_model_id", "")
@@ -332,28 +387,76 @@ def resolve_model_id_for_sdk(short_name: str, settings: AppSettings) -> str:
return entry.get("router_model_id", entry.get("model_id", short_name))
async def resolve_aux_model(settings: AppSettings, preferred_tier: str = "haiku") -> tuple[str, str | None]:
async def resolve_aux_model(
settings: AppSettings,
preferred_tier: str = "haiku",
primary_api: str | None = None,
) -> tuple[str, str | None]:
"""Pick the cheapest/most-available model for auxiliary LLM calls.
Used by title generation, group meta, dashboard naming, outputs/view
builder, and browser_agent — wherever we need a quick one-shot LLM call
that is NOT the user's selected chat model.
Args:
primary_api: when set, prefer this provider family ("anthropic" |
"codex" | "gemini-cli") over the default Anthropic-first cascade.
Lets a Codex-only or Gemini-only session keep aux work on the
same family it's already paying for, instead of leaking to
Anthropic Haiku just because the user *also* has Anthropic
connected. Caller passes `get_api_type(session.model)`.
Returns (model_id, base_url).
- If base_url is None, caller should use the default Anthropic client.
- If base_url is set, caller should route through 9Router.
- If base_url is set, caller should route through that endpoint.
Priority:
1. Anthropic API key set → bare haiku/sonnet on real Anthropic API
2. 9Router + Claude subscription connected → cc/<model>
3. 9Router + Codex connected → cx/gpt-5.4-mini
4. 9Router + Gemini connected → gc/gemini-2.5-flash
5. Nothing available → raise ValueError
Priority (when primary_api is None, classic cascade):
1. OpenSwarm Pro mode → bare haiku/sonnet via proxy
2. Anthropic API key set → bare haiku/sonnet on real Anthropic API
3. 9Router + Claude subscription connected → cc/<model>
4. 9Router + Codex connected → cx/gpt-5.4-mini
5. 9Router + Gemini connected → gc/gemini-3.1-flash-lite-preview
6. Nothing available → raise ValueError
When primary_api is provided, the resolver tries that family first
(subscription path then API key) and only falls through to other
providers if the primary family isn't reachable.
"""
haiku_bare = "claude-haiku-4-5-20251001"
sonnet_bare = "claude-sonnet-4-20250514"
bare = haiku_bare if preferred_tier == "haiku" else sonnet_bare
# Probe 9Router once up front so the primary_api branch and the
# default cascade share the same connection set.
from backend.apps.nine_router import is_running as _9r_running, get_providers as _9r_providers
base_url = "http://localhost:20128"
connected: set[str] = set()
if _9r_running():
try:
connections = await _9r_providers()
connected = {c.get("provider") for c in connections if c.get("isActive")}
except Exception:
connected = set()
# Match primary_api first when supplied. Each branch checks both the
# subscription path (preferred — usually free) and the direct-API path
# before giving up on this family.
if primary_api == "codex":
if "codex" in connected:
return ("cx/gpt-5.4-mini", base_url)
if getattr(settings, "openai_api_key", None):
return ("gpt-5.4-mini", "https://api.openai.com/v1")
# primary is Codex but it's not reachable — fall through to default
elif primary_api == "gemini-cli" or primary_api == "gemini":
if "gemini-cli" in connected:
return ("gc/gemini-3.1-flash-lite-preview", base_url)
if getattr(settings, "google_api_key", None):
return ("gemini-3.1-flash-lite-preview", "https://generativelanguage.googleapis.com/v1beta")
# fall through to default
# primary_api == "anthropic" naturally falls into the Anthropic-first
# cascade below — no special branch needed.
# OpenSwarm Pro — route through our cloud proxy
if getattr(settings, "connection_mode", "own_key") == "openswarm-pro":
proxy_url = getattr(settings, "openswarm_proxy_url", None) or "https://api.openswarm.com"
@@ -363,25 +466,18 @@ async def resolve_aux_model(settings: AppSettings, preferred_tier: str = "haiku"
if getattr(settings, "anthropic_api_key", None):
return (bare, None)
# Fall back to 9Router
from backend.apps.nine_router import is_running as _9r_running, get_providers as _9r_providers
if not _9r_running():
raise ValueError(
"No AI provider configured for auxiliary LLM call. "
"Set an Anthropic API key or connect a subscription."
)
connections = await _9r_providers()
connected = {c.get("provider") for c in connections if c.get("isActive")}
base_url = "http://localhost:20128"
if "claude" in connected:
return (f"cc/{haiku_bare}" if preferred_tier == "haiku" else f"cc/{sonnet_bare}", base_url)
if "codex" in connected:
return ("cx/gpt-5.4-mini", base_url)
if "gemini-cli" in connected:
return ("gc/gemini-2.5-flash", base_url)
return ("gc/gemini-3.1-flash-lite-preview", base_url)
raise ValueError(
"No AI provider connected for auxiliary LLM call. "
@@ -389,183 +485,6 @@ async def resolve_aux_model(settings: AppSettings, preferred_tier: str = "haiku"
)
# ---------------------------------------------------------------------------
# Provider factory
# ---------------------------------------------------------------------------
def create_provider(
provider_name: str,
settings: AppSettings,
provider_config: dict | None = None,
) -> BaseProvider:
"""Create a provider adapter.
Routes based on the 'api' field in BUILTIN_MODELS:
- "anthropic" → native Anthropic SDK
- "openai" → native OpenAI SDK (direct API)
- "gemini" → native Google GenAI SDK
- "openrouter" → OpenAI-compat via openrouter.ai (Meta, Mistral, DeepSeek, Qwen, xAI, etc.)
Custom providers use OpenAI-compat with user's base_url.
"""
api_type = _get_api_type(provider_name)
# Check for 9Router first
if provider_name in ("9Router", "9router"):
from backend.apps.agents.providers.openai_compat import OpenAICompatProvider
return OpenAICompatProvider(api_key="9router", base_url="http://localhost:20128/v1")
if api_type == "anthropic":
from backend.apps.agents.providers.anthropic import AnthropicProvider
if getattr(settings, "connection_mode", "own_key") == "openswarm-pro":
return AnthropicProvider(
auth_token=getattr(settings, "openswarm_bearer_token", None),
base_url=getattr(settings, "openswarm_proxy_url", None) or "https://api.openswarm.com",
)
# Priority: API key → 9Router subscription
if settings.anthropic_api_key:
return AnthropicProvider(api_key=settings.anthropic_api_key)
# No API key — try 9Router as fallback
if _is_9router_available():
from backend.apps.agents.providers.openai_compat import OpenAICompatProvider
provider = OpenAICompatProvider(api_key="9router", base_url="http://localhost:20128/v1")
# Override get_model_id to map our short names to 9Router's cc/ prefixed IDs
_original_get_model = provider.get_model_id
_9r_model_map = {
"sonnet": "cc/claude-sonnet-4-6",
"opus": "cc/claude-opus-4-6",
"haiku": "cc/claude-haiku-4-5-20251001",
}
provider.get_model_id = lambda name: _9r_model_map.get(name, f"cc/{name}" if not name.startswith("cc/") else name)
return provider
raise ValueError("Anthropic API key not configured. Set it in Settings, or connect 9Router.")
if api_type == "openai":
from backend.apps.agents.providers.openai_compat import OpenAICompatProvider
if settings.openai_api_key:
return OpenAICompatProvider(api_key=settings.openai_api_key, base_url="https://api.openai.com/v1")
# No API key — try 9Router as fallback
if _is_9router_available():
return OpenAICompatProvider(api_key="9router", base_url="http://localhost:20128/v1")
raise ValueError("OpenAI API key not configured. Set it in Settings, or connect 9Router.")
if api_type == "gemini":
from backend.apps.agents.providers.gemini import GeminiProvider
if settings.google_api_key:
return GeminiProvider(api_key=settings.google_api_key)
# No API key — try 9Router as fallback
if _is_9router_available():
from backend.apps.agents.providers.openai_compat import OpenAICompatProvider
return OpenAICompatProvider(api_key="9router", base_url="http://localhost:20128/v1")
raise ValueError("Google API key not configured. Set it in Settings, or connect 9Router.")
if api_type == "openrouter":
from backend.apps.agents.providers.openai_compat import OpenAICompatProvider
openrouter_key = getattr(settings, "openrouter_api_key", None)
if openrouter_key:
return OpenAICompatProvider(api_key=openrouter_key, base_url=OPENROUTER_BASE_URL)
# No OpenRouter key — try 9Router as fallback
if _is_9router_available():
return OpenAICompatProvider(api_key="9router", base_url="http://localhost:20128/v1")
raise ValueError(f"OpenRouter API key not configured for {provider_name}. Set it in Settings, or connect a subscription.")
# Custom provider — look up in settings.custom_providers
if provider_config:
from backend.apps.agents.providers.openai_compat import OpenAICompatProvider
return OpenAICompatProvider(
api_key=provider_config.get("api_key", ""),
base_url=provider_config.get("base_url", ""),
)
for cp in getattr(settings, "custom_providers", []):
if cp.name == provider_name:
from backend.apps.agents.providers.openai_compat import OpenAICompatProvider
return OpenAICompatProvider(
api_key=cp.api_key,
base_url=cp.base_url,
)
raise ValueError(f"Unknown provider: {provider_name}")
def _get_api_type(provider_name: str) -> str:
"""Get the API type for a provider from BUILTIN_MODELS.
Accepts both display names ('Anthropic') and lowercase API names ('anthropic').
"""
# Direct lookup first (display name like 'Anthropic', 'OpenAI', etc.)
models = BUILTIN_MODELS.get(provider_name, [])
if models:
return models[0].get("api", "openrouter")
# Lowercase API name mapping
_API_NAME_MAP = {
"anthropic": "anthropic",
"openai": "openai",
"gemini": "gemini",
"google": "gemini",
"openrouter": "openrouter",
}
if provider_name.lower() in _API_NAME_MAP:
return _API_NAME_MAP[provider_name.lower()]
# Case-insensitive lookup into BUILTIN_MODELS
lower = provider_name.lower()
for key, models in BUILTIN_MODELS.items():
if key.lower() == lower:
return models[0].get("api", "openrouter")
return "openrouter"
def _has_credentials(provider_name: str, settings: AppSettings) -> bool:
"""Check if a provider has credentials configured."""
api_type = _get_api_type(provider_name)
if api_type == "anthropic":
if getattr(settings, "connection_mode", "own_key") == "openswarm-pro":
return bool(getattr(settings, "openswarm_bearer_token", None))
return bool(settings.anthropic_api_key)
if api_type == "openai":
return bool(settings.openai_api_key)
if api_type == "gemini":
return bool(getattr(settings, "google_api_key", None))
if api_type == "openrouter":
return bool(getattr(settings, "openrouter_api_key", None))
return False
def get_available_models(settings: AppSettings) -> dict[str, list[dict]]:
"""Return all models — always show everything, mark which have keys configured.
Like Cursor: show all models upfront, prompt for key when user tries to use one.
Returns: {"provider_name": [{"value": ..., "label": ..., "context_window": ..., "configured": bool}, ...]}
"""
result: dict[str, list[dict]] = {}
# Built-in providers — always show all
for provider_name, models in BUILTIN_MODELS.items():
configured = _has_credentials(provider_name, settings)
result[provider_name] = [
{**m, "configured": configured}
for m in models
]
# Custom providers
for cp in getattr(settings, "custom_providers", []):
if cp.models:
result[cp.name] = [
{
"value": m.get("value", m.get("id", "")),
"label": m.get("label", m.get("value", m.get("id", ""))),
"context_window": m.get("context_window", 128_000),
"configured": True,
}
for m in cp.models
]
return result
def get_context_window(provider: str, model: str, settings: AppSettings | None = None) -> int:
"""Look up context window for any model."""
# Check built-in models first
@@ -591,20 +510,25 @@ def get_context_window(provider: str, model: str, settings: AppSettings | None =
COST_PER_1M_TOKENS: dict[tuple[str, str], tuple[float, float]] = {
# (provider, model): (input_cost_per_1M, output_cost_per_1M)
# NOTE: `calculate_cost` is currently unused in the live path — real
# cost tracking comes from 9Router's usage stats (analytics.py:270+).
# These entries are kept so the table matches BUILTIN_MODELS and can
# cost numbers come from 9Router's usage stats. These entries are kept
# so the table matches BUILTIN_MODELS and can
# be used by any future native-loop path. Subscription-routed models
# are zero-cost to the user, but API rates are recorded here for
# reference where they exist.
# Anthropic (direct API rates)
# Anthropic (direct API rates).
("Anthropic", "sonnet"): (3.0, 15.0),
("Anthropic", "opus"): (5.0, 25.0),
("Anthropic", "haiku"): (1.0, 5.0),
# OpenAI — Codex subscription path, user pays nothing per token
("OpenAI", "gpt-5.5"): (0.0, 0.0),
("OpenAI", "gpt-5.4"): (0.0, 0.0),
("OpenAI", "gpt-5.4-mini"): (0.0, 0.0),
("OpenAI", "gpt-5.3-codex"): (0.0, 0.0),
("OpenAI", "gpt-5.3-codex-high"): (0.0, 0.0),
("OpenAI", "gpt-5.3-codex-xhigh"): (0.0, 0.0),
# Google — Gemini CLI subscription path, user pays nothing per token
("Google", "gemini-3.1-pro"): (0.0, 0.0),
("Google", "gemini-3.1-flash-lite"): (0.0, 0.0),
("Google", "gemini-3-pro"): (0.0, 0.0),
("Google", "gemini-3-flash"): (0.0, 0.0),
("Google", "gemini-2.5-pro"): (0.0, 0.0),
-24
View File
@@ -2,46 +2,22 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any
@dataclass
class ToolContext:
"""Runtime context passed to every tool execution."""
cwd: str
session_id: str
class BaseTool(ABC):
"""Abstract base for all builtin tools.
Subclasses must set ``name`` and ``description`` as class attributes and
implement ``get_schema`` (JSON Schema for tool input) and ``execute``.
"""
name: str
description: str
@abstractmethod
def get_schema(self) -> dict:
"""Return JSON Schema for this tool's input parameters."""
...
@abstractmethod
async def execute(self, input_data: dict, context: ToolContext) -> list[dict]:
"""Execute the tool.
Returns a list of content blocks, e.g.
``[{"type": "text", "text": "..."}]``.
"""
...
def to_tool_schema(self):
"""Convert to the provider-agnostic ``ToolSchema`` used everywhere."""
from backend.apps.agents.providers.base import ToolSchema
return ToolSchema(
name=self.name,
description=self.description,
input_schema=self.get_schema(),
)
-476
View File
@@ -1,476 +0,0 @@
"""Filesystem tools: Read, Write, Edit, Glob, Grep."""
from __future__ import annotations
import asyncio
import base64
import mimetypes
import os
import re
from pathlib import Path
from typing import Any
from backend.apps.agents.tools.base import BaseTool, ToolContext
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"}
_MAX_OUTPUT_BYTES = 50 * 1024 # ~50 KB cap for grep output
def _resolve(file_path: str, cwd: str) -> Path:
"""Resolve *file_path* against *cwd* when it is relative."""
p = Path(file_path)
if not p.is_absolute():
p = Path(cwd) / p
return p.resolve()
def _text_block(text: str) -> list[dict]:
return [{"type": "text", "text": text}]
# ───────────────────────────────────────────────────────────────────────────
# ReadTool
# ───────────────────────────────────────────────────────────────────────────
class ReadTool(BaseTool):
name = "Read"
description = (
"Read a file from the filesystem. Returns lines with line numbers "
"(cat -n style). For image files returns base64 content. Supports "
"offset and limit parameters for reading portions of large files."
)
def get_schema(self) -> dict:
return {
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "Absolute or relative path to the file to read.",
},
"offset": {
"type": "integer",
"description": "1-based line number to start reading from.",
},
"limit": {
"type": "integer",
"description": "Maximum number of lines to return (default 2000).",
},
},
"required": ["file_path"],
"additionalProperties": False,
}
async def execute(self, input_data: dict, context: ToolContext) -> list[dict]:
file_path = _resolve(input_data["file_path"], context.cwd)
if not file_path.exists():
return _text_block(f"Error: file not found: {file_path}")
if not file_path.is_file():
return _text_block(f"Error: not a regular file: {file_path}")
# Binary / image files → base64
ext = file_path.suffix.lower()
if ext in _IMAGE_EXTENSIONS:
try:
raw = file_path.read_bytes()
b64 = base64.b64encode(raw).decode("ascii")
media = mimetypes.guess_type(str(file_path))[0] or "application/octet-stream"
return [
{
"type": "image",
"source": {
"type": "base64",
"media_type": media,
"data": b64,
},
}
]
except Exception as exc:
return _text_block(f"Error reading image {file_path}: {exc}")
# Text files
offset = max(input_data.get("offset", 1), 1)
limit = input_data.get("limit", 2000)
if limit <= 0:
limit = 2000
try:
with open(file_path, "r", errors="replace") as fh:
lines: list[str] = []
for lineno, line in enumerate(fh, start=1):
if lineno < offset:
continue
if len(lines) >= limit:
break
# cat -n style: right-justified line number + tab + content
lines.append(f"{lineno:>6}\t{line.rstrip()}")
if not lines:
return _text_block(f"(file is empty or offset beyond end of file: {file_path})")
return _text_block("\n".join(lines))
except Exception as exc:
return _text_block(f"Error reading {file_path}: {exc}")
# ───────────────────────────────────────────────────────────────────────────
# WriteTool
# ───────────────────────────────────────────────────────────────────────────
class WriteTool(BaseTool):
name = "Write"
description = (
"Write content to a file. Creates parent directories if they do not "
"exist. Overwrites the file if it already exists."
)
def get_schema(self) -> dict:
return {
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "Absolute or relative path to the file to write.",
},
"content": {
"type": "string",
"description": "The full content to write to the file.",
},
},
"required": ["file_path", "content"],
"additionalProperties": False,
}
async def execute(self, input_data: dict, context: ToolContext) -> list[dict]:
file_path = _resolve(input_data["file_path"], context.cwd)
content: str = input_data["content"]
try:
file_path.parent.mkdir(parents=True, exist_ok=True)
file_path.write_text(content, encoding="utf-8")
return _text_block(f"Successfully wrote {len(content)} bytes to {file_path}")
except Exception as exc:
return _text_block(f"Error writing {file_path}: {exc}")
# ───────────────────────────────────────────────────────────────────────────
# EditTool
# ───────────────────────────────────────────────────────────────────────────
class EditTool(BaseTool):
name = "Edit"
description = (
"Perform exact string replacements in a file. By default the "
"old_string must appear exactly once (not unique → error). Pass "
"replace_all=true to replace every occurrence."
)
def get_schema(self) -> dict:
return {
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "Absolute or relative path to the file to edit.",
},
"old_string": {
"type": "string",
"description": "The exact text to find in the file.",
},
"new_string": {
"type": "string",
"description": "The text to replace old_string with.",
},
"replace_all": {
"type": "boolean",
"description": "If true, replace all occurrences. Default false.",
"default": False,
},
},
"required": ["file_path", "old_string", "new_string"],
"additionalProperties": False,
}
async def execute(self, input_data: dict, context: ToolContext) -> list[dict]:
file_path = _resolve(input_data["file_path"], context.cwd)
old_string: str = input_data["old_string"]
new_string: str = input_data["new_string"]
replace_all: bool = input_data.get("replace_all", False)
if not file_path.exists():
return _text_block(f"Error: file not found: {file_path}")
if not file_path.is_file():
return _text_block(f"Error: not a regular file: {file_path}")
try:
content = file_path.read_text(encoding="utf-8")
except Exception as exc:
return _text_block(f"Error reading {file_path}: {exc}")
count = content.count(old_string)
if count == 0:
return _text_block(
f"Error: old_string not found in {file_path}. "
"Make sure the string matches exactly, including whitespace and indentation."
)
if not replace_all and count > 1:
return _text_block(
f"Error: old_string appears {count} times in {file_path}. "
"Provide more surrounding context to make the match unique, "
"or set replace_all=true to replace every occurrence."
)
if replace_all:
new_content = content.replace(old_string, new_string)
else:
# Replace only the first (and only) occurrence
new_content = content.replace(old_string, new_string, 1)
try:
file_path.write_text(new_content, encoding="utf-8")
except Exception as exc:
return _text_block(f"Error writing {file_path}: {exc}")
replacements = count if replace_all else 1
return _text_block(
f"Successfully edited {file_path} ({replacements} replacement{'s' if replacements != 1 else ''})."
)
# ───────────────────────────────────────────────────────────────────────────
# GlobTool
# ───────────────────────────────────────────────────────────────────────────
class GlobTool(BaseTool):
name = "Glob"
description = (
"Fast file pattern matching. Supports glob patterns like '**/*.py'. "
"Returns matching file paths sorted by modification time (newest first)."
)
def get_schema(self) -> dict:
return {
"type": "object",
"properties": {
"pattern": {
"type": "string",
"description": "Glob pattern to match files (e.g. '**/*.py', 'src/**/*.ts').",
},
"path": {
"type": "string",
"description": "Directory to search in. Defaults to the working directory.",
},
},
"required": ["pattern"],
"additionalProperties": False,
}
async def execute(self, input_data: dict, context: ToolContext) -> list[dict]:
pattern: str = input_data["pattern"]
base = Path(input_data.get("path") or context.cwd)
if not base.is_dir():
return _text_block(f"Error: directory not found: {base}")
try:
matches: list[Path] = []
for p in base.glob(pattern):
if p.is_file():
matches.append(p)
if len(matches) >= 500:
break
# Sort by modification time, newest first
matches.sort(key=lambda p: p.stat().st_mtime, reverse=True)
if not matches:
return _text_block(f"No files matched pattern '{pattern}' in {base}")
result = "\n".join(str(p) for p in matches)
return _text_block(result)
except Exception as exc:
return _text_block(f"Error during glob '{pattern}' in {base}: {exc}")
# ───────────────────────────────────────────────────────────────────────────
# GrepTool
# ───────────────────────────────────────────────────────────────────────────
class GrepTool(BaseTool):
name = "Grep"
description = (
"Search file contents using regular expressions. Uses ripgrep (rg) "
"when available, otherwise falls back to Python's re module. "
"Supports output modes: files_with_matches, content, count."
)
def get_schema(self) -> dict:
return {
"type": "object",
"properties": {
"pattern": {
"type": "string",
"description": "Regular expression pattern to search for.",
},
"path": {
"type": "string",
"description": "File or directory to search in. Defaults to the working directory.",
},
"glob": {
"type": "string",
"description": "Glob pattern to filter files (e.g. '*.py', '*.{ts,tsx}').",
},
"output_mode": {
"type": "string",
"enum": ["files_with_matches", "content", "count"],
"description": "Output mode. Default: files_with_matches.",
"default": "files_with_matches",
},
},
"required": ["pattern"],
"additionalProperties": False,
}
async def execute(self, input_data: dict, context: ToolContext) -> list[dict]:
pattern: str = input_data["pattern"]
search_path: str = input_data.get("path") or context.cwd
file_glob: str | None = input_data.get("glob")
output_mode: str = input_data.get("output_mode", "files_with_matches")
# Try ripgrep first
try:
result = await self._run_rg(pattern, search_path, file_glob, output_mode)
if result is not None:
return result
except FileNotFoundError:
pass # rg not installed, fall through to Python fallback
# Python fallback
return await self._python_grep(pattern, search_path, file_glob, output_mode)
async def _run_rg(
self,
pattern: str,
search_path: str,
file_glob: str | None,
output_mode: str,
) -> list[dict] | None:
"""Run ripgrep and return results, or None if rg is not available."""
cmd = ["rg", "--no-heading", "--color=never"]
if output_mode == "files_with_matches":
cmd.append("--files-with-matches")
elif output_mode == "count":
cmd.append("--count")
else:
cmd.extend(["--line-number"])
if file_glob:
cmd.extend(["--glob", file_glob])
cmd.append(pattern)
cmd.append(search_path)
try:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=30)
except FileNotFoundError:
raise # re-raise so caller knows rg is missing
except asyncio.TimeoutError:
return _text_block("Error: grep timed out after 30 seconds.")
except Exception as exc:
return _text_block(f"Error running ripgrep: {exc}")
output = stdout.decode("utf-8", errors="replace")
if proc.returncode not in (0, 1):
err = stderr.decode("utf-8", errors="replace").strip()
if err:
return _text_block(f"Grep error: {err}")
if not output.strip():
return _text_block(f"No matches found for pattern '{pattern}'.")
# Truncate if too large
if len(output) > _MAX_OUTPUT_BYTES:
output = output[:_MAX_OUTPUT_BYTES] + "\n... (output truncated)"
return _text_block(output.rstrip())
async def _python_grep(
self,
pattern: str,
search_path: str,
file_glob: str | None,
output_mode: str,
) -> list[dict]:
"""Pure-Python grep fallback using the re module."""
try:
regex = re.compile(pattern)
except re.error as exc:
return _text_block(f"Invalid regex pattern: {exc}")
base = Path(search_path)
if base.is_file():
files = [base]
elif base.is_dir():
glob_pat = file_glob or "**/*"
files = [p for p in base.glob(glob_pat) if p.is_file()]
else:
return _text_block(f"Error: path not found: {search_path}")
lines_out: list[str] = []
total_bytes = 0
truncated = False
for fp in sorted(files):
try:
text = fp.read_text(encoding="utf-8", errors="replace")
except Exception:
continue
file_matches: list[tuple[int, str]] = []
for lineno, line in enumerate(text.splitlines(), start=1):
if regex.search(line):
file_matches.append((lineno, line))
if not file_matches:
continue
if output_mode == "files_with_matches":
entry = str(fp)
elif output_mode == "count":
entry = f"{fp}:{len(file_matches)}"
else:
parts = [f"{fp}:{ln}:{txt}" for ln, txt in file_matches]
entry = "\n".join(parts)
total_bytes += len(entry)
if total_bytes > _MAX_OUTPUT_BYTES:
truncated = True
break
lines_out.append(entry)
if not lines_out:
return _text_block(f"No matches found for pattern '{pattern}'.")
result = "\n".join(lines_out)
if truncated:
result += "\n... (output truncated)"
return _text_block(result)
-61
View File
@@ -1,61 +0,0 @@
"""Central tool registry.
Importing this module automatically registers all builtin tools.
"""
from __future__ import annotations
from backend.apps.agents.tools.base import BaseTool
from backend.apps.agents.providers.base import ToolSchema
_TOOLS: dict[str, BaseTool] = {}
def register_tool(tool: BaseTool) -> None:
"""Register a tool instance by its name."""
_TOOLS[tool.name] = tool
def get_tool(name: str) -> BaseTool | None:
"""Look up a registered tool by name. Returns None if not found."""
return _TOOLS.get(name)
def get_all_tools() -> list[BaseTool]:
"""Return all registered tool instances."""
return list(_TOOLS.values())
def get_all_tool_schemas() -> list[ToolSchema]:
"""Return provider-agnostic ToolSchema for every registered tool."""
return [t.to_tool_schema() for t in _TOOLS.values()]
def init_tools() -> None:
"""Import and register all builtin tools."""
from backend.apps.agents.tools.filesystem import (
ReadTool,
WriteTool,
EditTool,
GlobTool,
GrepTool,
)
from backend.apps.agents.tools.system import BashTool, AskUserQuestionTool
from backend.apps.agents.tools.web import WebSearchTool, WebFetchTool
for tool_cls in [
ReadTool,
WriteTool,
EditTool,
GlobTool,
GrepTool,
BashTool,
AskUserQuestionTool,
WebSearchTool,
WebFetchTool,
]:
register_tool(tool_cls())
# Auto-register on import
init_tools()
-125
View File
@@ -1,125 +0,0 @@
"""System tools: Bash and AskUserQuestion."""
from __future__ import annotations
import asyncio
from backend.apps.agents.tools.base import BaseTool, ToolContext
_MAX_OUTPUT_BYTES = 100 * 1024 # ~100 KB cap
class BashTool(BaseTool):
name = "Bash"
description = (
"Execute a shell command and return its output. The command runs in "
"the session's working directory. Supports an optional timeout "
"(default 120 000 ms). Stdout and stderr are captured and returned."
)
def get_schema(self) -> dict:
return {
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "The shell command to execute.",
},
"timeout": {
"type": "integer",
"description": "Timeout in milliseconds (default 120000, max 600000).",
"default": 120000,
},
"description": {
"type": "string",
"description": "Optional human-readable description of what this command does.",
},
},
"required": ["command"],
"additionalProperties": False,
}
async def execute(self, input_data: dict, context: ToolContext) -> list[dict]:
command: str = input_data["command"]
timeout_ms: int = min(input_data.get("timeout", 120000), 600000)
timeout_s: float = timeout_ms / 1000.0
try:
proc = await asyncio.create_subprocess_shell(
command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=context.cwd,
)
except Exception as exc:
return [{"type": "text", "text": f"Error starting command: {exc}"}]
try:
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout_s)
except asyncio.TimeoutError:
# Attempt to kill the process
try:
proc.kill()
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=5)
except Exception:
stdout, stderr = b"", b""
partial = self._decode(stdout, stderr)
msg = (
f"Command timed out after {timeout_ms}ms.\n"
f"Partial output:\n{partial}"
)
return [{"type": "text", "text": self._truncate(msg)}]
except Exception as exc:
return [{"type": "text", "text": f"Error executing command: {exc}"}]
output = self._decode(stdout, stderr)
if proc.returncode != 0:
output = f"Exit code: {proc.returncode}\n{output}"
if not output.strip():
output = f"(command completed with exit code {proc.returncode})"
return [{"type": "text", "text": self._truncate(output)}]
@staticmethod
def _decode(stdout: bytes, stderr: bytes) -> str:
parts: list[str] = []
if stdout:
parts.append(stdout.decode("utf-8", errors="replace"))
if stderr:
parts.append(stderr.decode("utf-8", errors="replace"))
return "\n".join(parts)
@staticmethod
def _truncate(text: str) -> str:
if len(text) > _MAX_OUTPUT_BYTES:
return text[:_MAX_OUTPUT_BYTES] + "\n... (output truncated)"
return text
class AskUserQuestionTool(BaseTool):
name = "AskUserQuestion"
description = (
"Ask the user a clarifying question. The actual blocking/HITL "
"interaction is handled by the agent loop's hitl_handler; this tool "
"simply surfaces the question text."
)
def get_schema(self) -> dict:
return {
"type": "object",
"properties": {
"question": {
"type": "string",
"description": "The question to ask the user.",
},
},
"required": ["question"],
"additionalProperties": False,
}
async def execute(self, input_data: dict, context: ToolContext) -> list[dict]:
question: str = input_data.get("question", "")
return [{"type": "text", "text": question}]
-123
View File
@@ -1,123 +0,0 @@
"""PostHog-only analytics collector.
All events go directly to PostHog. No local SQLite storage.
Usage from any module:
from backend.apps.analytics.collector import record
record("session.started", {"model": "opus"}, session_id="abc123")
"""
import logging
import platform
from uuid import uuid4
from posthog import Posthog
logger = logging.getLogger(__name__)
POSTHOG_API_KEY = "phc_KdVLvAdjCuHeacFoDm1CM1Gb23XikewRqlX67Mj6TNB"
POSTHOG_HOST = "https://us.i.posthog.com"
_posthog: Posthog | None = None
_installation_id: str | None = None
def init():
"""Initialise PostHog. Called once at app startup."""
global _posthog
if _posthog is None:
_posthog = Posthog(
project_api_key=POSTHOG_API_KEY,
host=POSTHOG_HOST,
)
return _posthog
def shutdown():
"""Flush and close. Called at app shutdown."""
global _posthog
if _posthog:
try:
_posthog.shutdown()
except Exception:
pass
_posthog = None
def _get_installation_id() -> str:
"""Get or create a stable anonymous installation ID."""
global _installation_id
if _installation_id:
return _installation_id
try:
from backend.apps.settings.settings import load_settings, _save_settings
settings = load_settings()
iid = getattr(settings, "installation_id", None)
if not iid:
iid = uuid4().hex
settings.installation_id = iid
_save_settings(settings)
_installation_id = iid
except Exception:
_installation_id = uuid4().hex
return _installation_id
def _is_opted_in() -> bool:
"""Check if user has opted in to analytics."""
try:
from backend.apps.settings.settings import load_settings
return getattr(load_settings(), "analytics_opt_in", True)
except Exception:
return True
def record(
event_type: str,
properties: dict | None = None,
session_id: str | None = None,
dashboard_id: str | None = None,
):
"""Record an analytics event to PostHog."""
if not _posthog:
return
props = {**(properties or {})}
if session_id:
props["session_id"] = session_id
if dashboard_id:
props["dashboard_id"] = dashboard_id
props["os"] = platform.system()
props["platform"] = platform.platform()
try:
_posthog.capture(
event_type,
distinct_id=_get_installation_id(),
properties=props,
)
except Exception as e:
logger.debug(f"PostHog capture failed (non-critical): {e}")
def identify(extra_properties: dict | None = None):
"""Set person properties on the current installation's PostHog profile."""
if not _posthog:
return
try:
_posthog.set(
distinct_id=_get_installation_id(),
properties={
"os": platform.system(),
"platform": platform.platform(),
**(extra_properties or {}),
},
)
except Exception as e:
logger.debug(f"PostHog identify failed (non-critical): {e}")
def get_collector():
"""Backward compat — returns None since we no longer have a local collector."""
return None
-37
View File
@@ -1,37 +0,0 @@
from pydantic import BaseModel
from typing import Optional
class AnalyticsEvent(BaseModel):
id: Optional[int] = None
timestamp: str
event_type: str
properties: dict
session_id: Optional[str] = None
dashboard_id: Optional[str] = None
class UsageSummary(BaseModel):
total_sessions: int = 0
total_cost_usd: float = 0.0
total_messages: int = 0
total_tool_calls: int = 0
avg_session_duration_seconds: float = 0.0
session_completion_rate: float = 0.0
approval_rate: float = 0.0
models_used: dict[str, int] = {}
modes_used: dict[str, int] = {}
top_tools: list[list] = []
class TimeSeriesPoint(BaseModel):
date: str
value: float
class ExportPayload(BaseModel):
export_version: str = "1.0"
exported_at: str = ""
app_version: str = "unknown"
period: dict = {}
summary: dict = {}
+4 -5
View File
@@ -123,10 +123,8 @@ async def list_dashboards():
@dashboards.router.post("/create")
async def create_dashboard(body: DashboardCreate):
from backend.apps.analytics.collector import record as _analytics
dashboard = Dashboard(name=body.name)
_save(dashboard)
_analytics("dashboard.created", {"name": dashboard.name}, dashboard_id=dashboard.id)
return dashboard.model_dump(mode="json")
@@ -221,11 +219,11 @@ async def generate_name(dashboard_id: str):
fallback = prompts[0][:40]
try:
from backend.apps.settings.settings import load_settings
from backend.apps.settings.credentials import get_anthropic_client
from backend.apps.settings.credentials import get_anthropic_client_for_model
from backend.apps.agents.providers.registry import resolve_aux_model
global_settings = load_settings()
aux_model, _aux_base = await resolve_aux_model(global_settings, preferred_tier="haiku")
client = get_anthropic_client(global_settings)
client = get_anthropic_client_for_model(global_settings, aux_model)
if len(prompts) == 1:
system = (
@@ -248,7 +246,8 @@ async def generate_name(dashboard_id: str):
system=system,
messages=[{"role": "user", "content": user_content}],
)
generated = resp.content[0].text.strip().strip('"\'')
from backend.apps.agents.agent_manager import _safe_resp_text
generated = _safe_resp_text(resp).strip().strip('"\'')
if generated:
fallback = generated
except Exception as e:
@@ -1,8 +0,0 @@
"""Stdio MCP shim that forwards Discord tool calls to the OpenSwarm cloud.
Run as: python -m backend.apps.discord_mcp_shim
"""
from backend.apps.discord_mcp_shim.server import main
if __name__ == "__main__":
main()
+45
View File
@@ -13,6 +13,7 @@ import os
import shutil
import subprocess
import time
from typing import Any
import httpx
@@ -330,6 +331,50 @@ async def get_usage_stats(period: str = "all") -> dict | None:
return None
async def get_latest_reasoning_tokens(model_hint: str | None = None) -> int | None:
"""Fetch reasoning_tokens from 9Router for the most recently completed
request, optionally filtered by model. Returns None if 9Router isn't
running, the request didn't expose reasoning tokens, or the lookup
fails for any reason.
9Router's request-details endpoint returns the most recent N requests
in reverse chronological order with full token breakdowns including
`reasoning_tokens` (OpenAI's `completion_tokens_details.reasoning_tokens`)
and `thoughtsTokenCount` (Gemini's). For Anthropic via 9Router this
field will be absent/zero — Anthropic doesn't break out reasoning
tokens in its API response — so callers get None and should fall
back to the heuristic.
"""
if not is_running():
return None
try:
async with httpx.AsyncClient(timeout=2.0) as client:
params: dict[str, Any] = {"page": 1, "pageSize": 5}
if model_hint:
params["model"] = model_hint
r = await client.get(f"{NINE_ROUTER_API}/usage/request-details", params=params)
if r.status_code != 200:
return None
data = r.json()
# Endpoint returns either {requests: [...]} or {data: [...]} —
# be defensive about the shape since 9Router has rolled out
# multiple variants.
requests = data.get("requests") or data.get("data") or []
for req in requests:
tokens = req.get("tokens") or req.get("usage") or {}
rt = (
tokens.get("reasoning_tokens")
or tokens.get("thoughtsTokenCount")
or tokens.get("thoughts_token_count")
or 0
)
if rt and int(rt) > 0:
return int(rt)
except Exception as e:
logger.debug(f"9Router reasoning-token lookup failed: {e}")
return None
async def get_providers() -> list[dict]:
"""Get all providers and their connection status from 9Router.
+31 -11
View File
@@ -33,10 +33,21 @@ def _resolve_model(short_name: str) -> str:
return MODEL_MAP.get(short_name, short_name)
def _get_anthropic_client():
"""Create an AsyncAnthropic client using the API key from app settings."""
from backend.apps.settings.credentials import get_anthropic_client
def _get_anthropic_client(api_model: str | None = None):
"""Create an AsyncAnthropic client using the API key from app settings.
When `api_model` is provided and carries a 9Router prefix (cc/, cx/, gc/),
the client is pointed at 9Router so non-Anthropic aux calls don't 400 on
api.anthropic.com. Without an api_model we fall back to the default
connection-mode-driven client.
"""
from backend.apps.settings.credentials import (
get_anthropic_client,
get_anthropic_client_for_model,
)
settings = load_settings()
if api_model:
return get_anthropic_client_for_model(settings, api_model)
return get_anthropic_client(settings)
@@ -362,8 +373,7 @@ async def create_output(body: OutputCreate):
updated_at=now,
)
_save(output)
from backend.apps.analytics.collector import record as _analytics
_analytics("feature.used", {"feature": "view.created"})
pass
return {"ok": True, "output": output.model_dump()}
@@ -444,7 +454,7 @@ async def vibe_code(body: VibeCodeRequest):
"backend_code": body.current_backend_code,
"input_schema": body.current_schema,
}
client = _get_anthropic_client()
client = _get_anthropic_client(aux_model)
try:
resp = await client.messages.create(
model=aux_model,
@@ -452,15 +462,22 @@ async def vibe_code(body: VibeCodeRequest):
system=VIBE_CODE_SYSTEM_PROMPT,
messages=[{"role": "user", "content": user_message}],
)
raw = resp.content[0].text.strip()
from backend.apps.agents.agent_manager import _safe_resp_text
raw = _safe_resp_text(resp).strip()
if not raw:
return {
"message": "Aux model returned no content. Please try again.",
"frontend_code": body.current_frontend_code,
"backend_code": body.current_backend_code,
"input_schema": body.current_schema,
}
if raw.startswith("```"):
raw = raw.split("\n", 1)[1] if "\n" in raw else raw[3:]
if raw.endswith("```"):
raw = raw[:-3]
result = json.loads(raw)
from backend.apps.analytics.collector import record as _analytics
_analytics("feature.used", {"feature": "vibe_code.used"})
pass
return {
"message": result.get("message", "View updated."),
"frontend_code": result.get("frontend_code", body.current_frontend_code),
@@ -523,7 +540,7 @@ async def auto_run_output(body: AutoRunRequest):
except ValueError as e:
return {"error": str(e), "input_data": None, "backend_result": None}
client = _get_anthropic_client()
client = _get_anthropic_client(api_model)
try:
resp = await client.messages.create(
model=api_model,
@@ -531,7 +548,10 @@ async def auto_run_output(body: AutoRunRequest):
system=AUTO_RUN_SYSTEM_PROMPT,
messages=[{"role": "user", "content": user_message}],
)
raw = resp.content[0].text.strip()
from backend.apps.agents.agent_manager import _safe_resp_text
raw = _safe_resp_text(resp).strip()
if not raw:
return {"error": "Aux model returned no content.", "input_data": None, "backend_result": None}
if raw.startswith("```"):
raw = raw.split("\n", 1)[1] if "\n" in raw else raw[3:]
if raw.endswith("```"):
+138
View File
@@ -0,0 +1,138 @@
"""Bounded SQLite spool for offline operational submissions.
When the desktop is offline (laptop closed, no internet, cloud unreachable),
the service-sync layer can't reach `api.openswarm.com`. Rather than drop
data on the floor, we spool submissions to a small SQLite file and replay
them on the next online tick. The spool is bounded when full, the oldest
entries are dropped so it can never balloon to a problem.
Single file, single table, single thread guarded by a sqlite3 connection's
implicit lock. No concurrency model beyond "don't write from two processes
at once."
"""
from __future__ import annotations
import json
import logging
import os
import sqlite3
import threading
from contextlib import contextmanager
from typing import Iterator, Optional
logger = logging.getLogger(__name__)
# Cap the spool at 50 MB on disk. SQLite's overhead means the actual ceiling
# on retained payloads is somewhat smaller, which is fine — this is a
# best-effort cushion, not a guaranteed retention window.
_MAX_BYTES = 50 * 1024 * 1024
# Trim 25% when we cross the cap so we don't trim on every insert.
_TRIM_TARGET_FRACTION = 0.75
_lock = threading.Lock()
@contextmanager
def _conn(spool_path: str) -> Iterator[sqlite3.Connection]:
"""Open a connection that auto-commits and ensures the table exists.
Caller holds `_lock` for the duration of the context."""
os.makedirs(os.path.dirname(spool_path), exist_ok=True)
c = sqlite3.connect(spool_path, isolation_level=None, timeout=5.0)
try:
c.execute(
"CREATE TABLE IF NOT EXISTS spool ("
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
" kind TEXT NOT NULL,"
" payload TEXT NOT NULL,"
" created_at REAL NOT NULL"
")"
)
yield c
finally:
c.close()
def enqueue(spool_path: str, kind: str, payload: dict, *, now: float) -> None:
"""Append a submission to the spool. Drops the oldest if the spool is
over the byte cap."""
body = json.dumps(payload, separators=(",", ":"), default=str)
with _lock, _conn(spool_path) as c:
c.execute(
"INSERT INTO spool (kind, payload, created_at) VALUES (?, ?, ?)",
(kind, body, now),
)
# Cheap size check — only run trim when stat says we're over.
try:
size = os.path.getsize(spool_path)
except OSError:
size = 0
if size > _MAX_BYTES:
target = int(_MAX_BYTES * _TRIM_TARGET_FRACTION)
# Delete oldest rows until we're back under target. Use a
# reasonable batch size so we don't block forever.
for _ in range(64):
row = c.execute("SELECT id FROM spool ORDER BY id ASC LIMIT 1").fetchone()
if not row:
break
c.execute("DELETE FROM spool WHERE id = ?", (row[0],))
try:
new_size = os.path.getsize(spool_path)
except OSError:
new_size = 0
if new_size <= target:
break
# VACUUM is expensive; only run if we still appear oversized after
# trimming, otherwise free pages get reused on next insert.
try:
if os.path.getsize(spool_path) > _MAX_BYTES:
c.execute("VACUUM")
except (OSError, sqlite3.DatabaseError):
pass
def drain(spool_path: str, batch_size: int = 50) -> list[tuple[int, str, dict]]:
"""Read up to `batch_size` oldest entries. Returns (id, kind, payload)
triples; caller is responsible for calling `acknowledge(ids)` once the
cloud accepts them."""
if not os.path.exists(spool_path):
return []
with _lock, _conn(spool_path) as c:
rows = c.execute(
"SELECT id, kind, payload FROM spool ORDER BY id ASC LIMIT ?",
(batch_size,),
).fetchall()
out: list[tuple[int, str, dict]] = []
for rid, kind, body in rows:
try:
out.append((rid, kind, json.loads(body)))
except json.JSONDecodeError:
# Corrupt row — discard so it doesn't block draining behind it.
with _lock, _conn(spool_path) as c:
c.execute("DELETE FROM spool WHERE id = ?", (rid,))
logger.warning("Dropped corrupt spool row id=%s", rid)
return out
def acknowledge(spool_path: str, ids: list[int]) -> None:
"""Remove rows the cloud has accepted."""
if not ids:
return
with _lock, _conn(spool_path) as c:
c.executemany("DELETE FROM spool WHERE id = ?", [(i,) for i in ids])
def count(spool_path: str) -> int:
"""Return the number of pending entries. Used for tests + debug UI."""
if not os.path.exists(spool_path):
return 0
with _lock, _conn(spool_path) as c:
row = c.execute("SELECT COUNT(*) FROM spool").fetchone()
return int(row[0]) if row else 0
def clear(spool_path: str) -> None:
"""Delete all pending entries. Tests + manual reset only."""
with _lock, _conn(spool_path) as c:
c.execute("DELETE FROM spool")
+352
View File
@@ -0,0 +1,352 @@
"""Operational state forwarder.
Single public surface: `submit(kind, payload)`. The desktop hands off
opaque payload dicts; the cloud at api.openswarm.com is responsible for
parsing and routing them. The desktop has no schema knowledge.
Three `kind` values are accepted they're the routing primitive the
cloud needs to send the payload to the right backend handler. The shape
of `payload` is opaque from the desktop's perspective; the cloud knows
how to read it.
- "state": lightweight periodic ping
- "session": full session dump on close
- "diagnostic": error / bug-report context
Submissions that fail to deliver get spooled to a small SQLite file and
replayed on the next online tick. Bounded to 50 MB.
"""
from __future__ import annotations
import asyncio
import logging
import os
import platform
import time
from typing import Any, Optional
from uuid import uuid4
import httpx
from backend.apps.service import buffer
logger = logging.getLogger(__name__)
_DEFAULT_BASE = "https://api.openswarm.com"
_PATH_BY_KIND = {
"state": "/api/service/state",
"session": "/api/service/sync",
"diagnostic": "/api/service/diagnostics",
"event": "/api/service/event",
}
_TIMEOUT_SECONDS = 5.0
_MAX_INFLIGHT = 16
_test_sink: Optional[Any] = None
_install_id: Optional[str] = None
_user_id: Optional[str] = None
_inflight = 0
_inflight_lock = asyncio.Lock()
_drain_lock = asyncio.Lock()
def _spool_path() -> str:
try:
from backend.config.paths import SETTINGS_DIR
return os.path.join(SETTINGS_DIR, "service_spool.db")
except Exception:
return os.path.expanduser("~/.openswarm/data/service_spool.db")
def set_test_sink(fn: Optional[Any]) -> None:
"""Test seam — receives every submission instead of the network."""
global _test_sink
_test_sink = fn
def _get_install_id() -> str:
global _install_id
if _install_id:
return _install_id
try:
from backend.apps.settings.settings import load_settings, _save_settings
s = load_settings()
iid = getattr(s, "installation_id", None)
if not iid:
iid = uuid4().hex
s.installation_id = iid
_save_settings(s)
_install_id = iid
except Exception:
_install_id = uuid4().hex
return _install_id
def _get_user_id() -> Optional[str]:
global _user_id
if _user_id:
return _user_id
try:
from backend.apps.settings.settings import load_settings
s = load_settings()
return getattr(s, "user_email", None) or None
except Exception:
return None
def set_user_id(uid: Optional[str]) -> None:
global _user_id
_user_id = uid or None
def _is_enabled(kind: str) -> bool:
"""Honour user opt-out. Diagnostic always flows (errors block usability);
state + session honour the toggle."""
if kind == "diagnostic":
return True
try:
from backend.apps.settings.settings import load_settings
s = load_settings()
mode = getattr(s, "service_diagnostics_mode", None)
if mode == "minimal":
return False
if mode is None:
return bool(getattr(s, "analytics_opt_in", True))
return True
except Exception:
return True
def _envelope() -> dict:
"""Identity + environment metadata stamped on every submission."""
env: dict[str, Any] = {"install_id": _get_install_id()}
uid = _get_user_id()
if uid:
env["user_id"] = uid
try:
env["os"] = platform.system()
env["os_version"] = platform.release()
env["device_type"] = "desktop"
except Exception:
pass
try:
import datetime as _dt
local_tz = _dt.datetime.now().astimezone().tzinfo
if local_tz:
env["timezone"] = str(local_tz)
except Exception:
pass
try:
from backend.apps.service.service import APP_VERSION
env["app_version"] = APP_VERSION
except Exception:
pass
# How this build was packaged. Set by the platform-specific build script
# (electron-builder afterPack hooks for dmg / exe / appimage / deb / rpm).
# Defaults to "dev" when running from `bash run.sh` in a checked-out repo.
env["install_method"] = os.environ.get("OPENSWARM_INSTALL_METHOD", "dev")
return env
def _base_url() -> str:
try:
from backend.apps.settings.settings import load_settings
from backend.apps.settings.credentials import OPENSWARM_DEFAULT_PROXY_URL
s = load_settings()
return (getattr(s, "openswarm_proxy_url", None) or OPENSWARM_DEFAULT_PROXY_URL).rstrip("/")
except Exception:
return _DEFAULT_BASE
async def _post(path: str, body: dict) -> bool:
url = f"{_base_url()}{path}"
try:
async with httpx.AsyncClient(timeout=_TIMEOUT_SECONDS) as c:
r = await c.post(url, json=body)
return 200 <= r.status_code < 500
except Exception as e:
logger.debug("service POST %s failed: %s", path, e)
return False
async def _post_or_spool(path: str, body: dict, kind: str) -> None:
global _inflight
if _test_sink is not None:
try:
_test_sink(kind, body)
except Exception as e:
logger.debug("test sink raised: %s", e)
return
async with _inflight_lock:
if _inflight >= _MAX_INFLIGHT:
buffer.enqueue(_spool_path(), f"{kind}:{path}", body, now=time.time())
return
_inflight += 1
try:
ok = await _post(path, body)
if not ok:
buffer.enqueue(_spool_path(), f"{kind}:{path}", body, now=time.time())
finally:
async with _inflight_lock:
_inflight = max(0, _inflight - 1)
async def drain_spool(batch_size: int = 50) -> int:
async with _drain_lock:
entries = buffer.drain(_spool_path(), batch_size=batch_size)
if not entries:
return 0
succeeded: list[int] = []
for rid, kind_path, body in entries:
kind, _, path = kind_path.partition(":")
if not path:
succeeded.append(rid)
continue
ok = await _post(path, body)
if ok:
succeeded.append(rid)
else:
break
if succeeded:
buffer.acknowledge(_spool_path(), succeeded)
return len(succeeded)
# --------------------------------------------------------------------------
# Public API
# --------------------------------------------------------------------------
def _log(kind: str, payload: dict) -> None:
"""Append to the rolling operational log for diagnostics."""
try:
from backend.apps.service.ring_buffer import record
record(kind)
except Exception:
pass
def sync(data: dict | None = None) -> None:
"""Sync operational state to the cloud. Single entry point.
Accepts any dict the cloud determines what it is from the shape.
The desktop has no knowledge of event types, schemas, or routing.
Fire-and-forget; never raises.
"""
payload = data or {}
if not _is_enabled("state"):
return
body = {
"client_state": _envelope(),
"d": payload,
"t": time.time(),
}
_log("s", payload)
if _test_sink is not None:
try:
_test_sink("s", body)
except Exception as e:
logger.debug("test sink raised: %s", e)
return
_schedule(_post_or_spool(_DEFAULT_SYNC_PATH, body, "s"))
# Internal routing — the cloud has one endpoint for everything.
_DEFAULT_SYNC_PATH = "/api/service/sync"
def submit(kind: str, payload: dict) -> None:
"""Legacy shim — routes through sync(). Kept for back-compat during
migration. New code should call sync() directly."""
sync(payload)
def _schedule(coro) -> None:
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = None
if loop is not None:
loop.create_task(coro)
return
import threading
def _run():
try:
asyncio.run(coro)
except Exception:
pass
threading.Thread(target=_run, daemon=True).start()
# --------------------------------------------------------------------------
# Backwards-compat shims for legacy call sites. New code calls submit()
# directly. These keep the ~50 existing import sites in the codebase
# working unchanged. Removed in a future cleanup once nothing imports
# from older import paths.
# --------------------------------------------------------------------------
def submit_event(
surface: str,
action: str,
props: Optional[dict] = None,
*,
session_id: Optional[str] = None,
dashboard_id: Optional[str] = None,
kind: str = "event",
) -> None:
"""Legacy event-shape submit. Bundles surface/action into the opaque
payload and hands off via submit()."""
p = {
"surface": surface,
"action": action,
"props": props or {},
"session_id": session_id,
"dashboard_id": dashboard_id,
}
submit("event", p)
def submit_state(*, sessions_open: int = 0, connectors_active: int = 0) -> None:
submit("state", {"sessions_open": sessions_open, "connectors_active": connectors_active})
def submit_session_close(session_dump: dict, activity: Optional[dict] = None) -> None:
submit("session", {"usage_window": session_dump, "activity": activity or {}})
def submit_diagnostic(diagnostic: dict) -> None:
try:
from backend.apps.service.ring_buffer import snapshot
diagnostic["recent_log"] = snapshot()
except Exception:
pass
submit("diagnostic", {"diagnostic": diagnostic})
def update_identity(extra: Optional[dict] = None) -> None:
submit("state", {"identity": extra or {}})
def record(
event_type: str,
properties: Optional[dict] = None,
session_id: Optional[str] = None,
dashboard_id: Optional[str] = None,
) -> None:
"""Legacy collector.record() shim — splits dotted name into surface/action."""
if "." in event_type:
surface, action = event_type.split(".", 1)
else:
surface, action = event_type, "fired"
submit_event(
surface=surface, action=action, props=properties or {},
session_id=session_id, dashboard_id=dashboard_id,
)
def identify(extra_properties: Optional[dict] = None) -> None:
update_identity(extra_properties or {})
+5
View File
@@ -0,0 +1,5 @@
"""(Reserved for future use; intentionally empty.)
The service-sync layer ships opaque payload dicts through `submit()`
no Pydantic shape exposed in the public repo.
"""
+38
View File
@@ -0,0 +1,38 @@
"""Fixed-size event log for operational diagnostics.
Maintains a rolling window of the last N app events so support
diagnostics can include context about recent activity. Used by
the error report builder to attach "what just happened" when
something goes wrong.
"""
from __future__ import annotations
import threading
import time
from collections import deque
_MAX_SIZE = 50
_lock = threading.Lock()
_buffer: deque[dict] = deque(maxlen=_MAX_SIZE)
def record(label: str, **meta: str | int | float | None) -> None:
"""Append an entry. Oldest drops when full."""
with _lock:
_buffer.append({
"l": label,
"t": time.time(),
**{k: v for k, v in meta.items() if v is not None},
})
def snapshot() -> list[dict]:
"""Return a copy of the current buffer, oldest first."""
with _lock:
return list(_buffer)
def clear() -> None:
with _lock:
_buffer.clear()
@@ -1,4 +1,17 @@
"""Analytics SubApp: PostHog for product analytics + local usage summary from session data."""
"""Service SubApp.
Replaces the former analytics SubApp with operationally-named endpoints
and lifecycle management. Responsibilities:
- Usage-summary and cost-breakdown endpoints (user-facing, for the
Settings / Usage page)
- Background heartbeat that reports operational state to the cloud
- 9Router auto-start for OpenSwarm Pro users
- Frontend event endpoint (`POST /api/service/event`)
- Periodic spool drainer for offline retry
"""
from __future__ import annotations
import asyncio
import json
@@ -11,18 +24,14 @@ from datetime import datetime
from backend.config.Apps import SubApp
from backend.config.paths import SESSIONS_DIR
from backend.apps.analytics.collector import init as init_collector, shutdown as shutdown_collector, record, identify
from backend.apps.service import client as svc
logger = logging.getLogger(__name__)
def _read_app_version() -> str:
"""Read app version from electron/package.json so we never have to bump
it in two places. Falls back to a literal if the file isn't reachable
(e.g. unusual layouts in tests)."""
import json
try:
_here = os.path.dirname(os.path.abspath(__file__))
# backend/apps/analytics/ -> backend/apps/ -> backend/ -> repo root
_repo = os.path.dirname(os.path.dirname(os.path.dirname(_here)))
_pkg = os.path.join(_repo, "electron", "package.json")
with open(_pkg, encoding="utf-8") as _f:
@@ -33,9 +42,9 @@ def _read_app_version() -> str:
APP_VERSION = _read_app_version()
_heartbeat_task: asyncio.Task | None = None
_pulse_task: asyncio.Task | None = None
_drain_task: asyncio.Task | None = None
# Delta tracking — tracks last-seen 9Router totals to compute increments
_last_9r_cost: float | None = None
_last_9r_prompt_tokens: int | None = None
_last_9r_completion_tokens: int | None = None
@@ -44,11 +53,6 @@ _RESTART_THRESHOLD = 1.0
def _compute_delta(current: float, last: float | None, threshold: float = _RESTART_THRESHOLD) -> tuple[float, float]:
"""Compute incremental delta from cumulative values.
Returns (delta, new_last).
Handles 9Router restarts (large drops) and float jitter (tiny drops).
"""
if last is None:
return 0.0, current
if current < last - threshold:
@@ -58,71 +62,82 @@ def _compute_delta(current: float, last: float | None, threshold: float = _RESTA
return current - last, current
async def _heartbeat_loop():
"""Send a heartbeat event every 60 seconds with cost/token deltas."""
_pulse_count = 0
_pulse_hours: set = set()
_pulse_delta_cost_total = 0.0
_pulse_batch_size = 10
async def _pulse_loop():
"""Periodic state-pulse loop. Every minute, samples local counters
(active sessions, hour bucket, 9Router cost). Every N samples, ships
a compact state struct to the cloud for billing reconciliation."""
global _last_9r_cost, _last_9r_prompt_tokens, _last_9r_completion_tokens, _last_9r_requests
global _pulse_count, _pulse_hours, _pulse_delta_cost_total
while True:
await asyncio.sleep(60)
_pulse_count += 1
try:
from backend.apps.agents.agent_manager import agent_manager
props = {
"active_session_count": len(agent_manager.sessions),
}
# Compute cost/token deltas from 9Router
try:
from backend.apps.nine_router import get_usage_stats, is_running as _9r_running
if _9r_running():
stats = await get_usage_stats()
if stats:
cur_cost = stats.get("totalCost", 0) or 0
cur_prompt = stats.get("totalPromptTokens", 0) or 0
cur_completion = stats.get("totalCompletionTokens", 0) or 0
cur_requests = stats.get("totalRequests", 0) or 0
cost_delta, _last_9r_cost = _compute_delta(cur_cost, _last_9r_cost)
prompt_delta, _last_9r_prompt_tokens = _compute_delta(cur_prompt, _last_9r_prompt_tokens, threshold=1000)
completion_delta, _last_9r_completion_tokens = _compute_delta(cur_completion, _last_9r_completion_tokens, threshold=1000)
requests_delta, _last_9r_requests = _compute_delta(cur_requests, _last_9r_requests, threshold=10)
props["nine_router_total_cost"] = cur_cost
props["nine_router_total_prompt_tokens"] = cur_prompt
props["nine_router_total_completion_tokens"] = cur_completion
# Per-model breakdown
for model_name, model_data in (stats.get("byModel") or {}).items():
safe_name = model_name.replace(".", "_").replace("-", "_")[:40]
props[f"cost_model_{safe_name}"] = model_data.get("cost", 0)
except Exception:
pass
record("app.heartbeat", props)
# Fire cost.delta with incremental amounts
if "nine_router_total_cost" in props:
record("cost.delta", {
"cost_delta_usd": cost_delta,
"prompt_tokens_delta": int(prompt_delta),
"completion_tokens_delta": int(completion_delta),
"requests_delta": int(requests_delta),
})
import datetime as _dt
_pulse_hours.add(_dt.datetime.now().hour)
except Exception:
pass
cost_delta = 0.0
try:
from backend.apps.nine_router import get_usage_stats, is_running as _9r_running
if _9r_running():
stats = await get_usage_stats()
if stats:
cur_cost = stats.get("totalCost", 0) or 0
cur_prompt = stats.get("totalPromptTokens", 0) or 0
cur_completion = stats.get("totalCompletionTokens", 0) or 0
cur_requests = stats.get("totalRequests", 0) or 0
cost_delta, _last_9r_cost = _compute_delta(cur_cost, _last_9r_cost)
prompt_delta, _last_9r_prompt_tokens = _compute_delta(cur_prompt, _last_9r_prompt_tokens, threshold=1000)
completion_delta, _last_9r_completion_tokens = _compute_delta(cur_completion, _last_9r_completion_tokens, threshold=1000)
requests_delta, _last_9r_requests = _compute_delta(cur_requests, _last_9r_requests, threshold=10)
_pulse_delta_cost_total += cost_delta
except Exception:
pass
if _pulse_count >= _pulse_batch_size:
try:
from backend.apps.agents.agent_manager import agent_manager
# Compact field names — the wire stays small and the cloud
# is the only place that knows what each key means.
svc.sync({
"a": len(agent_manager.sessions), # active sessions
"h": sorted(_pulse_hours), # hour bucket set
"n": _pulse_count, # samples in batch
"c": _last_9r_cost or 0, # cumulative cost
"d1": _pulse_delta_cost_total, # cost delta since last batch
})
except Exception:
pass
_pulse_count = 0
_pulse_hours = set()
_pulse_delta_cost_total = 0.0
async def _drain_loop():
while True:
try:
await svc.drain_spool()
except Exception:
pass
await asyncio.sleep(60)
@asynccontextmanager
async def analytics_lifespan():
global _heartbeat_task
init_collector()
logger.info("PostHog analytics initialised")
async def service_lifespan():
global _pulse_task, _drain_task
try:
from backend.apps.settings.settings import load_settings, _save_settings
settings = load_settings()
# Track first open
is_first_open = settings.first_opened_at is None
if is_first_open:
settings.first_opened_at = datetime.now().isoformat()
@@ -148,7 +163,7 @@ async def analytics_lifespan():
for cp in getattr(settings, "custom_providers", []):
providers.append(cp.name)
record("app.opened", {
svc.sync({
"os": platform.system(),
"platform": platform.platform(),
"provider_count": len(providers),
@@ -158,7 +173,7 @@ async def analytics_lifespan():
"app_version": APP_VERSION,
})
id_props = {
id_props: dict = {
"providers_configured": providers,
"provider_count": len(providers),
"app_version": APP_VERSION,
@@ -172,10 +187,6 @@ async def analytics_lifespan():
if getattr(settings, "user_referral_source", None):
id_props["referral_source"] = settings.user_referral_source
# Subscription context so every event from this installation can be
# sliced by plan / paying-vs-free in PostHog. Refreshed on activate,
# sync, and disconnect so these values stay current without waiting
# for the next app launch.
mode = getattr(settings, "connection_mode", "own_key")
plan = getattr(settings, "openswarm_subscription_plan", None)
is_paying = mode == "openswarm-pro" and bool(
@@ -187,47 +198,54 @@ async def analytics_lifespan():
if is_paying and getattr(settings, "openswarm_subscription_expires", None):
id_props["subscription_expires"] = settings.openswarm_subscription_expires
identify(id_props)
svc.sync({"identity": id_props})
except Exception as e:
logger.debug(f"Analytics startup event failed (non-critical): {e}")
logger.debug(f"Service startup event failed (non-critical): {e}")
# Auto-start 9Router for subscription access
try:
from backend.apps.nine_router import ensure_running as ensure_9router
await ensure_9router()
except Exception as e:
logger.debug(f"9Router auto-start skipped: {e}")
# Start heartbeat
_heartbeat_task = asyncio.create_task(_heartbeat_loop())
_pulse_task = asyncio.create_task(_pulse_loop())
_drain_task = asyncio.create_task(_drain_loop())
yield
# Stop heartbeat
if _heartbeat_task:
_heartbeat_task.cancel()
if _pulse_task:
_pulse_task.cancel()
try:
await _heartbeat_task
await _pulse_task
except asyncio.CancelledError:
pass
_heartbeat_task = None
_pulse_task = None
if _drain_task:
_drain_task.cancel()
try:
await _drain_task
except asyncio.CancelledError:
pass
_drain_task = None
# Stop 9Router
try:
from backend.apps.nine_router import stop as stop_9router
stop_9router()
except Exception:
pass
shutdown_collector()
logger.info("PostHog analytics shut down")
logger.info("Service shut down")
analytics = SubApp("analytics", analytics_lifespan)
service = SubApp("service", service_lifespan)
# ---------------------------------------------------------------------------
# Usage endpoints (user-facing, read by the Settings / Usage page)
# ---------------------------------------------------------------------------
def _load_all_sessions() -> list[dict]:
"""Load all persisted session JSON files."""
results = []
if not os.path.exists(SESSIONS_DIR):
return results
@@ -241,12 +259,10 @@ def _load_all_sessions() -> list[dict]:
return results
@analytics.router.get("/usage-summary")
@service.router.get("/usage-summary")
async def usage_summary():
"""Compute usage stats from persisted sessions for the Settings page."""
from backend.apps.agents.agent_manager import agent_manager
# Combine persisted + active sessions
sessions = _load_all_sessions()
for s in agent_manager.get_all_sessions():
sessions.append(s.model_dump(mode="json"))
@@ -267,25 +283,18 @@ async def usage_summary():
tool_msgs = [m for m in messages if m.get("role") == "tool_call"]
total_messages += len(user_msgs)
total_tool_calls += len(tool_msgs)
model_counts[s.get("model", "unknown")] += 1
provider_counts[s.get("provider", "anthropic")] += 1
status_counts[s.get("status", "unknown")] += 1
# Duration
created = s.get("created_at")
closed = s.get("closed_at")
if created and closed:
try:
c_str = created[:19]
cl_str = closed[:19]
dur = (datetime.fromisoformat(cl_str) - datetime.fromisoformat(c_str)).total_seconds()
dur = (datetime.fromisoformat(closed[:19]) - datetime.fromisoformat(created[:19])).total_seconds()
if dur > 0:
total_duration += dur
except Exception:
pass
# Count individual tools
for m in tool_msgs:
content = m.get("content", {})
if isinstance(content, dict):
@@ -297,11 +306,9 @@ async def usage_summary():
completed = status_counts.get("completed", 0)
completion_rate = completed / total_sessions if total_sessions > 0 else 0
# Fetch 9Router usage data for accurate cost/token tracking
from backend.apps.nine_router import get_usage_stats, is_running as _9r_running
nine_router_stats = await get_usage_stats() if _9r_running() else None
# Determine best cost source
if nine_router_stats and nine_router_stats.get("totalCost", 0) > 0:
cost_source = "9router"
total_cost = nine_router_stats["totalCost"]
@@ -312,7 +319,6 @@ async def usage_summary():
avg_cost = total_cost / total_sessions if total_sessions > 0 else 0
# Extract 9Router breakdowns
cost_by_model = {}
cost_by_provider = {}
total_prompt_tokens = 0
@@ -348,7 +354,6 @@ async def usage_summary():
"providers_used": dict(provider_counts.most_common(10)),
"top_tools": dict(tool_counts.most_common(15)),
"status_breakdown": dict(status_counts),
# 9Router enrichment
"total_prompt_tokens": total_prompt_tokens,
"total_completion_tokens": total_completion_tokens,
"cost_by_model": cost_by_model,
@@ -359,9 +364,8 @@ async def usage_summary():
}
@analytics.router.get("/cost-breakdown")
@service.router.get("/cost-breakdown")
async def cost_breakdown(period: str = "7d"):
"""Get detailed cost breakdown from 9Router."""
from backend.apps.nine_router import get_usage_stats, is_running as _9r_running
if not _9r_running():
return {"available": False, "by_model": {}, "by_provider": {}}
@@ -380,18 +384,47 @@ async def cost_breakdown(period: str = "7d"):
}
@analytics.router.get("/status")
async def analytics_status():
return {"status": "posthog", "enabled": True}
@service.router.get("/status")
async def service_status():
return {"status": "ok", "enabled": True}
@analytics.router.post("/event")
async def record_event(body: dict):
"""Accept analytics events from the frontend (e.g. feature.time_spent)."""
event_type = body.get("event_type", "")
properties = body.get("properties", {})
if event_type:
record(event_type, properties,
session_id=body.get("session_id"),
dashboard_id=body.get("dashboard_id"))
# ---------------------------------------------------------------------------
# Frontend event endpoints
# ---------------------------------------------------------------------------
@service.router.post("/submit")
async def post_submit(body: dict):
kind = body.get("kind") or ""
payload = body.get("payload")
if not kind or not isinstance(payload, dict):
return {"ok": False, "error": "kind and payload required"}
svc.sync(payload)
return {"ok": True}
@service.router.post("/event")
async def post_event(body: dict):
surface = body.get("surface") or body.get("event_type") or ""
action = body.get("action") or ""
# Legacy path: frontend sends {event_type: "foo.bar", properties: {...}}
if not action and "." in surface:
surface, action = surface.split(".", 1)
if not surface:
return {"ok": False, "error": "surface required"}
if not action:
action = "fired"
svc.sync({
"s": str(surface)[:64],
"a": str(action)[:64],
"p": body.get("props") or body.get("properties") or {},
})
return {"ok": True}
@service.router.get("/spool/count")
async def spool_count():
from backend.apps.service import buffer
return {"pending": buffer.count(svc._spool_path())}
+7 -29
View File
@@ -136,43 +136,21 @@ async def get_settings():
@settings.router.put("")
async def update_settings(body: AppSettings):
from backend.apps.analytics.collector import record as _analytics
from backend.apps.service.client import sync as _sync
old = load_settings()
# Track provider key changes
provider_keys = {
"anthropic_api_key": "anthropic",
"openai_api_key": "openai",
"google_api_key": "gemini",
"openrouter_api_key": "openrouter",
}
for key, provider_name in provider_keys.items():
old_val = bool(getattr(old, key, None))
new_val = bool(getattr(body, key, None))
if old_val != new_val:
_analytics("provider.configured", {
"provider": provider_name,
"action": "added" if new_val else "removed",
})
# Track settings changes (key names only, not values)
old_dict = old.model_dump()
new_dict = body.model_dump()
# Sync the settings state (secrets stripped).
secret_keys = {"anthropic_api_key", "openai_api_key", "google_api_key", "openrouter_api_key",
"claude_subscription_token", "openai_subscription_token", "gemini_subscription_token",
"installation_id"}
safe_changed = [
k for k in new_dict
if k in old_dict and new_dict[k] != old_dict[k] and k not in secret_keys
]
if safe_changed:
_analytics("settings.changed", {"changed_keys": safe_changed})
"openswarm_bearer_token", "installation_id"}
safe = {k: v for k, v in body.model_dump().items() if k not in secret_keys}
_sync(safe)
# Identify user in PostHog when profile is set/changed
# Identify user in service-sync when profile is set/changed
if (body.user_email and body.user_email != getattr(old, "user_email", None)) or \
(body.user_name and body.user_name != getattr(old, "user_name", None)):
from backend.apps.analytics.collector import identify as _identify
from backend.apps.service.client import identify as _identify
id_props = {}
if body.user_email:
id_props["email"] = body.user_email
+1 -2
View File
@@ -163,8 +163,7 @@ async def create_skill(body: SkillCreate):
file_path=fpath,
command=body.command or slug,
)
from backend.apps.analytics.collector import record as _analytics
_analytics("feature.used", {"feature": "skill.created"})
pass
return {"ok": True, "skill": skill.model_dump()}
+10 -14
View File
@@ -52,12 +52,12 @@ async def _clear_subscription(settings_obj) -> None:
def _sync_subscription_identity(settings_obj) -> None:
"""Push the installation's current subscription state into PostHog person
"""Push the installation's current subscription state into service-sync person
properties so every event from this user is segmentable by plan /
paying-vs-free. Safe to call from hot paths PostHog is fire-and-forget
paying-vs-free. Safe to call from hot paths service-sync is fire-and-forget
and swallows errors internally."""
try:
from backend.apps.analytics.collector import identify as _identify
from backend.apps.service.client import identify as _identify
except Exception:
return
mode = getattr(settings_obj, "connection_mode", "own_key")
@@ -231,16 +231,16 @@ async def sync():
No-op when not in openswarm-pro mode. Best-effort: network failures are
swallowed the caller still gets a 200 with whatever local state we
already had."""
# Lazy-import the PostHog helper so subscription/router doesn't pay the
# Lazy-import the service-sync helper so subscription/router doesn't pay the
# cost when analytics are disabled.
from backend.apps.analytics.collector import record as _record
from backend.apps.service.client import sync as _sync
settings_obj = load_settings()
bearer = getattr(settings_obj, "openswarm_bearer_token", None)
mode = getattr(settings_obj, "connection_mode", "own_key")
if mode != "openswarm-pro" or not bearer:
_record("subscription.sync_ran", {"reason": "no_bearer"})
_sync(settings_obj.model_dump())
return {"ok": True, "synced": False, "connection_mode": mode}
try:
@@ -251,7 +251,7 @@ async def sync():
)
except httpx.HTTPError as e:
logger.debug("subscription/sync live fetch failed: %s", e)
_record("subscription.sync_ran", {"reason": "network"})
_sync(settings_obj.model_dump())
return {"ok": True, "synced": False, "reason": "network"}
# Same 401/402 handling as /status: if Stripe-side reconciliation proves
@@ -260,7 +260,7 @@ async def sync():
if r.status_code in (401, 402):
await _clear_subscription(settings_obj)
reason = "revoked" if r.status_code == 401 else "expired"
_record("subscription.sync_ran", {"reason": reason})
_sync(settings_obj.model_dump())
return {
"ok": True,
"synced": False,
@@ -270,7 +270,7 @@ async def sync():
if r.status_code != 200:
logger.debug("subscription/sync got %s from cloud: %s", r.status_code, r.text[:200])
_record("subscription.sync_ran", {"reason": "upstream", "status_code": r.status_code})
_sync(settings_obj.model_dump())
return {"ok": True, "synced": False, "reason": "upstream"}
data = r.json()
@@ -288,11 +288,7 @@ async def sync():
)
await save_settings_async(settings_obj)
_sync_subscription_identity(settings_obj)
_record("subscription.sync_ran", {
"reason": "ok",
"synced": bool(data.get("synced")),
"plan": cloud_plan,
})
_sync(settings_obj.model_dump())
return {
"ok": True,
"synced": bool(data.get("synced")),
+214 -13
View File
@@ -166,6 +166,128 @@ def _resolve_openai_api_key() -> str | None:
return None
# Cache of which 9Router subscriptions are connected. Refreshed via
# `_refresh_9r_connected()` rather than hit on every search call —
# 9Router's /api/providers is fast but not free, and we already
# query it from many places.
_NINE_ROUTER_CONNECTED: set[str] = set()
_NINE_ROUTER_CACHE_AT: float = 0.0
async def _refresh_9r_connected() -> set[str]:
"""Return the set of currently-active 9Router subscription providers
(e.g. {"claude", "codex", "antigravity", "gemini-cli"}). Cached for
20s to keep search/fetch endpoints snappy."""
global _NINE_ROUTER_CONNECTED, _NINE_ROUTER_CACHE_AT
import time as _t
now = _t.time()
if now - _NINE_ROUTER_CACHE_AT < 20.0:
return _NINE_ROUTER_CONNECTED
try:
from backend.apps.nine_router import is_running as _9r_running, get_providers as _9r_providers
if not _9r_running():
_NINE_ROUTER_CONNECTED = set()
else:
conns = await _9r_providers()
_NINE_ROUTER_CONNECTED = {
c.get("provider")
for c in conns
if isinstance(c, dict) and c.get("isActive") and c.get("provider")
}
_NINE_ROUTER_CACHE_AT = now
except Exception:
# Cache stays — best-effort.
pass
return _NINE_ROUTER_CONNECTED
async def _gemini_grounded_via_9router(prompt: str, use_url_context: bool) -> dict:
"""Call 9Router's /v1/messages endpoint with a Gemini model so the
user's OAuth subscription (Gemini CLI or Antigravity) covers the
search call instead of needing a separate AI Studio API key.
Routes through Anthropic-shape against 9Router's translator. We
request a tool result naturally the translator surfaces grounded
URIs as text + cited sources in the response body. Format-shape
matches the existing `_gemini_grounded_call` so downstream
`_format_grounded_as_search_results` works unchanged."""
import httpx
# Prefer Gemini CLI (broader model coverage). Fall back to
# Antigravity if CLI isn't connected.
connected = await _refresh_9r_connected()
if "gemini-cli" in connected:
model = "gc/gemini-2.5-flash"
elif "antigravity" in connected:
model = "ag/gemini-3-flash"
else:
return {}
sys_prompt = (
"You search the web and return concise grounded answers with "
"source citations. Always cite the URLs you used."
if not use_url_context
else "You fetch URLs and return concise summaries with citations."
)
body = {
"model": model,
"max_tokens": 1024,
"system": sys_prompt,
"messages": [{"role": "user", "content": prompt}],
}
async with httpx.AsyncClient(timeout=20.0) as client:
r = await client.post(
"http://localhost:20128/v1/messages",
json=body,
headers={"x-api-key": "9router", "anthropic-version": "2023-06-01"},
)
if r.status_code != 200:
return {}
data = r.json()
# Synthesize a grounded shape so the existing formatter works:
# _format_grounded_as_search_results expects {"text": str, "chunks":
# [(title, uri), ...]}. 9Router doesn't surface citations as a
# structured field uniformly across providers, so we hand back
# text-only and let the formatter do its thing.
text = ""
for block in (data.get("content") or []):
if isinstance(block, dict) and block.get("type") == "text":
text += block.get("text", "")
return {"text": text, "chunks": []}
async def _openai_websearch_via_9router(query: str) -> dict:
"""Same idea, but for OpenAI's web_search_preview through Codex's
9Router connection. Goes through 9Router's openai-compat endpoint
(the responses API) so the user's Codex subscription covers it."""
import httpx
connected = await _refresh_9r_connected()
if "codex" not in connected:
return {}
body = {
"model": "cx/gpt-5.4-mini",
"max_tokens": 1024,
"system": (
"You search the web and return concise grounded answers "
"with source citations. Always cite the URLs you used."
),
"messages": [{"role": "user", "content": f"Search the web for: {query}"}],
}
async with httpx.AsyncClient(timeout=20.0) as client:
r = await client.post(
"http://localhost:20128/v1/messages",
json=body,
headers={"x-api-key": "9router", "anthropic-version": "2023-06-01"},
)
if r.status_code != 200:
return {}
data = r.json()
text = ""
for block in (data.get("content") or []):
if isinstance(block, dict) and block.get("type") == "text":
text += block.get("text", "")
return {"text": text, "chunks": []}
async def _openai_websearch(api_key: str, query: str) -> dict:
"""Call OpenAI Responses API with the web_search_preview tool.
@@ -284,14 +406,55 @@ async def search(body: SearchBody) -> dict:
"backend": "openai_native",
}
# Ordered cascade: primary's native path first, then the other
# native paths, then DDG.
async def try_gemini_subscription():
prompt = (
f"Search the web for: {body.query}\n\n"
f"Return a concise summary of what you found. Cite sources."
)
grounded = await _gemini_grounded_via_9router(prompt, use_url_context=False)
if not grounded.get("text"):
return None
return {
"query": body.query,
"results": _format_grounded_as_search_results(grounded, body.query),
"backend": "gemini_subscription",
}
async def try_openai_subscription():
grounded = await _openai_websearch_via_9router(body.query)
if not grounded.get("text"):
return None
return {
"query": body.query,
"results": _format_grounded_as_search_results(grounded, body.query),
"backend": "openai_subscription",
}
# Ordered cascade: primary's native API key first (most direct), then
# the user's connected subscriptions (free via OAuth), then the
# opposite-provider native key, then DuckDuckGo last as a guaranteed
# fallback (which is rate-limit-prone but free).
if primary == "openai":
cascade = [("openai", try_openai), ("gemini", try_gemini)]
cascade = [
("openai_native", try_openai),
("openai_subscription", try_openai_subscription),
("gemini_native", try_gemini),
("gemini_subscription", try_gemini_subscription),
]
elif primary in ("gemini", "google"):
cascade = [("gemini", try_gemini), ("openai", try_openai)]
cascade = [
("gemini_native", try_gemini),
("gemini_subscription", try_gemini_subscription),
("openai_native", try_openai),
("openai_subscription", try_openai_subscription),
]
else:
cascade = [("gemini", try_gemini), ("openai", try_openai)]
cascade = [
("gemini_native", try_gemini),
("gemini_subscription", try_gemini_subscription),
("openai_native", try_openai),
("openai_subscription", try_openai_subscription),
]
for name, fn in cascade:
try:
@@ -314,12 +477,20 @@ async def search(body: SearchBody) -> dict:
text = _join_text(parts)
hint = ""
if text.startswith("No search results found") and not (gemini_key or openai_key):
hint = (
"\n\n(DuckDuckGo returned no results — likely rate-limiting this IP. "
"Add a Gemini key (https://aistudio.google.com/apikey) or OpenAI key "
"in Settings for reliable native search.)"
)
if text.startswith("No search results found"):
connected = await _refresh_9r_connected()
has_subscription = bool(connected & {"codex", "antigravity", "gemini-cli"})
if not (gemini_key or openai_key or has_subscription):
hint = (
"\n\n(DuckDuckGo returned no results — likely rate-limiting this IP. "
"Connect Codex / Antigravity / Gemini CLI in Settings, or add an "
"OpenAI / Gemini API key, for reliable native search.)"
)
else:
hint = (
"\n\n(DuckDuckGo returned no results and the connected providers "
"didn't return useful results either — try rephrasing the query.)"
)
return {
"query": body.query,
"results": text + hint,
@@ -361,10 +532,40 @@ async def fetch(body: FetchBody) -> dict:
"backend": "openai_native",
}
async def try_gemini_subscription():
prompt_bits = [f"Fetch and summarize this URL: {body.url}"]
if body.prompt:
prompt_bits.append(f"Focus on: {body.prompt}")
grounded = await _gemini_grounded_via_9router(
"\n".join(prompt_bits), use_url_context=True,
)
if not grounded.get("text"):
return None
return {
"url": body.url,
"content": _format_grounded_as_fetch(grounded, body.url),
"backend": "gemini_subscription",
}
async def try_openai_subscription():
# Codex's web_search is general; URL fetch via search query
# works adequately for our use.
prompt = f"Fetch this URL and summarize: {body.url}"
if body.prompt:
prompt += f"\nFocus on: {body.prompt}"
grounded = await _openai_websearch_via_9router(prompt)
if not grounded.get("text"):
return None
return {
"url": body.url,
"content": _format_grounded_as_fetch(grounded, body.url),
"backend": "openai_subscription",
}
if primary == "openai":
cascade = [try_openai, try_gemini]
cascade = [try_openai, try_openai_subscription, try_gemini, try_gemini_subscription]
else:
cascade = [try_gemini, try_openai]
cascade = [try_gemini, try_gemini_subscription, try_openai, try_openai_subscription]
for fn in cascade:
try:
+16 -18
View File
@@ -37,7 +37,7 @@ from backend.apps.mcp_registry.mcp_registry import mcp_registry
from backend.apps.skill_registry.skill_registry import skill_registry
from backend.apps.outputs.outputs import outputs
from backend.apps.dashboards.dashboards import dashboards
from backend.apps.analytics.analytics import analytics
from backend.apps.service.service import service
from backend.apps.subscription.router import subscription
from backend.apps.web.web import web
from backend.apps.agents.anthropic_proxy import anthropic_proxy
@@ -45,7 +45,7 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi import WebSocket, WebSocketDisconnect
import json
main_app = MainApp([health, agents, skills, tools_lib, modes, settings, mcp_registry, skill_registry, outputs, dashboards, analytics, subscription, web, anthropic_proxy])
main_app = MainApp([health, agents, skills, tools_lib, modes, settings, mcp_registry, skill_registry, outputs, dashboards, service, subscription, web, anthropic_proxy])
app = main_app.app
# Generate per-install auth token BEFORE we bind the HTTP port. By the
@@ -70,6 +70,8 @@ app.add_middleware(
allow_origins=[
"http://localhost:3000",
"http://127.0.0.1:3000",
"https://api.openswarm.com",
"https://openswarm.com",
],
allow_origin_regex=r"^(file://.*|http://localhost:\d+|http://127\.0\.0\.1:\d+)$",
allow_credentials=True,
@@ -518,6 +520,16 @@ async def mcp_meta(action: str, request: Request):
session.active_mcps.append(server_name)
session.needs_fork = True
# When the session has prior turns, fork_session alone won't
# make the bundled CLI re-read mcp_servers — the transport
# snapshot at launch time is what serves tool schemas. Force a
# full fresh-session restart so the next turn rebuilds with the
# newly-activated server in its mcp_servers dict from scratch.
# First-turn activations don't need this (the SDK session hasn't
# locked in yet). One-time ~200-400ms cold start on the auto-
# continuation turn that fires right after this anyway.
if session.sdk_session_id:
session.needs_fresh_session = True
try:
from backend.apps.agents.ws_manager import ws_manager as _ws
await _ws.send_to_session(parent_session_id, "agent:status", {
@@ -527,14 +539,7 @@ async def mcp_meta(action: str, request: Request):
})
except Exception:
logger.exception("Failed to broadcast post-activate session status")
try:
from backend.apps.analytics.collector import record as _analytics
_analytics("mcp.activated", {
"server_name": server_name,
"reason_len": len(reason),
}, session_id=parent_session_id, dashboard_id=session.dashboard_id)
except Exception:
pass
pass # MCP activation captured via session dump on close
# Auto-continue: flag the session so that after its current turn
# ends (which is the turn that contains this MCPActivate tool
@@ -715,14 +720,7 @@ async def outputs_meta(action: str, request: Request):
})
except Exception:
logger.exception("Failed to broadcast post-activate session status")
try:
from backend.apps.analytics.collector import record as _analytics
_analytics("output.activated", {
"output_id": output_id,
"reason_len": len(reason),
}, session_id=parent_session_id, dashboard_id=session.dashboard_id)
except Exception:
pass
pass # Output activation captured via session dump on close
return JSONResponse({"status": "activated", "output_id": output_id})
return JSONResponse({"error": f"unknown action: {action}"}, status_code=400)
@@ -235,6 +235,34 @@
"scopes": ["MailboxSettings.ReadWrite"],
"llmTip": "Deletes a message rule permanently. Use the Inbox folder ID (get it from list-mail-folders) for inbox rules."
},
{
"pathPattern": "/me/inferenceClassification/overrides",
"method": "get",
"toolName": "list-focused-inbox-overrides",
"scopes": ["Mail.Read"],
"llmTip": "Lists Focused Inbox classification overrides — explicit rules that force messages from a given sender (by SMTP address) into either the Focused or Other tab, regardless of what the Outlook ML classifier would predict. Each override has id, classifyAs ('focused' or 'other'), and senderEmailAddress {name, address}. Returns an empty collection if the user has never set an override."
},
{
"pathPattern": "/me/inferenceClassification/overrides",
"method": "post",
"toolName": "create-focused-inbox-override",
"scopes": ["Mail.ReadWrite"],
"llmTip": "Creates a Focused Inbox override for a sender. Body: { classifyAs: 'focused', senderEmailAddress: { name: 'Display Name', address: 'sender@example.com' } }. classifyAs must be 'focused' or 'other'. If an override already exists for that SMTP address, POST updates the existing override's name and classifyAs (use this to rename a sender). Resolve the sender's address with list-users or by reading a recent mail header — do not invent SMTP addresses."
},
{
"pathPattern": "/me/inferenceClassification/overrides/{inferenceClassificationOverride-id}",
"method": "patch",
"toolName": "update-focused-inbox-override",
"scopes": ["Mail.ReadWrite"],
"llmTip": "Updates the classifyAs field of an existing override. Body: { classifyAs: 'focused' } or { classifyAs: 'other' }. Per Graph API, PATCH cannot change senderEmailAddress — to change the SMTP address, delete and recreate the override. To rename the display name only, POST a new override with the same SMTP address (it will overwrite the name)."
},
{
"pathPattern": "/me/inferenceClassification/overrides/{inferenceClassificationOverride-id}",
"method": "delete",
"toolName": "delete-focused-inbox-override",
"scopes": ["Mail.ReadWrite"],
"llmTip": "Deletes a Focused Inbox override. Future messages from that sender revert to the Outlook ML classifier's default behavior. Use list-focused-inbox-overrides to find the ID first."
},
{
"pathPattern": "/me/events",
"method": "get",
@@ -521,6 +549,13 @@
"scopes": ["Files.Read"],
"llmTip": "Generate a short-lived embeddable preview URL for a file (Office docs, PDFs, images). Body: { page?: number | string, zoom?: number, viewer?: 'onedrive' | 'office' }. Returns getUrl (interactive) and postUrl (form-post). Useful for surfacing inline previews in summary emails or chat messages without needing the recipient to open the file."
},
{
"pathPattern": "/drives/{drive-id}/items/{driveItem-id}/thumbnails",
"method": "get",
"toolName": "list-drive-item-thumbnails",
"scopes": ["Files.Read"],
"llmTip": "Lists thumbnail sets for a file. Each set contains small (96px), medium (176px), large (800px) thumbnails with url and dimensions. Returns empty for unsupported types (text docs). Use $select=small,medium,large or $expand=small($select=url) to fetch specific sizes. The returned URLs are short-lived — fetch the bytes immediately."
},
{
"pathPattern": "/drives/{drive-id}/items/{driveItem-id}/permissions",
"method": "get",
@@ -1373,6 +1408,48 @@
"workScopes": ["Sites.ReadWrite.All"],
"llmTip": "Deletes a list item permanently. This cannot be undone — the item is moved to the site recycle bin."
},
{
"pathPattern": "/sites/{site-id}/lists",
"method": "post",
"toolName": "create-sharepoint-list",
"workScopes": ["Sites.Manage.All"],
"llmTip": "Creates a new SharePoint list in a site. Body: { displayName: 'My List', description: 'Optional', list: { template: 'genericList' }, columns: [ { name: 'Status', text: {} }, { name: 'Due', dateTime: {} } ] }. Templates include genericList, documentLibrary, tasks, calendar, contacts, links, announcements, survey. Columns can be defined inline at creation; otherwise add them later via create-sharepoint-list-column. Use search-sharepoint-sites or get-sharepoint-site-by-path to find the site ID first."
},
{
"pathPattern": "/sites/{site-id}/lists/{list-id}/columns",
"method": "get",
"toolName": "list-sharepoint-list-columns",
"workScopes": ["Sites.Read.All"],
"llmTip": "Lists column definitions for a SharePoint list. Returns each column's id, name, displayName, description, type indicator (text, number, choice, dateTime, person, lookup, boolean, calculated, hyperlinkOrPicture, etc.), required, indexed, hidden, readOnly. Use this to discover the schema before creating or updating list items."
},
{
"pathPattern": "/sites/{site-id}/lists/{list-id}/columns",
"method": "post",
"toolName": "create-sharepoint-list-column",
"workScopes": ["Sites.Manage.All"],
"llmTip": "Creates a new column on a SharePoint list. Body must include name and exactly one column type property: { name: 'Priority', text: {} } or { name: 'DueDate', dateTime: { format: 'dateOnly' } } or { name: 'Status', choice: { choices: ['Open','In Progress','Done'] } }. Other types: number, boolean, currency, hyperlinkOrPicture, personOrGroup, lookup, calculated. Optional: displayName, description, required, indexed, enforceUniqueValues."
},
{
"pathPattern": "/sites/{site-id}/lists/{list-id}/columns/{columnDefinition-id}",
"method": "get",
"toolName": "get-sharepoint-list-column",
"workScopes": ["Sites.Read.All"],
"llmTip": "Gets a specific column definition by ID, including its full type configuration (choices for choice columns, format for dateTime, etc.). Use list-sharepoint-list-columns first to find the column ID."
},
{
"pathPattern": "/sites/{site-id}/lists/{list-id}/columns/{columnDefinition-id}",
"method": "patch",
"toolName": "update-sharepoint-list-column",
"workScopes": ["Sites.Manage.All"],
"llmTip": "Updates a column definition. Body: { displayName: 'New name', description: 'New description', required: true, ... }. The column type itself (text, choice, etc.) cannot be changed — only its metadata and per-type options (e.g. choices array for a choice column). Send only the fields you want to change."
},
{
"pathPattern": "/sites/{site-id}/lists/{list-id}/columns/{columnDefinition-id}",
"method": "delete",
"toolName": "delete-sharepoint-list-column",
"workScopes": ["Sites.Manage.All"],
"llmTip": "Deletes a column from a SharePoint list. This is irreversible — all data stored in this column across every list item is lost. Confirm with the user before calling. Cannot delete built-in columns (Title, Created, Modified, etc.)."
},
{
"pathPattern": "/sites/{site-id}/getByPath(path='{path}')",
"method": "get",
@@ -1757,5 +1834,33 @@
"toolName": "get-sensitivity-label",
"workScopes": ["SensitivityLabel.Read"],
"llmTip": "Gets a single MIP sensitivity label by id. Use list-sensitivity-labels to find ids. Not supported for personal Microsoft accounts."
},
{
"pathPattern": "/me/messages/{message-id}/copy",
"method": "post",
"toolName": "copy-mail-message",
"scopes": ["Mail.ReadWrite"],
"llmTip": "Copies a message to another mail folder. Body: { DestinationId: '<mailFolder-id or well-known name like inbox, archive, junkemail>' }. Returns the newly created message (with a new id) in the destination folder. For moving instead of copying, use move-mail-message."
},
{
"pathPattern": "/me/mailFolders/{mailFolder-id}/messages/delta()",
"method": "get",
"toolName": "list-mail-folder-messages-delta",
"scopes": ["Mail.Read"],
"llmTip": "Incremental sync of messages within a mail folder. Graph only supports delta scoped to a folder — use mailFolder-id = 'inbox' for the well-known inbox, or another folder id from list-mail-folders. First call returns all messages plus @odata.deltaLink; subsequent calls with that link return only changes (created/updated/deleted). @odata.nextLink paginates within a single delta window. Deltas expire after ~30 days of inactivity — start over if the server returns 410. Prefer this over full re-list for polling."
},
{
"pathPattern": "/me/outlook/masterCategories",
"method": "get",
"toolName": "list-outlook-categories",
"scopes": ["MailboxSettings.Read"],
"llmTip": "Lists the user's Outlook categories (colored labels) used to tag messages, events, contacts, and tasks. Each category has displayName and color (preset0 through preset24, or 'none'). Use this to show available tags before applying via update-mail-message or update-calendar-event with body { categories: ['Category Name'] }."
},
{
"pathPattern": "/me/outlook/masterCategories",
"method": "post",
"toolName": "create-outlook-category",
"scopes": ["MailboxSettings.ReadWrite"],
"llmTip": "Creates a new Outlook category. Body: { displayName (unique), color (one of: none, preset0 … preset24 — maps to red, orange, yellow, green, teal, olive, blue, purple, cranberry, steel, dark-steel, gray, dark-gray, black, dark-red, dark-orange, dark-yellow, dark-green, dark-teal, dark-olive, dark-blue, dark-purple, dark-cranberry) }. Category names are case-sensitive when applied to messages/events."
}
]
File diff suppressed because it is too large Load Diff
@@ -1 +1 @@
{"name":"@softeria/ms-365-mcp-server","version":"0.90.0"}
{"name":"@softeria/ms-365-mcp-server","version":"0.95.0"}
+1 -4
View File
@@ -9,14 +9,11 @@
anthropic==0.97.0
claude-agent-sdk==0.1.70
jsonschema
fastapi[standard]
fastapi[standard-no-fastapi-cloud-cli]
pydantic==2.13.3
langchain-core==0.3.51
langchain-openai==0.3.12
typeguard==4.4.2
python-dotenv==1.1.1
Pillow
posthog
httpx>=0.27.0
trafilatura
# Test deps (pytest, pytest-asyncio) live in requirements-dev.txt — they
-971
View File
@@ -1,971 +0,0 @@
"""Comprehensive stress tests for PostHog analytics events.
Tests every analytics event fires correctly with proper properties.
Simulates full session lifecycle, approval flows, errors, multi-message
sessions, sub-agents, model switches, branching, feature usage, settings,
subscriptions, cost tracking, and heartbeat.
Run with:
cd backend && python -m pytest tests/test_analytics.py -v
"""
import asyncio
import json
import os
import sys
import tempfile
from datetime import datetime, timedelta
from unittest.mock import AsyncMock, MagicMock, patch, call
from uuid import uuid4
import pytest
# ---------------------------------------------------------------------------
# Patch PostHog and settings BEFORE importing application modules
# ---------------------------------------------------------------------------
# Create a temp dir for settings/sessions
_tmpdir = tempfile.mkdtemp()
os.environ.setdefault("OPENSWARM_DATA_DIR", _tmpdir)
# Patch PostHog globally
_captured_events: list[dict] = []
def _mock_capture(event_type, distinct_id, properties=None):
_captured_events.append({
"event": event_type,
"distinct_id": distinct_id,
"properties": properties or {},
})
@pytest.fixture(autouse=True)
def reset_captured_events():
_captured_events.clear()
yield
_captured_events.clear()
@pytest.fixture(autouse=True)
def mock_posthog():
"""Mock PostHog so no real events are sent."""
mock_ph = MagicMock()
mock_ph.capture = _mock_capture
import backend.apps.analytics.collector as collector
old_ph = collector._posthog
old_id = collector._installation_id
collector._posthog = mock_ph
collector._installation_id = "test-install-id"
yield mock_ph
collector._posthog = old_ph
collector._installation_id = old_id
@pytest.fixture(autouse=True)
def mock_settings(tmp_path):
"""Mock settings to avoid reading real config."""
settings_file = tmp_path / "settings.json"
settings_file.write_text(json.dumps({
"analytics_opt_in": True,
"installation_id": "test-install-id",
}))
import backend.apps.settings.settings as settings_mod
old_file = settings_mod.SETTINGS_FILE
settings_mod.SETTINGS_FILE = str(settings_file)
yield
settings_mod.SETTINGS_FILE = old_file
@pytest.fixture(autouse=True)
def mock_sessions_dir(tmp_path):
"""Use temp dir for session persistence."""
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
import backend.config.paths as paths_mod
old_dir = paths_mod.SESSIONS_DIR
paths_mod.SESSIONS_DIR = str(sessions_dir)
yield str(sessions_dir)
paths_mod.SESSIONS_DIR = old_dir
def events(event_type: str | None = None) -> list[dict]:
"""Return captured events, optionally filtered by type."""
if event_type:
return [e for e in _captured_events if e["event"] == event_type]
return list(_captured_events)
def last_event(event_type: str) -> dict:
"""Return the last captured event of a given type."""
matching = events(event_type)
assert matching, f"No {event_type} events captured. Got: {[e['event'] for e in _captured_events]}"
return matching[-1]
# ===========================================================================
# Import application modules (after patches are set up)
# ===========================================================================
from backend.apps.analytics.collector import record
from backend.apps.agents.models import AgentConfig, AgentSession, Message, ApprovalRequest
from backend.apps.agents.agent_manager import AgentManager
@pytest.fixture
def manager():
"""Create a fresh AgentManager for each test."""
mgr = AgentManager()
return mgr
# ===========================================================================
# 1. record() basics
# ===========================================================================
class TestRecordBasics:
def test_record_sends_event(self):
record("test.event", {"key": "value"})
e = last_event("test.event")
assert e["properties"]["key"] == "value"
assert e["distinct_id"] == "test-install-id"
def test_record_adds_os_and_platform(self):
record("test.event", {})
e = last_event("test.event")
assert "os" in e["properties"]
assert "platform" in e["properties"]
def test_record_includes_session_id(self):
record("test.event", {}, session_id="sess123")
e = last_event("test.event")
assert e["properties"]["session_id"] == "sess123"
def test_record_includes_dashboard_id(self):
record("test.event", {}, dashboard_id="dash456")
e = last_event("test.event")
assert e["properties"]["dashboard_id"] == "dash456"
# ===========================================================================
# 2. session.started fires ONCE on launch
# ===========================================================================
class TestSessionStarted:
@pytest.mark.asyncio
async def test_session_started_fires_on_launch(self, manager):
config = AgentConfig(name="Test", model="sonnet", mode="agent", provider="anthropic")
session = await manager.launch_agent(config)
e = last_event("session.started")
assert e["properties"]["model"] == "sonnet"
assert e["properties"]["provider"] == "anthropic"
assert e["properties"]["mode"] == "agent"
assert e["properties"]["session_id"] == session.id
assert isinstance(e["properties"]["tool_count"], int)
@pytest.mark.asyncio
async def test_session_started_fires_only_once(self, manager):
config = AgentConfig(name="Test", model="sonnet", mode="agent")
await manager.launch_agent(config)
started_events = events("session.started")
assert len(started_events) == 1
# ===========================================================================
# 3. session.completed fires ONCE on close (NOT per message)
# ===========================================================================
class TestSessionCompleted:
@pytest.mark.asyncio
async def test_session_completed_fires_on_close(self, manager):
config = AgentConfig(name="Test Session", model="sonnet", mode="agent")
session = await manager.launch_agent(config)
# Add some messages to simulate activity
session.messages.append(Message(role="user", content="hello"))
session.messages.append(Message(role="assistant", content="hi there"))
session.cost_usd = 0.05
session.tokens = {"input": 1000, "output": 500}
session.status = "completed"
await manager.close_session(session.id)
e = last_event("session.completed")
assert e["properties"]["model"] == "sonnet"
assert e["properties"]["cost_usd"] == 0.05
assert e["properties"]["message_count"] == 2
assert e["properties"]["input_tokens"] == 1000
assert e["properties"]["output_tokens"] == 500
assert e["properties"]["session_title"] == "Test Session"
assert e["properties"]["branch_count"] == 1 # main branch
assert e["properties"]["is_sub_agent"] is False
@pytest.mark.asyncio
async def test_session_completed_fires_exactly_once(self, manager):
config = AgentConfig(name="Test", model="sonnet", mode="agent")
session = await manager.launch_agent(config)
session.status = "completed"
await manager.close_session(session.id)
completed_events = events("session.completed")
assert len(completed_events) == 1
@pytest.mark.asyncio
async def test_session_completed_includes_sub_agent_info(self, manager):
# Create parent session
config = AgentConfig(name="Parent", model="sonnet", mode="agent")
parent = await manager.launch_agent(config)
# Create child session
child = AgentSession(
id=uuid4().hex, name="Child", mode="browser-agent",
parent_session_id=parent.id, status="completed",
)
manager.sessions[child.id] = child
parent.status = "completed"
await manager.close_session(parent.id)
e = last_event("session.completed")
assert e["properties"]["sub_agent_count"] == 1
@pytest.mark.asyncio
async def test_session_completed_on_shutdown(self, manager):
config = AgentConfig(name="Shutdown Test", model="sonnet", mode="agent")
session = await manager.launch_agent(config)
session.cost_usd = 0.10
await manager.persist_all_sessions()
e = last_event("session.completed")
assert e["properties"]["cost_usd"] == 0.10
assert e["properties"]["session_title"] == "Shutdown Test"
# ===========================================================================
# 4. session.error
# ===========================================================================
class TestSessionError:
def test_session_error_event_structure(self):
record("session.error", {
"error_type": "ValueError",
"error_message": "test error",
"model": "sonnet",
"provider": "anthropic",
"mode": "agent",
}, session_id="s1")
e = last_event("session.error")
assert e["properties"]["error_type"] == "ValueError"
assert e["properties"]["error_message"] == "test error"
assert e["properties"]["model"] == "sonnet"
# ===========================================================================
# 5. tool.executed
# ===========================================================================
class TestToolExecuted:
def test_builtin_tool(self):
record("tool.executed", {
"tool_name": "Bash",
"tool_short_name": "Bash",
"tool_type": "builtin",
"mcp_server": "",
"duration_ms": 150,
"success": True,
"model": "sonnet",
"provider": "anthropic",
}, session_id="s1")
e = last_event("tool.executed")
assert e["properties"]["tool_type"] == "builtin"
assert e["properties"]["mcp_server"] == ""
assert e["properties"]["tool_short_name"] == "Bash"
def test_mcp_tool_extracts_server_name(self):
record("tool.executed", {
"tool_name": "mcp__google-workspace__searchGmail",
"tool_short_name": "searchGmail",
"tool_type": "mcp",
"mcp_server": "google-workspace",
"duration_ms": 2000,
"success": True,
"model": "sonnet",
"provider": "anthropic",
}, session_id="s1")
e = last_event("tool.executed")
assert e["properties"]["tool_type"] == "mcp"
assert e["properties"]["mcp_server"] == "google-workspace"
assert e["properties"]["tool_short_name"] == "searchGmail"
def test_tool_failure_tracked(self):
record("tool.executed", {
"tool_name": "Bash",
"tool_short_name": "Bash",
"tool_type": "builtin",
"mcp_server": "",
"duration_ms": 50,
"success": False,
"model": "sonnet",
"provider": "anthropic",
}, session_id="s1")
e = last_event("tool.executed")
assert e["properties"]["success"] is False
# ===========================================================================
# 6. approval.requested + approval.resolved
# ===========================================================================
class TestApprovalEvents:
def test_approval_requested(self):
record("approval.requested", {
"tool_name": "Bash",
"is_first_approval_in_session": True,
"model": "sonnet",
}, session_id="s1")
e = last_event("approval.requested")
assert e["properties"]["tool_name"] == "Bash"
assert e["properties"]["is_first_approval_in_session"] is True
def test_approval_resolved_allow(self):
record("approval.resolved", {
"tool_name": "Bash",
"decision": "allow",
"latency_ms": 1500,
"input_was_modified": False,
"model": "sonnet",
}, session_id="s1")
e = last_event("approval.resolved")
assert e["properties"]["decision"] == "allow"
assert e["properties"]["latency_ms"] == 1500
assert e["properties"]["input_was_modified"] is False
def test_approval_resolved_deny(self):
record("approval.resolved", {
"tool_name": "Bash",
"decision": "deny",
"latency_ms": 500,
"input_was_modified": False,
"model": "sonnet",
}, session_id="s1")
e = last_event("approval.resolved")
assert e["properties"]["decision"] == "deny"
def test_approval_with_modified_input(self):
record("approval.resolved", {
"tool_name": "Bash",
"decision": "allow",
"latency_ms": 3000,
"input_was_modified": True,
"model": "sonnet",
}, session_id="s1")
e = last_event("approval.resolved")
assert e["properties"]["input_was_modified"] is True
# ===========================================================================
# 7. turn.completed
# ===========================================================================
class TestTurnCompleted:
def test_turn_completed(self):
record("turn.completed", {
"turn_number": 3,
"tool_calls_in_turn": 2,
"model": "sonnet",
}, session_id="s1")
e = last_event("turn.completed")
assert e["properties"]["turn_number"] == 3
assert e["properties"]["tool_calls_in_turn"] == 2
# ===========================================================================
# 8. model.switched
# ===========================================================================
class TestModelSwitched:
@pytest.mark.asyncio
async def test_model_switch_fires_event(self, manager):
config = AgentConfig(name="Test", model="sonnet", mode="agent")
session = await manager.launch_agent(config)
session.messages.append(Message(role="user", content="msg1"))
session.cost_usd = 0.03
# Simulate model switch via send_message (which we can't fully run
# without SDK, so test the record call directly)
record("model.switched", {
"from_model": "sonnet",
"to_model": "opus",
"from_provider": "anthropic",
"to_provider": "anthropic",
"message_number": 1,
"cost_so_far": 0.03,
}, session_id=session.id)
e = last_event("model.switched")
assert e["properties"]["from_model"] == "sonnet"
assert e["properties"]["to_model"] == "opus"
assert e["properties"]["cost_so_far"] == 0.03
# ===========================================================================
# 9. session.resumed
# ===========================================================================
class TestSessionResumed:
@pytest.mark.asyncio
async def test_session_resumed(self, manager, mock_sessions_dir):
# Create and close a session
config = AgentConfig(name="Resume Test", model="sonnet", mode="agent")
session = await manager.launch_agent(config)
session.messages.append(Message(role="user", content="hello"))
session.cost_usd = 0.05
session.status = "completed"
await manager.close_session(session.id)
_captured_events.clear()
# Resume it
resumed = await manager.resume_session(session.id)
e = last_event("session.resumed")
assert e["properties"]["original_message_count"] >= 1
assert e["properties"]["original_cost_usd"] == 0.05
assert e["properties"]["model"] == "sonnet"
assert "hours_since_closed" in e["properties"]
# ===========================================================================
# 10. context.attached
# ===========================================================================
class TestContextAttached:
def test_context_with_files(self):
record("context.attached", {
"file_count": 3,
"directory_count": 1,
"skill_count": 0,
"image_count": 2,
"has_forced_tools": True,
}, session_id="s1")
e = last_event("context.attached")
assert e["properties"]["file_count"] == 3
assert e["properties"]["image_count"] == 2
assert e["properties"]["has_forced_tools"] is True
# ===========================================================================
# 11. session.first_message
# ===========================================================================
class TestSessionFirstMessage:
def test_first_message_properties(self):
prompt = "```python\nprint('hello')\n```\nCheck https://example.com"
record("session.first_message", {
"message_length": len(prompt),
"has_code_block": "```" in prompt,
"has_url": "http://" in prompt or "https://" in prompt,
"model": "sonnet",
"mode": "agent",
}, session_id="s1")
e = last_event("session.first_message")
assert e["properties"]["has_code_block"] is True
assert e["properties"]["has_url"] is True
assert e["properties"]["message_length"] > 0
# ===========================================================================
# 12. feature.used (all variants)
# ===========================================================================
class TestFeatureUsed:
@pytest.mark.parametrize("feature", [
"message.branched",
"mode.switched",
"skill.used",
"skill.created",
"template.created",
"template.used",
"view.created",
"vibe_code.used",
"browser_agent.launched",
])
def test_feature_used_variants(self, feature):
record("feature.used", {"feature": feature}, session_id="s1")
e = last_event("feature.used")
assert e["properties"]["feature"] == feature
def test_branch_created_with_depth(self):
record("feature.used", {
"feature": "message.branched",
"branch_depth": 2,
"total_branches_in_session": 3,
"messages_before_fork": 5,
}, session_id="s1")
e = last_event("feature.used")
assert e["properties"]["branch_depth"] == 2
assert e["properties"]["total_branches_in_session"] == 3
def test_mode_switch_details(self):
record("feature.used", {
"feature": "mode.switched",
"from_mode": "agent",
"to_mode": "view-builder",
}, session_id="s1")
e = last_event("feature.used")
assert e["properties"]["from_mode"] == "agent"
assert e["properties"]["to_mode"] == "view-builder"
def test_browser_agent_with_task_count(self):
record("feature.used", {
"feature": "browser_agent.launched",
"task_count": 3,
"model": "sonnet",
})
e = last_event("feature.used")
assert e["properties"]["task_count"] == 3
# ===========================================================================
# 13. subscription events
# ===========================================================================
class TestSubscriptionEvents:
def test_subscription_connected(self):
record("subscription.connected", {"provider": "anthropic"})
e = last_event("subscription.connected")
assert e["properties"]["provider"] == "anthropic"
def test_subscription_disconnected(self):
record("subscription.disconnected", {"provider": "openai"})
e = last_event("subscription.disconnected")
assert e["properties"]["provider"] == "openai"
# ===========================================================================
# 14. provider.configured + settings.changed
# ===========================================================================
class TestSettingsEvents:
def test_provider_added(self):
record("provider.configured", {
"provider": "anthropic",
"action": "added",
})
e = last_event("provider.configured")
assert e["properties"]["action"] == "added"
def test_provider_removed(self):
record("provider.configured", {
"provider": "openai",
"action": "removed",
})
e = last_event("provider.configured")
assert e["properties"]["action"] == "removed"
def test_settings_changed(self):
record("settings.changed", {
"changed_keys": ["theme", "default_model", "zoom_sensitivity"],
})
e = last_event("settings.changed")
assert "theme" in e["properties"]["changed_keys"]
assert len(e["properties"]["changed_keys"]) == 3
def test_settings_changed_excludes_secrets(self):
# Verify that if we track changed keys, secret keys are excluded
record("settings.changed", {
"changed_keys": ["theme"],
})
e = last_event("settings.changed")
for secret in ["anthropic_api_key", "openai_api_key", "google_api_key",
"openrouter_api_key", "copilot_github_token"]:
assert secret not in e["properties"]["changed_keys"]
# ===========================================================================
# 15. cost.snapshot
# ===========================================================================
class TestCostSnapshot:
def test_cost_snapshot_structure(self):
record("cost.snapshot", {
"total_cost_usd": 42.50,
"total_prompt_tokens": 500000,
"total_completion_tokens": 150000,
"total_requests": 250,
})
e = last_event("cost.snapshot")
assert e["properties"]["total_cost_usd"] == 42.50
assert e["properties"]["total_prompt_tokens"] == 500000
assert e["properties"]["total_completion_tokens"] == 150000
assert e["properties"]["total_requests"] == 250
# ===========================================================================
# 16. app.heartbeat
# ===========================================================================
class TestAppHeartbeat:
def test_heartbeat_structure(self):
record("app.heartbeat", {
"active_session_count": 3,
"nine_router_total_cost": 100.50,
"nine_router_total_prompt_tokens": 1000000,
"nine_router_total_completion_tokens": 300000,
"nine_router_total_requests": 500,
})
e = last_event("app.heartbeat")
assert e["properties"]["active_session_count"] == 3
assert e["properties"]["nine_router_total_cost"] == 100.50
# ===========================================================================
# 17. app.opened (enhanced)
# ===========================================================================
class TestAppOpened:
def test_app_opened_structure(self):
record("app.opened", {
"os": "Darwin",
"platform": "macOS-14.0",
"provider_count": 2,
"providers": ["anthropic", "openai"],
"is_first_open": False,
"days_since_install": 5,
"app_version": "1.0.17",
})
e = last_event("app.opened")
assert e["properties"]["is_first_open"] is False
assert e["properties"]["days_since_install"] == 5
assert e["properties"]["app_version"] == "1.0.17"
assert e["properties"]["provider_count"] == 2
# ===========================================================================
# 18. Multi-message session does NOT fire session.completed multiple times
# ===========================================================================
class TestMultiMessageSession:
@pytest.mark.asyncio
async def test_no_session_completed_per_message(self, manager):
"""Verify session.completed does NOT fire when agent loop finishes.
It should only fire on close_session() or persist_all_sessions()."""
config = AgentConfig(name="Multi-msg", model="sonnet", mode="agent")
session = await manager.launch_agent(config)
# Simulate 3 message exchanges
for i in range(3):
session.messages.append(Message(role="user", content=f"msg {i}"))
session.messages.append(Message(role="assistant", content=f"reply {i}"))
# At this point, no session.completed should have fired
completed = events("session.completed")
assert len(completed) == 0, f"session.completed fired {len(completed)} times before close!"
# Now close — exactly 1 session.completed
session.status = "completed"
await manager.close_session(session.id)
completed = events("session.completed")
assert len(completed) == 1, f"Expected 1 session.completed, got {len(completed)}"
# ===========================================================================
# 19. Token tracking
# ===========================================================================
class TestTokenTracking:
@pytest.mark.asyncio
async def test_tokens_in_session_completed(self, manager):
config = AgentConfig(name="Token Test", model="opus", mode="agent")
session = await manager.launch_agent(config)
# Simulate SDK token reporting
session.tokens = {"input": 50000, "output": 15000}
session.cost_usd = 0.25
session.status = "completed"
await manager.close_session(session.id)
e = last_event("session.completed")
assert e["properties"]["input_tokens"] == 50000
assert e["properties"]["output_tokens"] == 15000
assert e["properties"]["cost_usd"] == 0.25
# ===========================================================================
# 20. Full lifecycle integration test
# ===========================================================================
class TestFullLifecycle:
@pytest.mark.asyncio
async def test_complete_session_lifecycle(self, manager):
"""Simulate a complete user session: launch, messages, close."""
# 1. Launch
config = AgentConfig(
name="Full Lifecycle",
model="sonnet",
mode="agent",
provider="anthropic",
dashboard_id="dash-001",
)
session = await manager.launch_agent(config)
assert len(events("session.started")) == 1
# 2. Simulate messages
session.messages.append(Message(role="user", content="Hello, help me code"))
session.messages.append(Message(role="assistant", content="Sure, let me help"))
session.messages.append(Message(
role="tool_call",
content={"tool": "Bash", "input": {"command": "ls"}},
))
session.messages.append(Message(
role="tool_result",
content={"text": "file1.py\nfile2.py", "tool_name": "Bash", "elapsed_ms": 50},
))
session.messages.append(Message(role="user", content="Now run tests"))
session.messages.append(Message(role="assistant", content="Running tests..."))
session.cost_usd = 0.08
session.tokens = {"input": 20000, "output": 5000}
# 3. No session.completed yet
assert len(events("session.completed")) == 0
# 4. Close
session.status = "completed"
await manager.close_session(session.id)
# 5. Verify session.completed
e = last_event("session.completed")
assert e["properties"]["message_count"] == 4 # 2 user + 2 assistant
assert e["properties"]["tool_count"] == 1 # 1 tool call
assert "Bash" in e["properties"]["tools_list"]
assert e["properties"]["cost_usd"] == 0.08
assert e["properties"]["input_tokens"] == 20000
assert e["properties"]["output_tokens"] == 5000
assert e["properties"]["dashboard_id"] == "dash-001"
assert e["properties"]["first_user_message"] == "Hello, help me code"
assert e["properties"]["duration_seconds"] >= 0 # may be 0 in fast tests
@pytest.mark.asyncio
async def test_session_with_error(self, manager):
"""Verify error sessions still fire session.completed on close."""
config = AgentConfig(name="Error Test", model="sonnet", mode="agent")
session = await manager.launch_agent(config)
session.status = "error"
await manager.close_session(session.id)
e = last_event("session.completed")
assert e["properties"]["status"] == "error"
@pytest.mark.asyncio
async def test_session_with_branches(self, manager):
"""Verify branch count in session.completed."""
config = AgentConfig(name="Branch Test", model="sonnet", mode="agent")
session = await manager.launch_agent(config)
# Simulate branching
from backend.apps.agents.models import MessageBranch
session.branches["branch-1"] = MessageBranch(id="branch-1", parent_branch_id="main")
session.branches["branch-2"] = MessageBranch(id="branch-2", parent_branch_id="branch-1")
session.status = "completed"
await manager.close_session(session.id)
e = last_event("session.completed")
assert e["properties"]["branch_count"] == 3 # main + branch-1 + branch-2
# ===========================================================================
# 21. MCP server name extraction in tool.executed
# ===========================================================================
class TestMCPServerExtraction:
def test_standard_mcp_format(self):
"""Test mcp__server-name__tool_name format."""
import re
tool_name = "mcp__google-workspace__searchGmail"
m = re.match(r"mcp__([^_]+(?:-[^_]+)*)__(.+)", tool_name)
assert m is not None
assert m.group(1) == "google-workspace"
assert m.group(2) == "searchGmail"
def test_builtin_tool_no_server(self):
import re
tool_name = "Bash"
m = re.match(r"mcp__([^_]+(?:-[^_]+)*)__(.+)", tool_name)
assert m is None
def test_browser_agent_mcp_format(self):
import re
tool_name = "mcp__openswarm-browser-agent__CreateBrowserAgent"
m = re.match(r"mcp__([^_]+(?:-[^_]+)*)__(.+)", tool_name)
assert m is not None
assert m.group(1) == "openswarm-browser-agent"
assert m.group(2) == "CreateBrowserAgent"
# ===========================================================================
# 22. Settings update tracking
# ===========================================================================
class TestSettingsUpdateTracking:
@pytest.mark.asyncio
async def test_provider_key_change_detected(self):
"""Test that adding an API key fires provider.configured."""
from backend.apps.settings.models import AppSettings
old = AppSettings(anthropic_api_key=None)
new = AppSettings(anthropic_api_key="sk-test-key")
# Simulate what update_settings does
provider_keys = {
"anthropic_api_key": "anthropic",
"openai_api_key": "openai",
"google_api_key": "gemini",
"openrouter_api_key": "openrouter",
}
for key, provider_name in provider_keys.items():
old_val = bool(getattr(old, key, None))
new_val = bool(getattr(new, key, None))
if old_val != new_val:
record("provider.configured", {
"provider": provider_name,
"action": "added" if new_val else "removed",
})
e = last_event("provider.configured")
assert e["properties"]["provider"] == "anthropic"
assert e["properties"]["action"] == "added"
@pytest.mark.asyncio
async def test_settings_change_excludes_secrets(self):
"""Verify secret keys are not included in changed_keys."""
from backend.apps.settings.models import AppSettings
old = AppSettings(theme="dark", anthropic_api_key="old-key")
new = AppSettings(theme="light", anthropic_api_key="new-key")
old_dict = old.model_dump()
new_dict = new.model_dump()
secret_keys = {"anthropic_api_key", "openai_api_key", "google_api_key",
"openrouter_api_key", "claude_subscription_token",
"openai_subscription_token", "gemini_subscription_token",
"copilot_github_token", "copilot_token", "installation_id"}
safe_changed = [
k for k in new_dict
if k in old_dict and new_dict[k] != old_dict[k] and k not in secret_keys
]
assert "theme" in safe_changed
assert "anthropic_api_key" not in safe_changed
# ===========================================================================
# 23. Cost snapshot accuracy
# ===========================================================================
class TestCostSnapshotAccuracy:
def test_nine_router_cost_in_heartbeat(self):
"""Verify heartbeat includes 9Router cost data."""
record("app.heartbeat", {
"active_session_count": 2,
"nine_router_total_cost": 235.50,
"nine_router_total_prompt_tokens": 5000000,
"nine_router_total_completion_tokens": 1500000,
"nine_router_total_requests": 1200,
"cost_model_claude_sonnet_4_20250514": 180.00,
"cost_model_claude_opus_4_20250514": 55.50,
})
e = last_event("app.heartbeat")
assert e["properties"]["nine_router_total_cost"] == 235.50
assert e["properties"]["cost_model_claude_sonnet_4_20250514"] == 180.00
def test_cost_snapshot_separate_event(self):
"""Verify cost.snapshot fires independently with accurate totals."""
record("cost.snapshot", {
"total_cost_usd": 235.50,
"total_prompt_tokens": 5000000,
"total_completion_tokens": 1500000,
"total_requests": 1200,
})
e = last_event("cost.snapshot")
assert e["properties"]["total_cost_usd"] == 235.50
# ===========================================================================
# 24. Edge cases
# ===========================================================================
class TestEdgeCases:
@pytest.mark.asyncio
async def test_close_session_with_no_messages(self, manager):
"""Session closed without any messages should still fire session.completed."""
config = AgentConfig(name="Empty", model="sonnet", mode="agent")
session = await manager.launch_agent(config)
session.status = "completed"
await manager.close_session(session.id)
e = last_event("session.completed")
assert e["properties"]["message_count"] == 0
assert e["properties"]["tool_count"] == 0
assert e["properties"]["first_user_message"] == ""
@pytest.mark.asyncio
async def test_close_session_with_zero_cost(self, manager):
"""Session with 0 cost should still report cost_usd=0."""
config = AgentConfig(name="Free", model="sonnet", mode="agent")
session = await manager.launch_agent(config)
session.status = "completed"
await manager.close_session(session.id)
e = last_event("session.completed")
assert e["properties"]["cost_usd"] == 0.0
assert e["properties"]["input_tokens"] == 0
assert e["properties"]["output_tokens"] == 0
def test_record_with_no_posthog(self):
"""record() should not crash if PostHog is not initialized."""
import backend.apps.analytics.collector as collector
old_ph = collector._posthog
collector._posthog = None
# Should not raise
record("test.event", {"key": "value"})
collector._posthog = old_ph
def test_record_with_none_properties(self):
"""record() handles None properties gracefully."""
record("test.event", None)
e = last_event("test.event")
assert "os" in e["properties"] # system props still added
+3 -304
View File
@@ -1,27 +1,8 @@
"""Stress tests for the Phase 1 / 2 / 3 perceived-latency changes.
Hits everything we touched on the eric/v2 branch:
"""Stress tests for live perceived-latency paths.
- Message.client_message_id round-trip (optimistic dedupe)
- Mode migration: 'chat' -> 'ask' on session reconcile + lifespan
deletion of stale built-in chat.json
- ContentBlock + StreamEvent now accept type='thinking' /
delta_type='thinking_delta' without breaking existing types
- Anthropic provider forwards thinking content_block_start /
content_block_delta with the right shape
- Agent loop emits agent:stream_start{role:'thinking'},
agent:stream_delta, agent:stream_end for thinking blocks AND
persists a Message(role='thinking') after stream end
- DashboardLayout serializes notes round-trip
- exclude_dynamic_sections reaches the SDK kwargs (presence-only;
we don't run the real CLI here)
Each test runs many randomized iterations to surface race conditions
and bad assumptions. Stub the network and CLI throughout these
tests are pure logic, no real Anthropic calls.
Run:
cd backend && .venv/bin/python -m pytest tests/test_phase1_stress.py -v
- Mode migration: 'chat' -> 'ask' on reconcile + lifespan deletion
- DashboardLayout notes round-trip
"""
from __future__ import annotations
@@ -213,288 +194,6 @@ def test_reconcile_idempotent():
assert mtime_after_first == mtime_after_second, "reconcile must be idempotent"
# ---------------------------------------------------------------------------
# Group 3 — ContentBlock / StreamEvent thinking acceptance
# ---------------------------------------------------------------------------
def test_content_block_thinking_type():
from backend.apps.agents.providers.base import ContentBlock
cb = ContentBlock(type="thinking", text="some reasoning")
assert cb.type == "thinking"
assert cb.text == "some reasoning"
assert cb.tool_call is None
def test_stream_event_thinking_delta():
from backend.apps.agents.providers.base import StreamEvent
e = StreamEvent(type="content_block_delta", delta_type="thinking_delta", text="hmm")
assert e.delta_type == "thinking_delta"
assert e.text == "hmm"
# Existing types still work — no regression
e2 = StreamEvent(type="content_block_delta", delta_type="text_delta", text="hi")
assert e2.delta_type == "text_delta"
# ---------------------------------------------------------------------------
# Group 4 — Anthropic provider thinking forwarding
#
# We feed a fake raw_stream (mimicking the SDK's async generator) through
# AnthropicProvider.stream_message and confirm the right StreamEvents come
# out. No network.
# ---------------------------------------------------------------------------
class _FakeRawEvent:
def __init__(self, **kwargs):
for k, v in kwargs.items():
setattr(self, k, v)
class _FakeBlock:
def __init__(self, **kwargs):
for k, v in kwargs.items():
setattr(self, k, v)
class _FakeDelta:
def __init__(self, **kwargs):
for k, v in kwargs.items():
setattr(self, k, v)
@pytest.mark.asyncio
async def test_anthropic_provider_forwards_thinking_blocks():
"""Mock the raw Anthropic stream with a thinking block + thinking_delta
+ content_block_stop, and assert AnthropicProvider yields the
normalized StreamEvents the agent_loop expects."""
from backend.apps.agents.providers.anthropic import AnthropicProvider
raw_events = [
# thinking block opens at index 0
_FakeRawEvent(type="content_block_start", index=0,
content_block=_FakeBlock(type="thinking")),
_FakeRawEvent(type="content_block_delta", index=0,
delta=_FakeDelta(type="thinking_delta", thinking="step 1, ")),
_FakeRawEvent(type="content_block_delta", index=0,
delta=_FakeDelta(type="thinking_delta", thinking="step 2.")),
# signature_delta on thinking — must be ignored, not crash
_FakeRawEvent(type="content_block_delta", index=0,
delta=_FakeDelta(type="signature_delta", signature="abc==")),
_FakeRawEvent(type="content_block_stop", index=0),
# text block follows at index 1
_FakeRawEvent(type="content_block_start", index=1,
content_block=_FakeBlock(type="text")),
_FakeRawEvent(type="content_block_delta", index=1,
delta=_FakeDelta(type="text_delta", text="hi")),
_FakeRawEvent(type="content_block_stop", index=1),
]
async def fake_stream():
for ev in raw_events:
yield ev
# AnthropicProvider takes api_key/auth_token/base_url; we monkeypatch
# its `client.messages.create` after construction so no real
# SDK client is needed.
provider = AnthropicProvider(api_key="test-key")
provider.client.messages.create = AsyncMock(return_value=fake_stream())
out_events = []
async for ev in provider.stream_message(model="sonnet", system=None, messages=[], tools=[]):
out_events.append(ev)
types = [(e.type, e.block_type, e.delta_type) for e in out_events]
# Thinking block should produce: start, 2x delta, stop. signature_delta ignored.
assert ("content_block_start", "thinking", "") in types
assert types.count(("content_block_delta", "", "thinking_delta")) == 2
assert ("content_block_start", "text", "") in types
assert ("content_block_delta", "", "text_delta") in types
thinking_text = "".join(
e.text for e in out_events
if e.type == "content_block_delta" and e.delta_type == "thinking_delta"
)
assert thinking_text == "step 1, step 2."
# ---------------------------------------------------------------------------
# Group 5 — Agent loop end-to-end thinking → WS events + persisted message
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_agent_loop_emits_thinking_stream_and_persists_message():
"""Drive the agent loop with a fake provider that yields thinking,
text, and one tool_use. Verify it emits the right WS events AND
persists a Message(role='thinking') via _emit_collected_messages."""
from backend.apps.agents.providers.base import StreamEvent
captured_ws: list[tuple[str, dict]] = []
async def fake_emitter(event: str, payload: dict):
captured_ws.append((event, payload))
# Build a fake provider yielding our normalized StreamEvents.
class FakeProvider:
async def stream_message(self, **kwargs):
yield StreamEvent(type="content_block_start", index=0, block_type="thinking")
yield StreamEvent(type="content_block_delta", index=0,
delta_type="thinking_delta", text="reasoning… ")
yield StreamEvent(type="content_block_delta", index=0,
delta_type="thinking_delta", text="more.")
yield StreamEvent(type="content_block_stop", index=0)
yield StreamEvent(type="content_block_start", index=1, block_type="text")
yield StreamEvent(type="content_block_delta", index=1,
delta_type="text_delta", text="hello!")
yield StreamEvent(type="content_block_stop", index=1)
yield StreamEvent(type="message_stop")
from backend.apps.agents.agent_loop import AgentLoop
loop = AgentLoop(
session_id="s1",
provider=FakeProvider(),
model="sonnet",
system_prompt="x",
tools=[],
ws_emitter=fake_emitter,
hitl_handler=AsyncMock(return_value=(True, None)),
tool_executor=AsyncMock(return_value=[{"type": "text", "text": "ok"}]),
)
response = await loop._stream_and_collect()
# Stream events: thinking start + 2 deltas + stream_end, then text start + delta + (text end at message_stop)
events_by_type = {}
for ev, payload in captured_ws:
events_by_type.setdefault(ev, []).append(payload)
# Thinking should have its own stream_start with role='thinking'
starts = events_by_type.get("agent:stream_start", [])
thinking_starts = [s for s in starts if s.get("role") == "thinking"]
assistant_starts = [s for s in starts if s.get("role") == "assistant"]
assert len(thinking_starts) == 1, f"expected 1 thinking start, got {len(thinking_starts)}"
assert len(assistant_starts) == 1, "expected 1 assistant text start"
# Two thinking deltas
deltas = events_by_type.get("agent:stream_delta", [])
thinking_msg_id = thinking_starts[0]["message_id"]
thinking_deltas = [d for d in deltas if d.get("message_id") == thinking_msg_id]
assert len(thinking_deltas) == 2
assert "".join(d["delta"] for d in thinking_deltas) == "reasoning… more."
# Thinking stream_end fires (text doesn't get stream_end inside _stream_and_collect — closes at message_stop)
ends = events_by_type.get("agent:stream_end", [])
assert any(e["message_id"] == thinking_msg_id for e in ends), "thinking must emit stream_end"
# Now persist via _emit_collected_messages and verify a thinking
# Message went out
captured_ws.clear()
await loop._emit_collected_messages(
response.content,
text_msg_id=assistant_starts[0]["message_id"],
tool_msg_ids={},
)
persisted = [p for ev, p in captured_ws if ev == "agent:message"]
roles = [p["message"]["role"] for p in persisted]
assert "thinking" in roles, "thinking content must be persisted as a Message"
assert "assistant" in roles
thinking_msg = next(p for p in persisted if p["message"]["role"] == "thinking")
assert thinking_msg["message"]["content"] == "reasoning… more."
@pytest.mark.asyncio
async def test_agent_loop_handles_no_thinking_gracefully():
"""Provider that emits zero thinking blocks must still work.
Regression guard against the new branch breaking text-only paths."""
from backend.apps.agents.providers.base import StreamEvent
from backend.apps.agents.agent_loop import AgentLoop
captured_ws = []
async def fake_emitter(event, payload):
captured_ws.append((event, payload))
class TextOnly:
async def stream_message(self, **kwargs):
yield StreamEvent(type="content_block_start", index=0, block_type="text")
yield StreamEvent(type="content_block_delta", index=0,
delta_type="text_delta", text="just text")
yield StreamEvent(type="content_block_stop", index=0)
yield StreamEvent(type="message_stop")
loop = AgentLoop(
session_id="s2", provider=TextOnly(), model="sonnet", system_prompt=None,
tools=[],
ws_emitter=fake_emitter,
hitl_handler=AsyncMock(return_value=(True, None)),
tool_executor=AsyncMock(return_value=[]),
)
resp = await loop._stream_and_collect()
starts = [p for ev, p in captured_ws if ev == "agent:stream_start"]
# Exactly one assistant start, zero thinking starts
assert len([s for s in starts if s.get("role") == "thinking"]) == 0
assert len([s for s in starts if s.get("role") == "assistant"]) == 1
assert any(b.type == "text" for b in resp.content)
@pytest.mark.asyncio
async def test_agent_loop_stress_many_thinking_blocks():
"""Hammer the loop with a long sequence of interleaved thinking +
text + tool blocks. Ensures the per-index buffers don't leak and
every block gets the right WS events."""
from backend.apps.agents.providers.base import StreamEvent
from backend.apps.agents.agent_loop import AgentLoop
captured = []
async def fake_emitter(ev, p):
captured.append((ev, p))
class Mix:
async def stream_message(self, **kwargs):
idx = 0
for turn in range(40):
yield StreamEvent(type="content_block_start", index=idx, block_type="thinking")
for _ in range(random.randint(1, 5)):
yield StreamEvent(type="content_block_delta", index=idx,
delta_type="thinking_delta", text=f"t{idx} ")
yield StreamEvent(type="content_block_stop", index=idx)
idx += 1
yield StreamEvent(type="content_block_start", index=idx, block_type="text")
yield StreamEvent(type="content_block_delta", index=idx,
delta_type="text_delta", text=f"text-{idx}")
yield StreamEvent(type="content_block_stop", index=idx)
idx += 1
yield StreamEvent(type="message_stop")
loop = AgentLoop(
session_id="s3", provider=Mix(), model="sonnet", system_prompt=None,
tools=[],
ws_emitter=fake_emitter,
hitl_handler=AsyncMock(return_value=(True, None)),
tool_executor=AsyncMock(return_value=[]),
)
resp = await loop._stream_and_collect()
starts = [p for ev, p in captured if ev == "agent:stream_start"]
ends = [p for ev, p in captured if ev == "agent:stream_end"]
# 40 thinking + 1 assistant (text accumulates into one stream_text_msg_id)
thinking_starts = [s for s in starts if s.get("role") == "thinking"]
assistant_starts = [s for s in starts if s.get("role") == "assistant"]
assert len(thinking_starts) == 40, f"got {len(thinking_starts)} thinking starts, want 40"
assert len(assistant_starts) == 1, "all text blocks share one assistant stream id"
# Each thinking block must have its own stream_end
thinking_ids = {s["message_id"] for s in thinking_starts}
end_ids = {e["message_id"] for e in ends}
assert thinking_ids.issubset(end_ids), "every thinking block needs a stream_end"
# ---------------------------------------------------------------------------
# Group 6 — Notes layout serialization
+353
View File
@@ -0,0 +1,353 @@
"""Tests for the service-sync layer.
Public surface is a single `sync(data)` function. The desktop hands off
opaque dicts; the cloud determines what they are. Tests verify:
- Envelope (install_id, user_id) stamped on every submission
- Opt-out gate works
- Test sink intercepts every sync
- Spool round-trip (enqueue/drain/acknowledge)
- Legacy shims (submit, record, identify) route through sync
Run:
cd backend && python -m pytest tests/test_service.py -v
"""
from __future__ import annotations
import json
import os
import tempfile
import time
from unittest.mock import patch
import pytest
_tmpdir = tempfile.mkdtemp()
os.environ.setdefault("OPENSWARM_DATA_DIR", _tmpdir)
@pytest.fixture(autouse=True)
def patch_settings(tmp_path):
sf = tmp_path / "settings.json"
sf.write_text(json.dumps({
"installation_id": "test-install-abc",
"analytics_opt_in": True,
}))
import backend.apps.settings.settings as settings_mod
old = settings_mod.SETTINGS_FILE
settings_mod.SETTINGS_FILE = str(sf)
yield
settings_mod.SETTINGS_FILE = old
@pytest.fixture(autouse=True)
def fresh_client(tmp_path):
import backend.apps.service.client as client
client._install_id = None
client._user_id = None
client._test_sink = None
spool = tmp_path / "spool.db"
with patch.object(client, "_spool_path", lambda: str(spool)):
yield
@pytest.fixture
def sink():
captured: list[tuple[str, dict]] = []
import backend.apps.service.client as client
client.set_test_sink(lambda kind, body: captured.append((kind, body)))
yield captured
client.set_test_sink(None)
# --- core sync ---------------------------------------------------------------
def test_sync_basic(sink):
from backend.apps.service.client import sync
sync({"foo": "bar"})
assert len(sink) == 1
_, body = sink[0]
assert body["d"] == {"foo": "bar"}
def test_sync_carries_install_id(sink):
from backend.apps.service.client import sync
sync({})
_, body = sink[0]
assert body["client_state"]["install_id"] == "test-install-abc"
def test_sync_carries_user_id_when_set(sink):
from backend.apps.service.client import sync, set_user_id
set_user_id("alice@example.com")
sync({})
_, body = sink[0]
assert body["client_state"]["user_id"] == "alice@example.com"
def test_sync_no_user_id_when_not_set(sink):
from backend.apps.service.client import sync
sync({})
_, body = sink[0]
assert "user_id" not in body["client_state"]
def test_sync_user_id_cleared_with_none(sink):
from backend.apps.service.client import sync, set_user_id
set_user_id("alice")
set_user_id(None)
sync({})
_, body = sink[0]
assert "user_id" not in body["client_state"]
def test_sync_user_id_cleared_with_empty(sink):
from backend.apps.service.client import sync, set_user_id
set_user_id("alice")
set_user_id("")
sync({})
_, body = sink[0]
assert "user_id" not in body["client_state"]
def test_sync_environment_metadata(sink):
from backend.apps.service.client import sync
sync({})
_, body = sink[0]
cs = body["client_state"]
assert cs.get("device_type") == "desktop"
assert cs.get("os")
assert cs.get("os_version")
def test_sync_payload_round_trips(sink):
from backend.apps.service.client import sync
data = {"deeply": {"nested": [1, 2]}, "flag": True, "n": 3.14}
sync(data)
_, body = sink[0]
assert body["d"] == data
def test_sync_empty_data(sink):
from backend.apps.service.client import sync
sync({})
assert len(sink) == 1
def test_sync_none_treated_as_empty(sink):
from backend.apps.service.client import sync
sync(None)
_, body = sink[0]
assert body["d"] == {}
def test_sync_timestamp_present(sink):
from backend.apps.service.client import sync
sync({})
_, body = sink[0]
assert isinstance(body["t"], float)
assert body["t"] > 0
# --- opt-out gating ----------------------------------------------------------
def test_opt_out_blocks_sync(sink, tmp_path):
sf = tmp_path / "minimal.json"
sf.write_text(json.dumps({
"installation_id": "test-install-abc",
"analytics_opt_in": False,
}))
import backend.apps.settings.settings as settings_mod
settings_mod.SETTINGS_FILE = str(sf)
from backend.apps.service.client import sync
sync({"x": 1})
assert sink == []
def test_standard_mode_allows_sync(sink):
from backend.apps.service.client import sync
sync({})
sync({})
assert len(sink) == 2
def test_settings_load_failure_defaults_to_enabled(sink):
import backend.apps.settings.settings as settings_mod
settings_mod.SETTINGS_FILE = "/nonexistent/path/settings.json"
from backend.apps.service.client import sync
sync({})
assert len(sink) == 1
# --- legacy shims ------------------------------------------------------------
def test_legacy_submit_routes_through_sync(sink):
from backend.apps.service.client import submit
submit("event", {"test": True})
assert len(sink) == 1
_, body = sink[0]
assert body["d"] == {"test": True}
def test_legacy_record_routes_through_sync(sink):
from backend.apps.service.client import record
record("some.event", {"k": "v"})
assert len(sink) == 1
def test_legacy_identify_routes_through_sync(sink):
from backend.apps.service.client import identify
identify({"plan": "pro"})
assert len(sink) == 1
def test_legacy_submit_session_close(sink):
from backend.apps.service.client import submit_session_close
submit_session_close({"id": "s-1", "cost_usd": 0.42})
assert len(sink) == 1
def test_legacy_submit_diagnostic(sink):
from backend.apps.service.client import submit_diagnostic
submit_diagnostic({"kind": "error_caught"})
assert len(sink) == 1
# --- spool -------------------------------------------------------------------
def test_buffer_enqueue_and_drain(tmp_path):
from backend.apps.service import buffer
spool = str(tmp_path / "s.db")
buffer.enqueue(spool, "s:/api/service/sync", {"a": 1}, now=time.time())
buffer.enqueue(spool, "s:/api/service/sync", {"a": 2}, now=time.time())
assert buffer.count(spool) == 2
rows = buffer.drain(spool, batch_size=10)
assert [r[2]["a"] for r in rows] == [1, 2]
buffer.acknowledge(spool, [r[0] for r in rows])
assert buffer.count(spool) == 0
def test_buffer_drain_partial(tmp_path):
from backend.apps.service import buffer
spool = str(tmp_path / "s.db")
for i in range(5):
buffer.enqueue(spool, "s:/x", {"i": i}, now=time.time())
rows = buffer.drain(spool, batch_size=2)
assert len(rows) == 2
assert buffer.count(spool) == 5
buffer.acknowledge(spool, [r[0] for r in rows])
assert buffer.count(spool) == 3
def test_buffer_clear(tmp_path):
from backend.apps.service import buffer
spool = str(tmp_path / "s.db")
buffer.enqueue(spool, "s:/x", {}, now=time.time())
buffer.clear(spool)
assert buffer.count(spool) == 0
def test_buffer_missing_file(tmp_path):
from backend.apps.service import buffer
assert buffer.count(str(tmp_path / "nope.db")) == 0
assert buffer.drain(str(tmp_path / "nope.db")) == []
def test_buffer_corrupt_row_dropped(tmp_path):
from backend.apps.service import buffer
spool = str(tmp_path / "s.db")
with buffer._conn(spool) as c:
c.execute(
"INSERT INTO spool (kind, payload, created_at) VALUES (?, ?, ?)",
("s:/x", "{not json", time.time()),
)
rows = buffer.drain(spool)
assert rows == []
assert buffer.count(spool) == 0
def test_buffer_size_cap(tmp_path):
from backend.apps.service import buffer
spool = str(tmp_path / "s.db")
big = "x" * 1024
for i in range(200):
buffer.enqueue(spool, "s:/x", {"i": i, "pad": big}, now=time.time())
assert buffer.count(spool) == 200
@pytest.mark.asyncio
async def test_drain_spool_empty():
from backend.apps.service.client import drain_spool
n = await drain_spool()
assert n == 0
# --- identity ----------------------------------------------------------------
def test_install_id_persisted(sink, tmp_path):
sf = tmp_path / "fresh.json"
sf.write_text(json.dumps({"analytics_opt_in": True}))
import backend.apps.settings.settings as settings_mod
settings_mod.SETTINGS_FILE = str(sf)
import backend.apps.service.client as client
client._install_id = None
from backend.apps.service.client import sync
sync({})
_, body = sink[0]
iid = body["client_state"]["install_id"]
assert iid
raw = json.loads(sf.read_text())
assert raw["installation_id"] == iid
def test_install_id_stable(sink):
from backend.apps.service.client import sync
sync({})
sync({})
iid1 = sink[0][1]["client_state"]["install_id"]
iid2 = sink[1][1]["client_state"]["install_id"]
assert iid1 == iid2
# --- SubApp endpoints --------------------------------------------------------
@pytest.mark.asyncio
async def test_endpoint_submit(sink):
from backend.apps.service.service import post_submit
res = await post_submit({"kind": "state", "payload": {"x": 1}})
assert res == {"ok": True}
assert len(sink) == 1
@pytest.mark.asyncio
async def test_endpoint_submit_missing_payload(sink):
from backend.apps.service.service import post_submit
res = await post_submit({"kind": "state"})
assert res["ok"] is False
@pytest.mark.asyncio
async def test_endpoint_event_happy(sink):
from backend.apps.service.service import post_event
res = await post_event({"surface": "test", "action": "happy"})
assert res == {"ok": True}
assert len(sink) == 1
@pytest.mark.asyncio
async def test_endpoint_event_missing_surface(sink):
from backend.apps.service.service import post_event
res = await post_event({"action": "x"})
assert res["ok"] is False
@pytest.mark.asyncio
async def test_endpoint_spool_count(tmp_path):
from backend.apps.service import client as svc, buffer
from backend.apps.service.service import spool_count
spool = str(tmp_path / "spool.db")
with patch.object(svc, "_spool_path", lambda: spool):
buffer.enqueue(spool, "s:/x", {}, now=time.time())
result = await spool_count()
assert result == {"pending": 1}
+222
View File
@@ -0,0 +1,222 @@
"""Service-sync compatibility tests.
Verifies the legacy compatibility helpers on backend/apps/service/client.py
(record, submit_event, submit_session_close, etc.) still produce the right
opaque payload through the unified sync() entry point. Forward-looking
contract tests live in test_service.py; this file covers the legacy shim
surface so it can be deprecated cleanly later.
Run with:
cd backend && python -m pytest tests/test_service_legacy.py -v
"""
import json
import os
import tempfile
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import pytest
# Sandbox the data dir before any module import touches settings on disk.
_tmpdir = tempfile.mkdtemp()
os.environ.setdefault("OPENSWARM_DATA_DIR", _tmpdir)
# Captured syncs from this test run.
_captured_syncs: list[dict] = []
@pytest.fixture(autouse=True)
def reset_captured_syncs():
_captured_syncs.clear()
yield
_captured_syncs.clear()
@pytest.fixture(autouse=True)
def install_sync_sink():
"""Install a service-sync sink and decode the opaque payload back into
a structured shape for assertions. The sink translates the new shape
{client_state, d, t} into a legacy-compatible {kind, distinct_id, props}
bag so existing tests can keep their assertions terse."""
import backend.apps.service.client as svc_client
def _sink(label: str, body: dict):
cs = body.get("client_state") or {}
payload = body.get("d") or body.get("payload") or {}
# Infer a synthetic kind from payload shape — same dispatch logic
# as the cloud uses in production.
if "status" in payload and "messages" in payload:
status = payload.get("status", "unknown")
kind = f"session.{status}" if status != "unknown" else "session.completed"
props = dict(payload)
elif "identity" in payload:
kind = "state.update"
props = dict(payload)
elif "diagnostic" in payload:
kind = "diagnostic.fired"
props = dict(payload)
elif "s" in payload and "a" in payload:
kind = f"{payload['s']}.{payload['a']}"
props = dict(payload.get("p") or {})
elif "surface" in payload:
surface = payload.get("surface", "")
action = payload.get("action", "fired")
kind = f"{surface}.{action}"
props = dict(payload.get("props") or {})
else:
kind = "state.update"
props = dict(payload)
if payload.get("session_id"):
props["session_id"] = payload["session_id"]
if payload.get("dashboard_id"):
props["dashboard_id"] = payload["dashboard_id"]
props.setdefault("os", cs.get("os", ""))
props.setdefault("platform", cs.get("os", ""))
_captured_syncs.append({
"kind": kind,
"distinct_id": cs.get("install_id", ""),
"properties": props,
})
old_sink = svc_client._test_sink
old_iid = svc_client._install_id
svc_client.set_test_sink(_sink)
svc_client._install_id = "test-install-id"
yield
svc_client.set_test_sink(old_sink)
svc_client._install_id = old_iid
@pytest.fixture(autouse=True)
def mock_settings(tmp_path):
"""Sandbox settings so tests don't read or write the real config."""
settings_file = tmp_path / "settings.json"
settings_file.write_text(json.dumps({
"service_diagnostics_mode": "standard",
"installation_id": "test-install-id",
}))
import backend.apps.settings.settings as settings_mod
old_file = settings_mod.SETTINGS_FILE
settings_mod.SETTINGS_FILE = str(settings_file)
yield
settings_mod.SETTINGS_FILE = old_file
@pytest.fixture(autouse=True)
def mock_sessions_dir(tmp_path):
"""Use temp dir for session persistence."""
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
import backend.config.paths as paths_mod
old_dir = paths_mod.SESSIONS_DIR
paths_mod.SESSIONS_DIR = str(sessions_dir)
yield str(sessions_dir)
paths_mod.SESSIONS_DIR = old_dir
def syncs(kind: str | None = None) -> list[dict]:
"""Return captured syncs, optionally filtered by inferred kind."""
if kind:
return [s for s in _captured_syncs if s["kind"] == kind]
return list(_captured_syncs)
def last_sync(kind: str) -> dict:
"""Return the last captured sync of a given inferred kind."""
matching = syncs(kind)
assert matching, f"No {kind} syncs captured. Got: {[s['kind'] for s in _captured_syncs]}"
return matching[-1]
# Import application modules (after fixtures are wired).
from backend.apps.service.client import record
from backend.apps.agents.models import AgentConfig, AgentSession, Message, ApprovalRequest
from backend.apps.agents.agent_manager import AgentManager
@pytest.fixture
def manager():
"""Fresh AgentManager per test."""
return AgentManager()
# ---------------------------------------------------------------------------
# 1. record() — legacy shim correctness
# ---------------------------------------------------------------------------
class TestRecordBasics:
def test_record_sends_payload(self):
record("test.report", {"key": "value"})
s = last_sync("test.report")
assert s["properties"]["key"] == "value"
assert s["distinct_id"] == "test-install-id"
def test_record_adds_os_and_platform(self):
record("test.report", {})
s = last_sync("test.report")
assert "os" in s["properties"]
assert "platform" in s["properties"]
def test_record_includes_session_id(self):
record("test.report", {}, session_id="sess123")
s = last_sync("test.report")
assert s["properties"]["session_id"] == "sess123"
def test_record_includes_dashboard_id(self):
record("test.report", {}, dashboard_id="dash456")
s = last_sync("test.report")
assert s["properties"]["dashboard_id"] == "dash456"
# ---------------------------------------------------------------------------
# 2. Multi-message session — close fires exactly once
# ---------------------------------------------------------------------------
class TestMultiMessageSession:
@pytest.mark.asyncio
async def test_session_completes_only_on_close(self, manager):
"""Verify a completed-session sync does NOT fire mid-loop. It should
only fire on close_session() or persist_all_sessions()."""
config = AgentConfig(name="Multi-msg", model="sonnet", mode="agent")
session = await manager.launch_agent(config)
for i in range(3):
session.messages.append(Message(role="user", content=f"msg {i}"))
session.messages.append(Message(role="assistant", content=f"reply {i}"))
completed = syncs("session.completed")
assert len(completed) == 0, f"session-completed fired {len(completed)} times before close"
session.status = "completed"
await manager.close_session(session.id)
completed = syncs("session.completed")
assert len(completed) == 1, f"expected 1 completed sync, got {len(completed)}"
# ---------------------------------------------------------------------------
# 3. Token + cost capture on close
# ---------------------------------------------------------------------------
class TestTokenTracking:
@pytest.mark.asyncio
async def test_tokens_and_cost_in_session_close(self, manager):
config = AgentConfig(name="Token Test", model="opus", mode="agent")
session = await manager.launch_agent(config)
session.tokens = {"input": 50000, "output": 15000}
session.cost_usd = 0.25
session.status = "completed"
await manager.close_session(session.id)
s = last_sync("session.completed")
assert s["properties"]["tokens"]["input"] == 50000
assert s["properties"]["tokens"]["output"] == 15000
assert s["properties"]["cost_usd"] == 0.25
File diff suppressed because it is too large Load Diff
+410
View File
@@ -0,0 +1,410 @@
"""Mirror tests for the frontend label/result logic.
The JS implementations live in:
- frontend/src/app/pages/AgentChat/toolLabels.ts
- frontend/src/app/pages/AgentChat/ToolCallBubble.tsx (getResultSummary,
getInputSummary, parseMcpToolName, bashCommandDetail, prettyPath, prettyUrl,
quoteQuery)
We re-implement the rules in Python and pin them as tests so we get
regression coverage from `pytest` too. Any drift between the JS source
and these Python mirrors is the production-side breakage we want to
catch.
"""
from __future__ import annotations
import random
import re
import pytest
# ===========================================================================
# Mirror: parseMcpToolName.displayName (sentence-case rule)
# ===========================================================================
def parse_mcp_tool_name_display(raw_name: str) -> str | None:
"""Mirror of frontend parseMcpToolName().displayName."""
m = re.match(r"^mcp__([^_]+(?:-[^_]+)*)__(.+)$", raw_name)
if not m:
return None
action = m.group(2)
spaced = action.replace("_", " ").lower()
return spaced[0].upper() + spaced[1:] if spaced else ""
def test_parse_mcp_tool_name_get_message_details():
assert parse_mcp_tool_name_display(
"mcp__google-workspace__get_message_details"
) == "Get message details"
def test_parse_mcp_tool_name_send_email():
assert parse_mcp_tool_name_display(
"mcp__google-workspace__send_gmail_message"
) == "Send gmail message"
def test_parse_mcp_tool_name_search_emails():
assert parse_mcp_tool_name_display(
"mcp__google-workspace__query_gmail_emails"
) == "Query gmail emails"
def test_parse_mcp_tool_name_returns_none_for_non_mcp():
assert parse_mcp_tool_name_display("Bash") is None
assert parse_mcp_tool_name_display("Read") is None
def test_parse_mcp_tool_name_no_title_case():
"""Regression test: NEVER capitalize every word."""
bad = parse_mcp_tool_name_display("mcp__notion__create_a_new_page")
assert bad == "Create a new page"
assert "A New Page" not in bad
# ===========================================================================
# Mirror: getResultSummary (glyph-free regression test)
# ===========================================================================
def get_result_summary_bash_success(stdout: str, exit_code: int = 0) -> str:
"""Mirror of getResultSummary for bash success case."""
if exit_code != 0:
return f"exit {exit_code}"
lines = [l for l in stdout.split("\n") if l.strip()]
n = len(lines)
return f"{n} line{'s' if n != 1 else ''}"
def test_bash_success_summary_no_glyph():
"""Regression: bash success used to return '✓ N lines'. Must now be glyph-free."""
assert "" not in get_result_summary_bash_success("hello\nworld")
assert get_result_summary_bash_success("hello\nworld") == "2 lines"
assert get_result_summary_bash_success("just one line") == "1 line"
assert get_result_summary_bash_success("") == "0 lines"
def test_bash_failure_summary_no_glyph():
"""Failure summary too: 'exit 1' not '✗ exit 1'."""
assert get_result_summary_bash_success("", exit_code=1) == "exit 1"
assert "" not in get_result_summary_bash_success("", exit_code=1)
assert "" not in get_result_summary_bash_success("", exit_code=1)
def test_no_check_glyph_in_summaries():
"""Sweep: every plausible summary string never contains a check glyph."""
summaries = [
get_result_summary_bash_success("a"),
get_result_summary_bash_success("a\nb\nc"),
get_result_summary_bash_success("", exit_code=1),
get_result_summary_bash_success("", exit_code=127),
]
for s in summaries:
assert "" not in s and "" not in s and "" not in s and "" not in s
# ===========================================================================
# Mirror: bashCommandDetail extraction
# ===========================================================================
def bash_command_detail(raw_cmd: str) -> str:
"""Mirror of frontend bashCommandDetail."""
if not raw_cmd:
return ""
cmd = raw_cmd.strip()
# strip env var assignments + sudo/time/nice/env
cmd = re.sub(r"^(?:[A-Z_][A-Z0-9_]*=\S+\s+)+", "", cmd)
cmd = re.sub(r"^(?:sudo|time|nice|env)\s+", "", cmd)
tokens = cmd.split()
if not tokens:
return ""
bin_path = tokens[0].split("/")[-1]
if bin_path == "git":
sub = (tokens[1] if len(tokens) > 1 else "").lower()
if sub in ("commit", "status", "log", "diff", "pull", "push", "fetch"):
return ""
return tokens[2].split("/")[-1] if len(tokens) > 2 else ""
if bin_path in ("npm", "pnpm", "yarn", "bun", "pip", "pip3", "brew", "apt", "apt-get"):
if len(tokens) > 2:
args = [t for t in tokens[2:] if not t.startswith("-")][:2]
return " ".join(args)
return ""
# First non-flag positional arg
arg = next((t for t in tokens[1:] if not t.startswith("-")), "")
if not arg:
return ""
if "/" in arg or "\\" in arg:
# basename
cleaned = arg.rstrip("/\\")
parts = cleaned.replace("\\", "/").split("/")
return parts[-1] if parts[-1] else cleaned
return arg if len(arg) <= 50 else arg[:47] + "..."
def test_bash_detail_rm_extracts_path():
assert bash_command_detail("rm /tmp/foo.txt") == "foo.txt"
assert bash_command_detail("rm foo.txt") == "foo.txt"
def test_bash_detail_git_commit_empty():
"""git commit -m 'message' → no detail (verb covers it)."""
assert bash_command_detail("git commit -m 'fix bug'") == ""
assert bash_command_detail("git commit -m hi") == ""
def test_bash_detail_git_status_empty():
assert bash_command_detail("git status") == ""
def test_bash_detail_git_checkout_branch():
assert bash_command_detail("git checkout main") == "main"
def test_bash_detail_npm_install():
assert bash_command_detail("npm install lodash") == "lodash"
assert bash_command_detail("npm install lodash @types/node") == "lodash @types/node"
def test_bash_detail_strips_sudo():
assert bash_command_detail("sudo rm /etc/foo") == "foo"
def test_bash_detail_strips_env_assignments():
assert bash_command_detail("FOO=bar BAZ=qux rm /tmp/a") == "a"
def test_bash_detail_handles_empty():
assert bash_command_detail("") == ""
assert bash_command_detail(" ") == ""
# ===========================================================================
# Mirror: prettyPath (basename a path)
# ===========================================================================
def pretty_path(p: str) -> str:
if not p:
return ""
cleaned = p.rstrip("/\\")
parts = cleaned.replace("\\", "/").split("/")
return parts[-1] if parts[-1] else cleaned
def test_pretty_path_absolute():
assert pretty_path("/Users/eric/Downloads/openswarm/foo.ts") == "foo.ts"
def test_pretty_path_relative():
assert pretty_path("a/b/c.tsx") == "c.tsx"
def test_pretty_path_trailing_slash():
assert pretty_path("/a/b/c/") == "c"
def test_pretty_path_empty():
assert pretty_path("") == ""
# ===========================================================================
# Mirror: prettyUrl (host-only)
# ===========================================================================
def pretty_url(u: str) -> str:
if not u:
return ""
try:
from urllib.parse import urlparse
host = urlparse(u).hostname or ""
return host[4:] if host.startswith("www.") else host or u[:60]
except Exception:
no_proto = re.sub(r"^https?://", "", u).split("/")[0].split("?")[0].split("#")[0]
return no_proto[:60]
def test_pretty_url_https():
assert pretty_url("https://example.com/long/path?q=1") == "example.com"
def test_pretty_url_strips_www():
assert pretty_url("https://www.example.com/path") == "example.com"
def test_pretty_url_subdomain_kept():
assert pretty_url("https://api.example.com/v1") == "api.example.com"
def test_pretty_url_empty():
assert pretty_url("") == ""
# ===========================================================================
# Mirror: quoteQuery
# ===========================================================================
def quote_query(q: str, max_len: int = 60) -> str:
if not q:
return ""
trimmed = q if len(q) <= max_len else q[:max_len - 1] + ""
return f'"{trimmed}"'
def test_quote_query_short():
assert quote_query("TODO") == '"TODO"'
def test_quote_query_long_truncated():
long = "a" * 100
result = quote_query(long)
assert result.startswith('"')
assert result.endswith('"')
assert len(result) <= 62 # 60 chars + 2 quotes
def test_quote_query_empty():
assert quote_query("") == ""
# ===========================================================================
# Mirror: stable-seeded variant pick (djb2 hash → mod n)
# ===========================================================================
def stable_index(seed: str | None, n: int) -> int:
"""Mirror of frontend _stableIndex."""
if n <= 1 or not seed:
return 0
h = 5381
for ch in seed:
h = ((h << 5) + h + ord(ch)) & 0xFFFFFFFF # 32-bit
# JS does `| 0` which produces signed int; Math.abs handles that
if h >= 0x80000000:
h -= 0x100000000
return abs(h) % n
def test_stable_index_same_seed_same_result():
"""Critical: same call.id always → same variant index."""
n = 5
for seed in ("abc-123", "xyz-789", "tool-call-uuid-deadbeef"):
a = stable_index(seed, n)
b = stable_index(seed, n)
c = stable_index(seed, n)
assert a == b == c, f"unstable for seed={seed!r}"
def test_stable_index_different_seeds_diverge():
"""Different seeds usually give different results (probabilistic)."""
n = 7
seeds = [f"seed-{i}-{random.randint(0, 99999)}" for i in range(50)]
indices = [stable_index(s, n) for s in seeds]
# All same is statistically extremely unlikely
assert len(set(indices)) > 1
def test_stable_index_in_range():
"""Index always in [0, n-1]."""
for _ in range(200):
seed = "".join(random.choices(string.ascii_letters + string.digits, k=20))
n = random.randint(2, 20)
idx = stable_index(seed, n)
assert 0 <= idx < n, f"out of range: {idx} for n={n}"
def test_stable_index_empty_seed_zero():
"""No seed → safe-default (index 0)."""
assert stable_index(None, 5) == 0
assert stable_index("", 5) == 0
def test_stable_index_n_one():
"""Single-variant pool → always index 0."""
assert stable_index("anything", 1) == 0
# ===========================================================================
# Mirror: bash verb extraction (the leading-binary lookup)
# ===========================================================================
BIN_VERB_MAP = {
"rm": ("Deleting", "Deleted"),
"mv": ("Moving", "Moved"),
"cp": ("Copying", "Copied"),
"mkdir": ("Creating folder", "Created folder"),
"ls": ("Listing folder", "Listed folder"),
"find": ("Hunting for files", "Hunted for files"),
"grep": ("Searching files", "Searched files"),
"cat": ("Reading", "Read"),
"echo": ("Printing", "Printed"),
"make": ("Building", "Built"),
}
GIT_VERB_MAP = {
"commit": ("Committing", "Committed"),
"push": ("Pushing to git", "Pushed to git"),
"pull": ("Pulling from git", "Pulled from git"),
"checkout": ("Switching branches", "Switched branches"),
"merge": ("Merging", "Merged"),
}
PKG_VERB_MAP_INSTALL = ("Installing packages", "Installed packages")
PKG_VERB_MAP_UNINSTALL = ("Removing packages", "Removed packages")
def bash_verb(cmd: str, past: bool = False):
if not cmd:
return None
stripped = re.sub(r"^(?:[A-Z_][A-Z0-9_]*=\S+\s+)+", "", cmd.strip())
stripped = re.sub(r"^(?:sudo|time|nice|env)\s+", "", stripped)
tokens = stripped.split()
if not tokens:
return None
bin_path = tokens[0].split("/")[-1].lower()
sub = (tokens[1] if len(tokens) > 1 else "").lower()
if bin_path == "git" and sub in GIT_VERB_MAP:
return GIT_VERB_MAP[sub][1 if past else 0]
if bin_path in ("npm", "pnpm", "yarn", "pip", "pip3", "brew"):
if sub in ("install", "add", "i"):
return PKG_VERB_MAP_INSTALL[1 if past else 0]
if sub in ("uninstall", "remove", "rm"):
return PKG_VERB_MAP_UNINSTALL[1 if past else 0]
if bin_path in BIN_VERB_MAP:
return BIN_VERB_MAP[bin_path][1 if past else 0]
return None
def test_bash_verb_rm_deleted():
assert bash_verb("rm foo", past=True) == "Deleted"
assert bash_verb("rm foo", past=False) == "Deleting"
def test_bash_verb_git_commit():
assert bash_verb("git commit -m hi", past=True) == "Committed"
def test_bash_verb_git_push():
assert bash_verb("git push origin main", past=True) == "Pushed to git"
def test_bash_verb_npm_install():
assert bash_verb("npm install lodash", past=True) == "Installed packages"
def test_bash_verb_unknown_returns_none():
"""Truly unknown command falls through to default 'Ran command'."""
assert bash_verb("supercustomtool foo bar") is None
def test_bash_verb_strips_sudo():
assert bash_verb("sudo rm -rf /tmp/x", past=True) == "Deleted"
def test_bash_verb_strips_env():
assert bash_verb("DEBUG=1 npm test", past=False) is None # 'test' isn't in pkg map for bash_verb
# string is needed for stable_index test
import string # noqa: E402
+36 -2
View File
@@ -438,12 +438,35 @@ async function startBackend() {
const shellPath = getShellPath();
// Identifies how this build was packaged. Read by the backend service
// client so the cloud can split installer-using customers from
// run-from-source developers in dashboards. Honors a build-time override
// (set in CI when producing platform installers) before falling back to
// OS-derived defaults.
let installMethod = process.env.OPENSWARM_INSTALL_METHOD;
if (!installMethod) {
if (!isPackaged) {
installMethod = 'dev';
} else if (process.platform === 'darwin') {
installMethod = 'dmg';
} else if (process.platform === 'win32') {
installMethod = 'windows-setup';
} else if (process.platform === 'linux') {
// electron-builder produces AppImage by default for linux targets.
// Override at packaging time when building .deb / .rpm.
installMethod = 'appimage';
} else {
installMethod = 'unknown';
}
}
const env = {
...process.env,
PATH: shellPath,
OPENSWARM_PACKAGED: isPackaged ? '1' : '0',
OPENSWARM_PORT: String(backendPort),
OPENSWARM_ELECTRON_PATH: process.execPath,
OPENSWARM_INSTALL_METHOD: installMethod,
PYTHONDONTWRITEBYTECODE: '1',
// PEP 540 UTF-8 mode: makes open() default to UTF-8 on Windows where
// the locale is otherwise cp1252. Many backend modules read UTF-8
@@ -653,8 +676,11 @@ function sendToRenderer(channel, ...args) {
function setupAutoUpdater() {
if (!autoUpdater) return;
autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = false;
// Silent background updates: download on detect, install on next quit.
// The OS gates the install on main-process exit (can't replace a
// running .app / locked .exe), so an active session is never disrupted.
autoUpdater.autoDownload = true;
autoUpdater.autoInstallOnAppQuit = true;
autoUpdater.on('update-available', (info) => {
console.log(`Update available: ${info.version}`);
@@ -688,6 +714,14 @@ function setupAutoUpdater() {
autoUpdater.checkForUpdates().catch((err) => {
console.log('Update check skipped:', err.message);
});
// Always-on users (lid never closes) miss the once-at-startup check
// above. Re-check every 4h; coalesces if a download is already cached.
setInterval(() => {
autoUpdater.checkForUpdates().catch((err) => {
console.log('Periodic update check failed:', err.message);
});
}, 4 * 60 * 60 * 1000);
}
function killBackend() {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "openswarm",
"version": "1.0.27",
"version": "1.0.28",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openswarm",
"version": "1.0.27",
"version": "1.0.28",
"hasInstallScript": true,
"dependencies": {
"electron-updater": "^6.3.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "openswarm",
"version": "1.0.27",
"version": "1.0.28",
"description": "OpenSwarm — AI Agent Orchestrator",
"main": "main.js",
"scripts": {
+5
View File
@@ -6,6 +6,11 @@
<title>Open Swarm</title>
<link rel="icon" href="./favicon.ico?v=2" sizes="16x16 32x32 48x48" />
<link rel="apple-touch-icon" href="./apple-touch-icon.png" />
<!-- Warm sockets to the few external endpoints we hit on first paint. -->
<link rel="preconnect" href="https://api.openswarm.com" crossorigin />
<link rel="dns-prefetch" href="https://api.openswarm.com" />
<link rel="dns-prefetch" href="https://api.github.com" />
<link rel="dns-prefetch" href="https://fonts.googleapis.com" />
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
</head>
<body>
+57 -34
View File
@@ -1,4 +1,4 @@
import React, { useMemo, useEffect, useState, useRef } from 'react';
import React, { useMemo, useEffect, useState, useRef, Suspense, lazy } from 'react';
import { Provider } from 'react-redux';
import { HashRouter, Routes, Route } from 'react-router-dom';
import { ThemeProvider as MuiThemeProvider, createTheme, CssBaseline } from '@mui/material';
@@ -19,16 +19,20 @@ import {
} from '@/shared/state/updateSlice';
import AppShell from './components/Layout/AppShell';
import DashboardSelection from './pages/DashboardSelection/DashboardSelection';
import Skills from './pages/Skills/Skills';
import Tools from './pages/Tools/Tools';
import Modes from './pages/Modes/Modes';
import Views from './pages/Views/Views';
import Customization from './pages/Customization/Customization';
import Analytics from './pages/Analytics/Analytics';
import OnboardingModal from './components/OnboardingModal';
import { trackEvent, getLastAction, getLastPage, getTimeSpent } from '@/shared/analytics';
import ErrorBoundary from './components/ErrorBoundary';
// Lazy: heavy pages that aren't on the first-paint path.
const Skills = lazy(() => import('./pages/Skills/Skills'));
const Tools = lazy(() => import('./pages/Tools/Tools'));
const Modes = lazy(() => import('./pages/Modes/Modes'));
const Views = lazy(() => import('./pages/Views/Views'));
const Customization = lazy(() => import('./pages/Customization/Customization'));
const Analytics = lazy(() => import('./pages/Analytics/Analytics'));
const OnboardingModal = lazy(() => import('./components/OnboardingModal'));
import { report, getSessionTraceState, getRecentActions } from '@/shared/serviceClient';
import { useRouteTracker } from '@/shared/hooks/useRouteTracker';
import { useKeyboardShortcuts } from '@/shared/hooks/useKeyboardShortcuts';
import { useDeepLink } from '@/shared/hooks/useDeepLink';
import { useInteractionHeartbeat } from '@/shared/hooks/useInteractionHeartbeat';
import KeyboardShortcutsHelp from './components/KeyboardShortcutsHelp';
import { ThemeProvider, useThemeMode, useClaudeTokens } from '@/shared/styles/ThemeContext';
import { ClaudeTokens } from '@/shared/styles/claudeTokens';
@@ -161,6 +165,10 @@ const ShortcutsProvider: React.FC<{ children: React.ReactNode }> = ({ children }
const DeepLinkListener: React.FC<{ children: React.ReactNode }> = ({ children }) => {
useDeepLink();
// Single global interaction-timestamp recorder. Powers idle-dim and
// similar UX, and gives the session-close dump a real "last user
// interaction" timestamp.
useInteractionHeartbeat();
return <>{children}</>;
};
@@ -331,20 +339,21 @@ const ThemedApp: React.FC = () => {
const { mode } = useThemeMode();
const muiTheme = useMemo(() => buildMuiTheme(c, mode), [c, mode]);
// Track last action before user leaves and uncaught errors
useEffect(() => {
const handleUnload = () => {
trackEvent('app.last_action', {
last_page: getLastPage(),
last_action: getLastAction(),
time_spent_seconds: getTimeSpent(),
}, true); // useBeacon for reliable delivery during unload
const { appStartTs, currentPage } = getSessionTraceState();
report('app', 'last_action', {
last_page: currentPage,
time_spent_seconds: Math.round((Date.now() - appStartTs) / 1000),
}, { immediate: true });
};
const handleError = (event: ErrorEvent) => {
trackEvent('app.error', {
const { currentPage } = getSessionTraceState();
report('app', 'error', {
error_message: event.message,
error_stack: event.error?.stack?.slice(0, 500),
last_page: getLastPage(),
last_page: currentPage,
recent_actions: getRecentActions(10),
});
};
window.addEventListener('beforeunload', handleUnload);
@@ -359,28 +368,35 @@ const ThemedApp: React.FC = () => {
<MuiThemeProvider theme={muiTheme}>
<CssBaseline />
<HashRouter>
<RouteTrackerMount />
<ShortcutsProvider>
<SettingsLoader>
<DefaultModelGuard>
<UpdateListener>
<DeepLinkListener>
<Routes>
<Route element={<AppShell />}>
<Route path="/" element={<DashboardSelection />} />
{/* Dashboard route is a no-op stub — the actual <Dashboard /> is rendered
persistently inside AppShell so its webviews survive navigation between
routes. This route exists only so React Router matches the URL. */}
<Route path="/dashboard/:id" element={null} />
<Route path="/customization" element={<Customization />} />
<Route path="/skills" element={<Skills />} />
<Route path="/actions" element={<Tools />} />
<Route path="/modes" element={<Modes />} />
<Route path="/apps" element={<Views />} />
<Route path="/apps/:id" element={<Views />} />
<Route path="/analytics" element={<Analytics />} />
</Route>
</Routes>
<OnboardingModal />
<ErrorBoundary scope="routes">
<Suspense fallback={null}>
<Routes>
<Route element={<AppShell />}>
<Route path="/" element={<DashboardSelection />} />
{/* Dashboard route is a no-op stub — the actual <Dashboard /> is rendered
persistently inside AppShell so its webviews survive navigation between
routes. This route exists only so React Router matches the URL. */}
<Route path="/dashboard/:id" element={null} />
<Route path="/customization" element={<Customization />} />
<Route path="/skills" element={<Skills />} />
<Route path="/actions" element={<Tools />} />
<Route path="/modes" element={<Modes />} />
<Route path="/apps" element={<Views />} />
<Route path="/apps/:id" element={<Views />} />
<Route path="/analytics" element={<Analytics />} />
</Route>
</Routes>
</Suspense>
</ErrorBoundary>
<Suspense fallback={null}>
<OnboardingModal />
</Suspense>
</DeepLinkListener>
</UpdateListener>
</DefaultModelGuard>
@@ -391,6 +407,13 @@ const ThemedApp: React.FC = () => {
);
};
// Tiny mount-point so the route-tracker hook can use useLocation() (which
// requires a Router ancestor). Lives inside HashRouter, runs once.
const RouteTrackerMount: React.FC = () => {
useRouteTracker();
return null;
};
const Main: React.FC = () => {
return (
<Provider store={store}>
+115
View File
@@ -0,0 +1,115 @@
import React, { useEffect, useRef, useState } from 'react';
import Box from '@mui/material/Box';
import { DURATION_MS, EASE } from '@/shared/styles/motionTokens';
import { useReducedMotion } from '@/shared/hooks/useReducedMotion';
/**
* Smooth visual transitions for status pills + counters that currently snap.
*
* <CrossFadeOnChange value={x}>{(v) => <span>{v}</span>}</CrossFadeOnChange>
* Old value fades to 30% while new value fades in. Cancels on rapid changes.
*
* <TweeningNumber value={1234} format={(n) => `$${n.toFixed(4)}`} />
* RAF-tweens from previous to new value. Caps duration on big jumps.
*/
interface CrossFadeProps<T> {
value: T;
children: (currentValue: T) => React.ReactNode;
/** Defaults to DURATION_MS.quick (140ms). */
durationMs?: number;
}
export function CrossFadeOnChange<T>({ value, children, durationMs }: CrossFadeProps<T>) {
const reduced = useReducedMotion();
const dur = reduced ? 0 : (durationMs ?? DURATION_MS.quick);
const [displayed, setDisplayed] = useState(value);
const [opacity, setOpacity] = useState(1);
useEffect(() => {
if (Object.is(displayed, value)) return;
if (dur === 0) {
setDisplayed(value);
return;
}
// Fade old to ~0, then swap and fade new in.
setOpacity(0);
const t = setTimeout(() => {
setDisplayed(value);
setOpacity(1);
}, dur / 2);
return () => clearTimeout(t);
}, [value, dur, displayed]);
return (
<Box
component="span"
sx={{
display: 'inline-block',
opacity,
transition: `opacity ${dur / 2}ms ${EASE.out}`,
}}
>
{children(displayed)}
</Box>
);
}
interface TweeningNumberProps {
value: number;
/** How to render the tweened number. Default: `n.toString()`. */
format?: (n: number) => string;
/** Cap on tween duration regardless of delta. Default 500ms. */
maxDurationMs?: number;
}
export const TweeningNumber: React.FC<TweeningNumberProps> = ({
value,
format = (n) => String(Math.round(n)),
maxDurationMs = 500,
}) => {
const reduced = useReducedMotion();
const [displayed, setDisplayed] = useState(value);
const startedAtRef = useRef<number | null>(null);
const fromRef = useRef<number>(value);
const toRef = useRef<number>(value);
const rafRef = useRef<number | null>(null);
useEffect(() => {
if (reduced) {
setDisplayed(value);
return;
}
if (Object.is(toRef.current, value)) return;
fromRef.current = displayed;
toRef.current = value;
startedAtRef.current = performance.now();
// Duration scales with delta but caps. ~1ms per unit, capped.
const delta = Math.abs(value - fromRef.current);
const dur = Math.min(maxDurationMs, Math.max(120, delta * 1.2));
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
const step = (now: number) => {
const t = Math.min(1, (now - (startedAtRef.current as number)) / dur);
// ease-out cubic
const eased = 1 - Math.pow(1 - t, 3);
const current = fromRef.current + (toRef.current - fromRef.current) * eased;
setDisplayed(current);
if (t < 1) {
rafRef.current = requestAnimationFrame(step);
} else {
rafRef.current = null;
}
};
rafRef.current = requestAnimationFrame(step);
return () => {
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
};
}, [value, reduced, maxDurationMs]); // eslint-disable-line react-hooks/exhaustive-deps
return <>{format(displayed)}</>;
};
@@ -0,0 +1,144 @@
import React from 'react';
import { report, getRecentActions } from '@/shared/serviceClient';
interface Props {
/** Friendly title for the fallback card. Default: "Something broke." */
title?: string;
/** Optional reset hook — if provided, the Reload button calls this instead of reloading the window. */
onReset?: () => void;
/** Where the boundary lives, for support ("root" | "page:tools" | etc.). */
scope?: string;
children: React.ReactNode;
}
interface State {
error: Error | null;
}
/**
* Catches uncaught render errors so a single broken component doesn't
* black out the whole app. Stack stays visible so users can copy/paste
* it to support; the cloud gets a fire-and-forget operational report.
*/
class ErrorBoundary extends React.Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
try {
report('app', 'error_boundary', {
scope: this.props.scope || 'unknown',
message: String(error?.message || error).slice(0, 500),
stack: String(error?.stack || '').slice(0, 2000),
component_stack: String(info?.componentStack || '').slice(0, 2000),
// Last 10 user-surface actions before the boundary tripped, so the
// backend can correlate the crash with what the user just did.
recent_actions: getRecentActions(10),
});
} catch {}
// surface in dev so developers can read the stack
if (typeof console !== 'undefined' && console.error) {
console.error('[ErrorBoundary]', error, info);
}
}
handleReload = () => {
if (this.props.onReset) {
this.props.onReset();
this.setState({ error: null });
return;
}
try { window.location.reload(); } catch {}
};
handleResetState = () => {
// best-effort: clear any localStorage we own + reload
try {
const keys = Object.keys(localStorage);
for (const k of keys) {
if (k.startsWith('openswarm:') || k.startsWith('redux-')) {
localStorage.removeItem(k);
}
}
} catch {}
try { window.location.reload(); } catch {}
};
render() {
const { error } = this.state;
if (!error) return this.props.children;
const title = this.props.title || 'Something broke.';
const wrap: React.CSSProperties = {
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 32,
fontFamily: '-apple-system, BlinkMacSystemFont, sans-serif',
background: '#0e0f12',
color: '#dad8d2',
};
const card: React.CSSProperties = {
maxWidth: 640,
background: '#16181d',
border: '1px solid rgba(255,255,255,0.08)',
borderRadius: 12,
padding: 24,
boxShadow: '0 8px 32px rgba(0,0,0,0.4)',
};
const btn: React.CSSProperties = {
background: '#c4633a',
color: 'white',
border: 'none',
borderRadius: 6,
padding: '8px 14px',
fontSize: 13,
fontWeight: 600,
cursor: 'pointer',
marginRight: 8,
};
const btnSecondary: React.CSSProperties = {
...btn,
background: 'transparent',
border: '1px solid rgba(255,255,255,0.15)',
color: '#dad8d2',
};
const stack: React.CSSProperties = {
marginTop: 16,
fontFamily: 'ui-monospace, SFMono-Regular, monospace',
fontSize: 11,
lineHeight: 1.5,
background: '#0a0b0d',
padding: 12,
borderRadius: 6,
maxHeight: 200,
overflow: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
color: '#9c9a92',
};
return (
<div style={wrap} role="alert" aria-live="assertive">
<div style={card}>
<h2 style={{ margin: '0 0 8px', fontSize: 18, fontWeight: 600 }}>{title}</h2>
<p style={{ margin: '0 0 16px', color: '#9c9a92', fontSize: 14, lineHeight: 1.5 }}>
We caught it before it crashed everything. The error is below copy it
if you want to share. Reload usually fixes it.
</p>
<div>
<button type="button" style={btn} onClick={this.handleReload}>Reload</button>
<button type="button" style={btnSecondary} onClick={this.handleResetState}>Reset & reload</button>
</div>
<pre style={stack}>{String(error?.stack || error?.message || error)}</pre>
</div>
</div>
);
}
}
export default ErrorBoundary;
@@ -30,7 +30,9 @@ import SystemUpdateAltIcon from '@mui/icons-material/SystemUpdateAlt';
import CloseIcon from '@mui/icons-material/Close';
import LinearProgress from '@mui/material/LinearProgress';
import CircularProgress from '@mui/material/CircularProgress';
import Settings from '@/app/pages/Settings/Settings';
// Settings is a global modal — lazy-load so its 2.3K LOC + Stripe / OAuth helpers
// don't ship on first paint. Prefetched on idle so click-to-open feels instant.
const Settings = React.lazy(() => import('@/app/pages/Settings/Settings'));
import DynamicIsland from '@/app/components/DynamicIsland';
import Dashboard from '@/app/pages/Dashboard/Dashboard';
import DashboardHost from '@/app/components/Layout/DashboardHost';
@@ -152,8 +154,12 @@ const AppShell: React.FC = () => {
);
const outputItems = useAppSelector((state) => state.outputs.items);
const appsList = Object.values(outputItems).sort(
(a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(),
// memo so the sort doesn't re-run on every AppShell re-render.
const appsList = React.useMemo(
() => Object.values(outputItems).sort(
(a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(),
),
[outputItems],
);
useEffect(() => {
@@ -161,6 +167,20 @@ const AppShell: React.FC = () => {
dispatch(fetchOutputs());
}, [dispatch]);
// Idle-prefetch the lazy Settings chunk so click-to-open is instant.
// requestIdleCallback waits until the browser is genuinely idle so we
// don't fight first-paint work for the network slot.
useEffect(() => {
const ric = (window as any).requestIdleCallback || ((cb: () => void) => setTimeout(cb, 1500));
const handle = ric(() => {
import('@/app/pages/Settings/Settings').catch(() => {});
}, { timeout: 3000 });
return () => {
const cic = (window as any).cancelIdleCallback || clearTimeout;
try { cic(handle); } catch {}
};
}, []);
const openUrlInBrowser = useCallback((url: string, webContentsId?: number) => {
const dashMatch = location.pathname.match(/^\/dashboard\/(.+)/);
if (dashMatch) {
@@ -314,8 +334,9 @@ const AppShell: React.FC = () => {
const handleDashboardRenameSubmit = (id: string) => {
const trimmed = renameValue.trim();
if (trimmed && trimmed !== dashboardItems[id]?.name) {
dispatch(renameDashboard({ id, name: trimmed }));
const previousName = dashboardItems[id]?.name;
if (trimmed && trimmed !== previousName) {
dispatch(renameDashboard({ id, name: trimmed, previousName }));
}
setRenamingDashboardId(null);
};
@@ -503,7 +524,7 @@ const AppShell: React.FC = () => {
<Typography sx={{ fontSize: '0.8rem', color: c.text.secondary, flex: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{updateStatus === 'available' && `OpenSwarm ${availableVersion} is available`}
{updateStatus === 'downloading' && `Downloading OpenSwarm ${availableVersion}`}
{updateStatus === 'downloaded' && `OpenSwarm ${availableVersion} is ready to install`}
{updateStatus === 'downloaded' && `OpenSwarm ${availableVersion} will install when you quit`}
</Typography>
{updateStatus === 'downloading' && (
<LinearProgress
@@ -1057,7 +1078,9 @@ const AppShell: React.FC = () => {
</Box>
</Box>
<Settings />
<React.Suspense fallback={null}>
<Settings />
</React.Suspense>
<Snackbar
open={showUpdateSnackbar}
+132
View File
@@ -0,0 +1,132 @@
import React, { useEffect, useState } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import CircularProgress from '@mui/material/CircularProgress';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { DURATION_MS, EASE, pulseKeyframes } from '@/shared/styles/motionTokens';
import { useReducedMotion } from '@/shared/hooks/useReducedMotion';
/**
* Unified loading primitives. Three components, one aesthetic.
*
* <Skeleton variant="card|line|circle" width height />
* For full-component / full-page loads. Replaces decorative spinners.
*
* <InlineSpinner size />
* For inline button states + OAuth waits. Spinner = "I'm doing it now".
*
* <EmptyState icon title hint />
* For "nothing here yet" empty lists. Replaces ad-hoc "Loading..." text.
*
* `delayMs` (Skeleton + EmptyState): don't show until N ms have elapsed.
* Prevents the flash-of-skeleton on fast loads (<100ms common case).
*/
interface SkeletonProps {
variant?: 'card' | 'line' | 'circle' | 'custom';
width?: number | string;
height?: number | string;
/** Default 100ms; pass 0 to render immediately */
delayMs?: number;
}
export const Skeleton: React.FC<SkeletonProps> = ({
variant = 'line',
width,
height,
delayMs = 100,
}) => {
const c = useClaudeTokens();
const reduced = useReducedMotion();
const [show, setShow] = useState(delayMs === 0);
useEffect(() => {
if (delayMs === 0) return;
const t = setTimeout(() => setShow(true), delayMs);
return () => clearTimeout(t);
}, [delayMs]);
if (!show) return null;
const dimensions: React.CSSProperties = {
width: width ?? (variant === 'card' ? '100%' : variant === 'circle' ? 24 : '60%'),
height: height ?? (variant === 'card' ? 80 : variant === 'circle' ? 24 : 12),
};
const radius = variant === 'circle'
? '50%'
: variant === 'card'
? 8
: 4;
return (
<Box
sx={{
...dimensions,
borderRadius: `${typeof radius === 'number' ? `${radius}px` : radius}`,
bgcolor: c.border.subtle,
opacity: 0.5,
animation: reduced ? 'none' : `openswarmPulse ${DURATION_MS.ambient}ms ${EASE.pulse} infinite`,
...pulseKeyframes,
}}
/>
);
};
interface InlineSpinnerProps {
/** 14 / 16 / 18; defaults to 16 */
size?: 14 | 16 | 18 | 20;
color?: string;
}
export const InlineSpinner: React.FC<InlineSpinnerProps> = ({ size = 16, color }) => {
const c = useClaudeTokens();
return <CircularProgress size={size} sx={{ color: color ?? c.text.tertiary }} />;
};
interface EmptyStateProps {
icon?: React.ReactNode;
title: string;
hint?: string;
/** Show after N ms — keeps "Loading..." flash off fast paths */
delayMs?: number;
}
export const EmptyState: React.FC<EmptyStateProps> = ({ icon, title, hint, delayMs = 100 }) => {
const c = useClaudeTokens();
const [show, setShow] = useState(delayMs === 0);
useEffect(() => {
if (delayMs === 0) return;
const t = setTimeout(() => setShow(true), delayMs);
return () => clearTimeout(t);
}, [delayMs]);
if (!show) return null;
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 1.5,
py: 6,
px: 3,
color: c.text.tertiary,
textAlign: 'center',
}}
>
{icon && <Box sx={{ opacity: 0.5, fontSize: 32 }}>{icon}</Box>}
<Typography sx={{ fontSize: '0.9rem', fontWeight: 500, color: c.text.muted }}>
{title}
</Typography>
{hint && (
<Typography sx={{ fontSize: '0.75rem', color: c.text.ghost, maxWidth: 320 }}>
{hint}
</Typography>
)}
</Box>
);
};
+37 -18
View File
@@ -5,7 +5,7 @@ import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import { useAppSelector } from '@/shared/hooks';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { API_BASE } from '@/shared/config';
import { trackEvent } from '@/shared/analytics';
import { report as _report } from '@/shared/serviceClient';
import PlanPicker from '@/app/components/PlanPicker';
// Email validation: format check + typo correction for common domains.
@@ -13,6 +13,25 @@ import PlanPicker from '@/app/components/PlanPicker';
// CRM system handles the confirm-subscription flow).
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
// Onboarding-step timing.
//
// We record `ms_since_start` on every onboarding/walkthrough report so the
// cloud can derive per-step duration without firing per-step events. Stamp
// is set on the first call (effectively when `onboarding.started` fires)
// and persists for the lifetime of the modal — abandoned modals reset on
// next open. This rides the existing report() surface; no new outbound
// paths added.
let _onboardingStartTs: number | null = null;
function report(surface: string, action: string, props?: Record<string, unknown>): void {
if (_onboardingStartTs === null) _onboardingStartTs = Date.now();
const enriched: Record<string, unknown> = { ...(props ?? {}) };
enriched["ms_since_start"] = Date.now() - _onboardingStartTs;
_report(surface, action, enriched);
if (action === "completed" || action === "profile_skipped" || action === "connect_skipped") {
_onboardingStartTs = null;
}
}
const COMMON_DOMAIN_TYPOS: Record<string, string> = {
'gmial.com': 'gmail.com',
'gmai.com': 'gmail.com',
@@ -204,7 +223,7 @@ const OnboardingModal: React.FC = () => {
if (nineRouterReady === null) return; // still checking
setOpen(true);
trackEvent('onboarding.started', { step: 'profile' });
report('onboarding', 'started', { step: 'profile' });
}, [nineRouterReady]);
// Cleanup timers on unmount
@@ -231,7 +250,7 @@ const OnboardingModal: React.FC = () => {
return;
}
if (!initialProActiveRef.current && isActive) {
trackEvent('onboarding.openswarm_pro_activated');
report('onboarding', 'openswarm_pro_activated');
dismiss();
}
// dismiss is stable enough — don't include in deps
@@ -256,7 +275,7 @@ const OnboardingModal: React.FC = () => {
if (dashboard?.id) {
const seedRes = await fetch(`${API_BASE}/dashboards/${dashboard.id}/seed-demo`, { method: 'POST' });
if (seedRes.ok) {
trackEvent('onboarding.completed', { dashboard_id: dashboard.id });
report('onboarding', 'completed', { dashboard_id: dashboard.id });
localStorage.setItem('openswarm_walkthrough_pending', 'true');
setOpen(false);
// Force full page load to ensure dashboard mounts fresh with walkthrough
@@ -298,7 +317,7 @@ const OnboardingModal: React.FC = () => {
}),
});
} catch {}
trackEvent('onboarding.profile_submitted', {
report('onboarding', 'profile_submitted', {
has_name: !!userName.trim(),
has_email: !!userEmail.trim(),
use_cases: useCases,
@@ -309,7 +328,7 @@ const OnboardingModal: React.FC = () => {
});
setStep('walkthrough');
setWalkthroughIdx(0);
trackEvent('onboarding.education_started');
report('onboarding', 'education_started');
};
// 500ms debounce on Next/Back during the video walkthrough. The video
@@ -326,12 +345,12 @@ const OnboardingModal: React.FC = () => {
const next = walkthroughIdx + 1;
const currentTitle = EDUCATION_STEPS[walkthroughIdx]?.title;
if (next >= EDUCATION_STEPS.length) {
trackEvent('onboarding.education_completed');
report('onboarding', 'education_completed');
setStep('connect');
trackEvent('onboarding.connect_started', { nine_router_ready: nineRouterReady });
report('onboarding', 'connect_started', { nine_router_ready: nineRouterReady });
return;
}
trackEvent('onboarding.education_step_advanced', { from: walkthroughIdx, title: currentTitle });
report('onboarding', 'education_step_advanced', { from: walkthroughIdx, title: currentTitle });
setWalkthroughIdx(next);
};
@@ -361,7 +380,7 @@ const OnboardingModal: React.FC = () => {
// Invalid format with non-empty value — refuse and force error state.
if (trimmed && !isValidEmail(trimmed)) {
setEmailBlurred(true);
trackEvent('onboarding.email_invalid_blocked', { value_length: trimmed.length });
report('onboarding', 'email_invalid_blocked', { value_length: trimmed.length });
return;
}
if (!isProfileComplete) return;
@@ -370,7 +389,7 @@ const OnboardingModal: React.FC = () => {
const handleApplySuggestion = (suggested: string) => {
setUserEmail(suggested);
trackEvent('onboarding.email_suggestion_applied');
report('onboarding', 'email_suggestion_applied');
};
// Mirrors Settings/SubscriptionCards `handleConnect` so the Gemini
@@ -388,7 +407,7 @@ const OnboardingModal: React.FC = () => {
if (pollTimerRef.current) { clearInterval(pollTimerRef.current); pollTimerRef.current = null; }
if (msgHandlerRef.current) { window.removeEventListener('message', msgHandlerRef.current); msgHandlerRef.current = null; }
setConnecting(providerId);
trackEvent('onboarding.provider_selected', { provider: providerId });
report('onboarding', 'provider_selected', { provider: providerId });
// OpenSwarm Pro: switch to the dedicated pricing step so the user can
// pick a tier + billing interval before heading to Stripe. The
@@ -428,7 +447,7 @@ const OnboardingModal: React.FC = () => {
clearInterval(devicePollTimer);
clearInterval(statusPollTimer);
pollTimerRef.current = null;
trackEvent('onboarding.provider_connected', { provider: providerId });
report('onboarding', 'provider_connected', { provider: providerId });
// Auto-close the popup 2s after success so the user briefly
// sees the "Connected!" page then it goes away on its own.
setTimeout(() => {
@@ -523,7 +542,7 @@ const OnboardingModal: React.FC = () => {
body: JSON.stringify({ provider: providerId, code, redirect_uri: data.redirect_uri, code_verifier: data.code_verifier, state: state || data.state }),
});
} catch {}
trackEvent('onboarding.provider_connected', { provider: providerId });
report('onboarding', 'provider_connected', { provider: providerId });
dismiss();
};
@@ -542,7 +561,7 @@ const OnboardingModal: React.FC = () => {
if (ipcUnsub) ipcUnsub();
clearInterval(statusPoller);
pollTimerRef.current = null;
trackEvent('onboarding.provider_connected', { provider: providerId });
report('onboarding', 'provider_connected', { provider: providerId });
dismiss();
}
}
@@ -597,9 +616,9 @@ const OnboardingModal: React.FC = () => {
} catch { setConnecting(null); }
};
const handleApiKey = () => { trackEvent('onboarding.api_key_chosen'); dismiss(); };
const handleApiKey = () => { report('onboarding', 'api_key_chosen'); dismiss(); };
const handleSkip = () => {
trackEvent(step === 'profile' ? 'onboarding.profile_skipped' : 'onboarding.connect_skipped');
report('onboarding', step === 'profile' ? 'profile_skipped' : 'connect_skipped');
dismiss();
};
@@ -941,7 +960,7 @@ const OnboardingModal: React.FC = () => {
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mt: 2 }}>
<Button
onClick={() => { setStep('connect'); trackEvent('onboarding.pricing_back'); }}
onClick={() => { setStep('connect'); report('onboarding', 'pricing_back'); }}
startIcon={<ArrowBackIcon sx={{ fontSize: 14 }} />}
sx={{
textTransform: 'none', fontSize: '0.85rem', fontWeight: 500,
@@ -3,7 +3,22 @@ import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { trackEvent } from '@/shared/analytics';
import { report as _report } from '@/shared/serviceClient';
// Same per-step timing wrapper as OnboardingModal — every walkthrough
// report carries `ms_since_start` so the cloud can compute per-step
// dwell time inside the existing aggregation. Reuses the existing
// report() surface; no new outbound paths.
let _walkthroughStartTs: number | null = null;
function report(surface: string, action: string, props?: Record<string, unknown>): void {
if (_walkthroughStartTs === null) _walkthroughStartTs = Date.now();
const enriched: Record<string, unknown> = { ...(props ?? {}) };
enriched["ms_since_start"] = Date.now() - _walkthroughStartTs;
_report(surface, action, enriched);
if (action === "completed") {
_walkthroughStartTs = null;
}
}
export interface WalkthroughStep {
target: string; // data-onboarding="<value>" selector
@@ -93,13 +108,13 @@ const OnboardingWalkthrough: React.FC<Props> = ({ onComplete }) => {
// Track walkthrough start on mount
useEffect(() => {
trackEvent('walkthrough.started');
report('walkthrough', 'started');
}, []);
// Track each step viewed
useEffect(() => {
if (step) {
trackEvent('walkthrough.step_viewed', { step: currentStep, step_name: step.target || 'done' });
report('walkthrough', 'step_viewed', { step: currentStep, step_name: step.target || 'done' });
}
}, [currentStep, step]);
@@ -194,7 +209,7 @@ const OnboardingWalkthrough: React.FC<Props> = ({ onComplete }) => {
const handleNext = useCallback(() => {
if (isLastStep) {
trackEvent('walkthrough.completed', { steps_viewed: currentStep + 1 });
report('walkthrough', 'completed', { steps_viewed: currentStep + 1 });
onComplete();
} else {
setCurrentStep((s) => s + 1);
@@ -216,7 +231,7 @@ const OnboardingWalkthrough: React.FC<Props> = ({ onComplete }) => {
if (!el) return;
const handler = () => {
trackEvent('walkthrough.step_action', { step: currentStep, step_name: step.target });
report('walkthrough', 'step_action', { step: currentStep, step_name: step.target });
setTimeout(() => handleNext(), 300);
};
el.addEventListener('click', handler, { once: true });
+3 -3
View File
@@ -6,7 +6,7 @@ import ToggleButton from '@mui/material/ToggleButton';
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
import CheckIcon from '@mui/icons-material/Check';
import CircularProgress from '@mui/material/CircularProgress';
import { trackEvent } from '@/shared/analytics';
import { report } from '@/shared/serviceClient';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import {
subscribeToPlan,
@@ -114,7 +114,7 @@ const PlanPicker: React.FC<PlanPickerProps> = ({
const [pending, setPending] = useState<OpenSwarmPlan | null>(null);
React.useEffect(() => {
trackEvent('subscription.plan_picker_opened', { source, default_plan: defaultPlan ?? 'pro_plus' });
report('subscription', 'plan_picker_opened', { source, default_plan: defaultPlan ?? 'pro_plus' });
}, [source, defaultPlan]);
const handleSubscribe = async (plan: OpenSwarmPlan) => {
@@ -130,7 +130,7 @@ const PlanPicker: React.FC<PlanPickerProps> = ({
const handleIntervalChange = (_: React.MouseEvent<HTMLElement>, next: BillingInterval | null) => {
if (!next) return;
setInterval(next);
trackEvent('subscription.billing_interval_toggled', { source, interval: next });
report('subscription', 'billing_interval_toggled', { source, interval: next });
};
// Typography scale — scaled down in compact mode (MessageBubble modal) but
+57 -6
View File
@@ -178,12 +178,33 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
useEffect(() => {
if (!id || isDraft) return;
const ws = createSessionWs(id);
ws.connect();
wsRef.current = ws;
dispatch(fetchSession(id));
let cancelled = false;
let ws: ReturnType<typeof createSessionWs> | null = null;
// Order matters: hydrate the persisted message list from REST FIRST,
// THEN connect the WS. The WS resume protocol replays buffered
// events starting at last_seq=0, which includes every stream_*
// event for messages that finished before the disconnect. The
// replay-skip guard in WebSocketManager._messageAlreadyComplete
// checks `session.messages` to decide whether to drop deltas — so
// if we connect first, the slice is empty when the replay arrives,
// the guard returns false, and the user sees the chat type itself
// out again. Awaiting fetchSession before connect makes the slice
// authoritative before any replay event lands.
(async () => {
try {
await dispatch(fetchSession(id));
} catch {
// Even if the REST hydrate fails, still connect — the WS resume
// protocol can hydrate from buffered events as a fallback.
}
if (cancelled) return;
ws = createSessionWs(id);
ws.connect();
wsRef.current = ws;
})();
return () => {
ws.disconnect();
cancelled = true;
if (ws) ws.disconnect();
wsRef.current = null;
};
}, [id, isDraft, dispatch]);
@@ -354,6 +375,7 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
}, []);
const scrollRafRef = useRef<number | null>(null);
const lastScrollHeightRef = useRef<number>(0);
useEffect(() => {
if (!isAtBottomRef.current) return;
if (scrollRafRef.current != null) return;
@@ -361,7 +383,19 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
scrollRafRef.current = null;
if (!isAtBottomRef.current) return;
const el = scrollContainerRef.current;
if (el) el.scrollTop = el.scrollHeight;
if (!el) return;
// Only set scrollTop when the scrollable height actually grew.
// Otherwise we're forcing a paint for nothing — and on a
// streaming turn we get one of these per delta, which thrashes
// the compositor for zero visible benefit. The native
// overflow-anchor on the container already keeps the viewport
// pinned to the bottom; this JS fallback only needs to handle
// the rare case where anchoring misses (legacy WebKit,
// virtualized children, dynamic-height inserts).
const newHeight = el.scrollHeight;
if (newHeight === lastScrollHeightRef.current) return;
lastScrollHeightRef.current = newHeight;
el.scrollTop = newHeight;
});
}, [session?.messages.length, session?.streamingMessage?.content]);
@@ -874,6 +908,23 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
overflow: 'auto',
px: 2,
py: 1,
// Smoothness bundle (perf-only — no behavior change):
// 1. overflow-anchor: auto — Chromium's native scroll
// anchoring keeps the viewport pinned to the user's
// visible content as siblings above/below resize.
// Eliminates the "transcript snaps back" feel during
// streaming and parallel tool fan-outs. Runs on the
// compositor thread, free.
// 2. contain: layout — tells the browser layout shifts
// inside this scroll container don't affect siblings
// outside it. Prevents reflow from cascading up to
// the dashboard layout when bubbles grow.
// 3. overscroll-behavior: contain — keeps over-scroll
// gestures from leaking up to the dashboard pan/zoom
// when the user hits the chat top/bottom.
overflowAnchor: 'auto',
contain: 'layout',
overscrollBehavior: 'contain',
'&::-webkit-scrollbar': { width: 6 },
'&::-webkit-scrollbar-track': { background: 'transparent' },
'&::-webkit-scrollbar-thumb': {
@@ -1156,4 +1156,4 @@ const GroupRow: React.FC<GroupRowProps> = ({ group, expanded, onToggle, onApprov
);
};
export default ApprovalBar;
export default React.memo(ApprovalBar);
@@ -7,6 +7,7 @@ import RefreshIcon from '@mui/icons-material/Refresh';
import DifferenceIcon from '@mui/icons-material/Difference';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { API_BASE } from '@/shared/config';
import { Skeleton } from '@/app/components/Loading';
const AGENTS_API = `${API_BASE}/agents`;
@@ -100,7 +101,11 @@ const DiffViewer: React.FC<Props> = ({ sessionId }) => {
}}
>
{loading ? (
<Typography sx={{ color: c.text.ghost, fontSize: '0.8rem' }}>Loading...</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{[0, 1, 2, 3, 4, 5, 6].map((i) => (
<Skeleton key={i} variant="line" width={`${60 + (i * 7) % 30}%`} height={10} />
))}
</Box>
) : diff ? (
<pre
style={{
+187 -108
View File
@@ -1,5 +1,5 @@
import React, { useState, useMemo } from 'react';
import { trackEvent } from '@/shared/analytics';
import { report } from '@/shared/serviceClient';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
@@ -32,10 +32,7 @@ const streamingCursorKeyframes = `
}
`;
// Claude.ai-style shimmer that sweeps left → right across text while the
// model is actively thinking. Uses background-clip: text to mask a moving
// linear gradient onto the text glyphs so the effect looks like a light
// wave traveling through the letters.
// shimmer-on-text effect for thinking. background-clip:text + sliding gradient.
const thinkingShimmerKeyframes = `
@keyframes thinking-shimmer {
0% { background-position: 200% 0; }
@@ -73,12 +70,9 @@ interface OpenSwarmErrorInfo {
ctaAction?: 'upgrade' | 'retry' | 'settings' | 'waitlist';
}
// Turn a raw Claude-CLI / cloud error string into a user-friendly card.
// Returns null for things that aren't obviously our errors — those fall
// through to normal markdown rendering.
// raw error text into a friendly card. null = not ours, render as markdown.
function parseOpenSwarmError(text: string): OpenSwarmErrorInfo | null {
if (!text) return null;
// Rate-limit cap from our cloud
if (/rate_limit_error|reached your OpenSwarm.*plan limit|Usage cap exceeded/i.test(text)) {
const reset = text.match(/Resets in ([\dhms\s]+)/)?.[1];
return {
@@ -91,14 +85,7 @@ function parseOpenSwarmError(text: string): OpenSwarmErrorInfo | null {
ctaAction: 'upgrade',
};
}
// Upstream capacity / 503 / transient. The backend already retries these
// for ~5.5 minutes (5/15/45/90/180s) before bubbling up, so by the time a
// user sees this the system has genuinely struggled — but it's almost
// always recoverable on the next send, not a plan/billing issue. Show a
// soft "connection hiccup" card instead of the waitlist/"servers maxed"
// copy, which misleads Pro/Pro+/Ultra subscribers into thinking their
// paid plan is out of capacity. The only real hard cap a user should see
// is their own per-plan 5h limit (matched above as `kind: 'cap'`).
// backend retried for ~5.5min before bubbling. show a soft hiccup, not a cap.
if (/at capacity|Try again shortly|503|service unavailable/i.test(text)) {
return {
kind: 'network',
@@ -106,11 +93,7 @@ function parseOpenSwarmError(text: string): OpenSwarmErrorInfo | null {
detail: 'That request timed out after a few retries. Send the message again to continue.',
};
}
// Too many MCP tool definitions for the chosen model's input window.
// Classic case: user has 5+ apps connected (M365 alone has 141 actions),
// chose Haiku (200K context), and even a one-line message can't fit
// because the tool schemas alone push past the limit. Bigger models
// (Sonnet/Opus, 1M) absorb it fine.
// tool schemas overflowed the window. M365 alone is 141 actions.
if (/Prompt is too long|prompt_too_long|input length and `max_tokens`|context length/i.test(text)) {
return {
kind: 'too_many_tools',
@@ -125,7 +108,6 @@ function parseOpenSwarmError(text: string): OpenSwarmErrorInfo | null {
ctaAction: 'settings',
};
}
// Auth / subscription problems
if (/No active subscription|Subscription canceled|Subscription past_due|Invalid.*token|Missing bearer token/i.test(text)) {
return {
kind: 'auth',
@@ -135,15 +117,7 @@ function parseOpenSwarmError(text: string): OpenSwarmErrorInfo | null {
ctaAction: 'settings',
};
}
// Genuine, hard network failures only. The bare word `network` used to
// match anything mentioning "network" (Python traces, MCP tool output,
// ffmpeg lines, etc.), and `fetch failed` / `ETIMEDOUT` alone fire for
// transient upstream blips the backend now silently retries — surfacing
// a card for those just confuses the user. So: require the specific
// errno codes at word boundaries, and only match `fetch failed` when
// paired with a concrete cause so we don't swallow every Node-level
// transient. The backend's capacity/transient retry layer handles the
// rest without ever reaching this classifier.
// strict matchers only. bare "network" used to false-match Python traces.
if (/\b(?:ECONNREFUSED|ENETUNREACH|ENOTFOUND|EAI_AGAIN)\b|Could\s+not\s+reach\s+OpenSwarm|Unable\s+to\s+connect\s+to\s+OpenSwarm/i.test(text)) {
return {
kind: 'network',
@@ -491,46 +465,33 @@ const MessageImageThumbnails: React.FC<{
);
};
// ── ThinkingBubble ──────────────────────────────────────────────────
// Collapsible reasoning section styled after Claude.ai / ChatGPT /
// Gemini. Defaults to expanded so thinking is always visible when
// present. User can click the header to collapse. If we observed the
// stream live we show "Thought for Ns"; otherwise (history replay) we
// just show "Thoughts".
// thinking pill. shows "Thought for Ns" if we caught it live, else just "Thoughts".
const ThinkingBubble: React.FC<{
content: string;
isStreaming?: boolean;
timestamp?: string;
// Server-stamped duration / token count, populated on the persisted
// Message at end-of-stream. When present, post-stream label uses these
// exact values instead of the in-memory React-state estimates that
// disappear when the streaming bubble unmounts.
// server-stamped totals for the turn. survives unmount.
persistedElapsedMs?: number;
persistedTokens?: number;
// Aux-LLM-generated dynamic label for the active turn ("Auditing the
// pull request", "Drafting your email"). Replaces the static
// "Thinking…" verb when present and the stream is still active.
persistedInputTokens?: number;
persistedToolCount?: number;
// aux-LLM label like "Auditing the pull request". null = use the heuristic.
dynamicLabel?: string | null;
}> = ({ content, isStreaming, persistedElapsedMs, persistedTokens, dynamicLabel }) => {
}> = ({ content, isStreaming, persistedElapsedMs, persistedTokens, persistedInputTokens, persistedToolCount, dynamicLabel }) => {
const c = useClaudeTokens();
// Only time a think-session that we actually saw start live. For saved
// messages loaded from history, we don't have reliable start/end, so
// we fall back to a generic "Thoughts" label.
// live timer is just the fallback. server-stamped values win.
const [startedStreamingAt, setStartedStreamingAt] = useState<number | null>(
isStreaming ? Date.now() : null
);
const [elapsed, setElapsed] = useState<number>(0);
const [frozenElapsed, setFrozenElapsed] = useState<number | null>(null);
// Record start time the first time we see streaming
React.useEffect(() => {
if (isStreaming && startedStreamingAt === null) {
setStartedStreamingAt(Date.now());
}
}, [isStreaming, startedStreamingAt]);
// Tick the timer while streaming
React.useEffect(() => {
if (!isStreaming || startedStreamingAt === null) return;
const iv = setInterval(() => {
@@ -539,58 +500,132 @@ const ThinkingBubble: React.FC<{
return () => clearInterval(iv);
}, [isStreaming, startedStreamingAt]);
// Freeze elapsed when streaming ends
React.useEffect(() => {
if (!isStreaming && startedStreamingAt !== null && frozenElapsed === null) {
setFrozenElapsed(Math.max(1, Math.floor((Date.now() - startedStreamingAt) / 1000)));
}
}, [isStreaming, startedStreamingAt, frozenElapsed]);
// Always default to expanded — user can click to collapse
// expanded while streaming, collapsed after. userOverride pins explicit clicks.
const [userOverride, setUserOverride] = useState<boolean | null>(null);
const expanded = userOverride ?? true;
const expanded = userOverride ?? !!isStreaming;
const toggle = () => setUserOverride(!expanded);
const text = typeof content === 'string' ? content : JSON.stringify(content);
// Live token estimate uses Anthropic's BPE-ish ratio for English prose
// (~3.6 chars/token) instead of the cruder /4. Still an estimate — true
// value lands via persistedTokens when the stream ends.
// 3.6 chars/token for English. swap for persistedTokens once the stream ends.
const liveTokenEstimate = isStreaming ? Math.max(0, Math.round(text.length / 3.6)) : 0;
// Post-stream label preference order:
// 1. Server-stamped persisted values (survive reload).
// 2. Live React-state values (set during this session's stream).
// 3. Generic "Thoughts" fallback for legacy messages with neither.
const persistedSecs = persistedElapsedMs != null
? Math.max(1, Math.round(persistedElapsedMs / 1000))
: null;
const finalSeconds = persistedSecs ?? frozenElapsed;
const finalSeconds = persistedSecs
?? (startedStreamingAt != null && !isStreaming
? Math.max(1, Math.floor((Date.now() - startedStreamingAt) / 1000))
: null);
const finalTokens = persistedTokens
?? (text && !isStreaming ? Math.max(1, Math.round(text.length / 3.6)) : null);
// Active-stream label preference:
// 1. Aux-LLM dynamic label ("Auditing the pull request") when available.
// 2. Heuristic "Thinking…" with token estimate as the fallback.
// The dynamic label only replaces the verb part — token count chip
// appends after, so users still see the live counter.
const activeLabel = dynamicLabel
? (liveTokenEstimate > 0 ? `${dynamicLabel}… · ~${liveTokenEstimate} tokens` : `${dynamicLabel}`)
: (liveTokenEstimate > 0 ? `Thinking… (~${liveTokenEstimate} tokens)` : 'Thinking…');
const label = isStreaming
? activeLabel
: finalSeconds != null
? (finalTokens != null
? `Thought for ${finalSeconds}s · ${finalTokens} tokens`
: `Thought for ${finalSeconds}s`)
: finalTokens != null
? `Thoughts · ${finalTokens} tokens`
: 'Thoughts';
const fmtTokens = (n: number) => {
if (n >= 1000) {
const k = n / 1000;
return k >= 10 ? `${Math.round(k)}K` : `${k.toFixed(1)}K`;
}
return String(n);
};
// 251s reads as "4m 11s". mirrors AgentCard's fmtSeconds.
const fmtThoughtDuration = (sec: number) => {
if (sec < 60) return `${sec}s`;
const minutes = Math.floor(sec / 60);
if (minutes < 60) {
const remSec = sec % 60;
return remSec > 0 ? `${minutes}m ${remSec}s` : `${minutes}m`;
}
const hours = Math.floor(minutes / 60);
const remMin = minutes % 60;
return remMin > 0 ? `${hours}h ${remMin}m` : `${hours}h`;
};
// input_tokens is the full turn cost (parent + subagents + tool MCPs).
// legacy messages without it fall back to output-only.
const combinedTotalTokens =
persistedInputTokens != null && persistedInputTokens > 0
? persistedInputTokens
: finalTokens;
// tooltip breakdown. legacy data without finalTokens shows total only.
const tokenBreakdown = (() => {
if (combinedTotalTokens == null || combinedTotalTokens <= 0) return null;
if (finalTokens == null || finalTokens <= 0) {
return { total: combinedTotalTokens, output: null as number | null, input: null as number | null };
}
const inputSide = Math.max(0, combinedTotalTokens - finalTokens);
return { total: combinedTotalTokens, output: finalTokens, input: inputSide };
})();
const renderPostStreamLabel = () => {
const segments: React.ReactNode[] = [];
segments.push(
<span key="duration">
{finalSeconds != null
? `Thought for ${fmtThoughtDuration(finalSeconds)}`
: 'Thoughts'}
</span>
);
if (tokenBreakdown) {
const { total, input, output } = tokenBreakdown;
const tooltipBody = input != null && output != null ? (
<Box sx={{ p: 0.5, fontFamily: c.font.sans, fontSize: '0.78rem', lineHeight: 1.5 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', gap: 2 }}>
<span>Input</span><span style={{ fontVariantNumeric: 'tabular-nums' }}>{input.toLocaleString()}</span>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', gap: 2 }}>
<span>Output</span><span style={{ fontVariantNumeric: 'tabular-nums' }}>{output.toLocaleString()}</span>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', gap: 2, mt: 0.25, pt: 0.25, borderTop: `1px solid ${c.border.subtle}`, fontWeight: 600 }}>
<span>Total</span><span style={{ fontVariantNumeric: 'tabular-nums' }}>{total.toLocaleString()}</span>
</Box>
<Box sx={{ mt: 0.5, color: c.text.ghost, fontSize: '0.7rem', fontStyle: 'italic' }}>
Input includes system prompt, history, tool defs, cache reads, and any subagent/tool work this turn.
</Box>
</Box>
) : (
<Box sx={{ p: 0.5, fontFamily: c.font.sans, fontSize: '0.78rem' }}>
{total.toLocaleString()} tokens (input + output + children)
</Box>
);
segments.push(<span key="sep-1"> · </span>);
segments.push(
<Tooltip
key="tokens"
title={tooltipBody}
placement="top"
arrow
slotProps={{ tooltip: { sx: { bgcolor: c.bg.elevated, color: c.text.primary, border: `1px solid ${c.border.medium}`, maxWidth: 'none' } } }}
>
<Box
component="span"
onClick={(e) => { e.stopPropagation(); }}
sx={{
cursor: 'help',
borderBottom: `1px dotted ${c.border.medium}`,
'&:hover': { color: c.text.secondary },
}}
>
{fmtTokens(total)} tokens
</Box>
</Tooltip>
);
}
if (persistedToolCount != null && persistedToolCount > 0) {
segments.push(<span key="sep-2"> · </span>);
segments.push(
<span key="tools">{persistedToolCount} tool{persistedToolCount === 1 ? '' : 's'} used</span>
);
}
return segments;
};
// shimmer needs a flat string. post-stream uses nodes for the tooltip.
const label: React.ReactNode = isStreaming ? activeLabel : renderPostStreamLabel();
// Shimmer colors — use a bright mid-tone against the muted base to make
// the sweep visible without being loud. The base color matches the
// static "Thought for Ns" state so the only visible change is the moving
// highlight band.
const shimmerBase = c.text.tertiary;
const shimmerHighlight = c.text.primary;
@@ -621,7 +656,6 @@ const ThinkingBubble: React.FC<{
fontSize: '0.78rem',
fontWeight: 500,
...(isStreaming ? {
// Moving gradient masked onto the text glyphs
background: `linear-gradient(90deg, ${shimmerBase} 0%, ${shimmerBase} 40%, ${shimmerHighlight} 50%, ${shimmerBase} 60%, ${shimmerBase} 100%)`,
backgroundSize: '200% 100%',
WebkitBackgroundClip: 'text',
@@ -659,22 +693,74 @@ const ThinkingBubble: React.FC<{
fontFamily: c.font.sans,
}}
>
{text}
{isStreaming && <StreamingCursor />}
{text ? (
<>
{text}
{isStreaming && <StreamingCursor />}
</>
) : (
<ProviderReasoningExplanation
isStreaming={!!isStreaming}
tokens={persistedTokens ?? null}
elapsedMs={persistedElapsedMs ?? null}
/>
)}
</Box>
</Collapse>
</Box>
);
};
// fallback when the model thought but the provider didn't expose the text.
const ProviderReasoningExplanation: React.FC<{
isStreaming: boolean;
tokens: number | null;
elapsedMs: number | null;
}> = ({ isStreaming, tokens, elapsedMs }) => {
if (isStreaming) {
return (
<Box component="span" sx={{ fontStyle: 'italic', opacity: 0.85 }}>
Reasoning
<StreamingCursor />
</Box>
);
}
const hasMetrics = (tokens && tokens > 0) || (elapsedMs && elapsedMs > 0);
const metric = (() => {
if (!hasMetrics) return null;
const segs: string[] = [];
if (elapsedMs && elapsedMs > 0) {
segs.push(`${Math.max(1, Math.round(elapsedMs / 1000))}s`);
}
if (tokens && tokens > 0) {
segs.push(`${tokens.toLocaleString()} reasoning tokens`);
}
return segs.join(' · ');
})();
const variants = [
"It's still thinking — we just aren't allowed to peek behind the curtain.",
"Wheels are turning, but this provider keeps its thoughts private.",
"Brain's busy back there; the provider just isn't letting us listen in.",
"Mulling it over quietly — only Claude shows its work out loud.",
"Thinking happened, just not in the open. (GPT and Gemini play their cards close.)",
"Reasoning's underway, but this provider doesn't broadcast it. Trust the process.",
];
const idx = useMemo(() => Math.floor(Math.random() * variants.length), []);
const line = variants[idx];
return (
<Box component="span" sx={{ fontStyle: 'italic', opacity: 0.85 }}>
{line} {metric ? `Took ${metric}.` : ''}
</Box>
);
};
interface Props {
message: AgentMessage;
editing?: boolean;
onSaveEdit?: (messageId: string, newContent: string) => void;
onCancelEdit?: () => void;
isStreaming?: boolean;
// Session's current aux-LLM turn label, if any. Only meaningful when
// this is the live-streaming thinking bubble; ignored otherwise.
dynamicTurnLabel?: string | null;
}
@@ -702,6 +788,8 @@ const MessageBubble: React.FC<Props> = React.memo(({ message, editing = false, o
timestamp={message.timestamp}
persistedElapsedMs={(message as any).elapsed_ms}
persistedTokens={(message as any).tokens}
persistedInputTokens={(message as any).input_tokens}
persistedToolCount={(message as any).tool_count}
dynamicLabel={isStreaming ? dynamicTurnLabel : null}
/>
);
@@ -747,17 +835,13 @@ const MessageBubble: React.FC<Props> = React.memo(({ message, editing = false, o
>{rawText}</ReactMarkdown>
), [rawText]);
// Detect friendly OpenSwarm / upstream errors and render a card instead of
// raw "API Error: ..." text. Checks both the wrapped format the Claude CLI
// uses ("API Error: NNN …") and the raw JSON body.
// upstream errors get a friendly card.
const openswarmError = !isUser ? parseOpenSwarmError(rawText) : null;
// Fire subscription.rate_limit_hit exactly once per rate-limit error
// card mount. Dependency on (message.id, kind) ensures we don't re-fire
// on re-renders or content edits.
// fire once per cap card. (message.id, kind) keeps it from re-firing on edits.
React.useEffect(() => {
if (openswarmError?.kind === 'cap') {
trackEvent('subscription.rate_limit_hit', { message_id: message.id });
report('subscription', 'rate_limit_hit', { message_id: message.id });
}
}, [message.id, openswarmError?.kind]);
@@ -783,8 +867,7 @@ const MessageBubble: React.FC<Props> = React.memo(({ message, editing = false, o
? content.slice(0, 200)
: JSON.stringify(content).slice(0, 200);
// Optimistic-bubble visuals: dim the bubble until the server echoes it
// back (status: 'pending'), and tint it red on send failure.
// pending = dim, failed = red tint.
const optimisticStatus = (message as any).optimistic_status as 'pending' | 'failed' | undefined;
const isPending = optimisticStatus === 'pending';
const isFailed = optimisticStatus === 'failed';
@@ -798,6 +881,8 @@ const MessageBubble: React.FC<Props> = React.memo(({ message, editing = false, o
display: 'flex',
justifyContent: isUser ? 'flex-end' : 'flex-start',
my: 0.75,
// contain: reflow inside this bubble doesn't shake the transcript.
contain: 'layout style',
}}
>
<Box
@@ -811,9 +896,6 @@ const MessageBubble: React.FC<Props> = React.memo(({ message, editing = false, o
py: 1.25,
boxShadow: isUser ? 'none' : c.shadow.sm,
overflow: 'hidden',
// Pending bubbles fade in at ~70% opacity until the server echo
// resolves them; failed bubbles get a soft red tint so the user
// can see the message didn't go through.
opacity: isPending ? 0.7 : 1,
transition: 'opacity 0.2s, border-color 0.2s',
}}
@@ -984,12 +1066,9 @@ const MessageBubble: React.FC<Props> = React.memo(({ message, editing = false, o
onClick={() => {
const api = (window as any).openswarm;
if (openswarmError.ctaAction === 'upgrade') {
// Open the tier picker in a modal so the user can
// choose Pro / Pro+ / Ultra + monthly/annual instead
// of going directly to a hardcoded pro_plus checkout.
// tier picker, not direct checkout.
setPickerOpen(true);
} else if (openswarmError.ctaAction === 'settings') {
// Best-effort: dispatch a DOM event the Settings modal listens to
window.dispatchEvent(new CustomEvent('openswarm:open-settings', { detail: { tab: 'models' } }));
} else if (openswarmError.ctaAction === 'waitlist') {
const url = 'https://discord.com/channels/1486442924391796896/1486442927554170892';
@@ -20,7 +20,7 @@ import CallSplitIcon from '@mui/icons-material/CallSplit';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { AgentMessage, expandSession, collapseSession, fetchSession } from '@/shared/state/agentsSlice';
import { getToolLabel } from './toolLabels';
import { getToolLabel, getToolLabelWithInput, prettyPath, prettyUrl, quoteQuery, bashCommandDetail } from './toolLabels';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { placeCard, removeCard, setGlowingAgentCard, clearGlowingAgentCard, DEFAULT_CARD_W, DEFAULT_CARD_H, EXPANDED_CARD_MIN_H, GRID_GAP } from '@/shared/state/dashboardLayoutSlice';
import { useClaudeTokens, useThemeMode } from '@/shared/styles/ThemeContext';
@@ -172,7 +172,11 @@ export function parseMcpToolName(rawName: string): McpToolInfo {
if (!m) return { isMcp: false, serverSlug: '', action: '', service: '', displayName: rawName };
const serverSlug = m[1];
const action = m[2];
const display = action.replace(/_/g, ' ').replace(/\b\w/g, (ch) => ch.toUpperCase());
// Sentence case: first word capitalized, rest lowercase. Reads "Get
// message details" not "Get Message Details" — the Linear/Notion/Stripe
// convention. Title Case feels marketing-y on every row.
const spaced = action.replace(/_/g, ' ').toLowerCase();
const display = spaced.charAt(0).toUpperCase() + spaced.slice(1);
const lower = action.toLowerCase();
let service = '';
@@ -209,23 +213,28 @@ function getInputSummary(toolName: string, input: any): string {
const n = toolName.toLowerCase();
if (isBashTool(toolName)) {
const cmd = input.command || '';
return `$ ${cmd.slice(0, 80)}${cmd.length > 80 ? '…' : ''}`;
// Verb is in the tool label ("Deleted", "Pulled from git", …);
// surface only the target so the row reads "Deleted foo.ts" instead
// of leaking the full shell command. Raw command stays in the body.
return bashCommandDetail(input.command || '');
}
if (n === 'read') return input.file_path || input.path || '';
if (n === 'write') return input.file_path || input.path || '';
if (n === 'edit' || n === 'multiedit' || n === 'strreplace')
return input.file_path || input.path || '';
if (n === 'read' || n === 'write' || n === 'edit' || n === 'multiedit' || n === 'strreplace')
return prettyPath(input.file_path || input.path || '');
if (n === 'glob') return input.pattern || input.glob || input.glob_pattern || '';
if (n === 'grep' || n === 'ripgrep') {
const pat = input.pattern || input.regex || '';
const path = input.path || input.directory || '';
return path ? `/${pat}/ in ${path}` : `/${pat}/`;
const q = quoteQuery(pat);
return path ? `${q} in ${prettyPath(path)}` : q;
}
if (n === 'websearch') return input.query || input.search_term || '';
if (n === 'webfetch') return input.url || '';
if (n === 'todoread' || n === 'todowrite') return 'todos';
if (n === 'ls') return input.path || '.';
if (n === 'websearch') return quoteQuery(input.query || input.search_term || '');
if (n === 'webfetch') return prettyUrl(input.url || '');
if (n === 'todoread' || n === 'todowrite') return '';
if (n === 'ls') return prettyPath(input.path || '.');
if (n === 'mcpactivate') return ''; // label already says "Connecting to X"
if (n === 'mcpsearch' || n === 'outputsearch') return quoteQuery(input.query || '');
if (n === 'outputactivate') return input.output_id || '';
if (n === 'renderoutput') return input.output_id || '';
return '';
} catch {
return '';
@@ -398,7 +407,8 @@ export function getMcpShortAction(mcpInfo: McpToolInfo): string {
if (service && action.toLowerCase().startsWith(service.toLowerCase() + '_')) {
short = action.slice(service.length + 1);
}
return short.replace(/_/g, ' ').replace(/\b\w/g, (ch) => ch.toUpperCase());
const lower = short.replace(/_/g, ' ').toLowerCase();
return lower.charAt(0).toUpperCase() + lower.slice(1);
}
export function getResultSummary(toolName: string, rawText: string): string {
@@ -406,9 +416,9 @@ export function getResultSummary(toolName: string, rawText: string): string {
if (parsed.type === 'bash') {
const lines = parsed.stdout.split('\n').filter((l) => l.trim()).length;
if (parsed.exitCode !== null && parsed.exitCode !== 0) return `exit ${parsed.exitCode}`;
if (parsed.stderr && !parsed.stdout) return 'stderr';
return `${lines} line${lines !== 1 ? 's' : ''}`;
if (parsed.exitCode !== null && parsed.exitCode !== 0) return `exit ${parsed.exitCode}`;
if (parsed.stderr && !parsed.stdout) return 'stderr';
return `${lines} line${lines !== 1 ? 's' : ''}`;
}
if (parsed.type === 'mcp') {
@@ -417,7 +427,7 @@ export function getResultSummary(toolName: string, rawText: string): string {
const subj = d.subject || getGmailHeader(d, 'Subject');
if (subj) return subj;
if (Array.isArray(d.messages)) return `${d.messages.length} email${d.messages.length !== 1 ? 's' : ''}`;
if (d.id || d.messageId) return '✓ done';
if (d.id || d.messageId) return 'sent';
}
if (parsed.service === 'calendar') {
if (d.summary) return d.summary.slice(0, 40);
@@ -427,8 +437,8 @@ export function getResultSummary(toolName: string, rawText: string): string {
if (d.name) return d.name;
if (Array.isArray(d.files)) return `${d.files.length} file${d.files.length !== 1 ? 's' : ''}`;
}
if (d.error || d.is_error) return 'error';
return '✓ done';
if (d.error || d.is_error) return 'error';
return '';
}
const text = parsed.content;
@@ -446,19 +456,11 @@ export function getResultSummary(toolName: string, rawText: string): string {
return `${matchCount} match${matchCount !== 1 ? 'es' : ''}`;
}
if (n === 'read') return `${lineCount} lines`;
if (n === 'write') {
if (text.toLowerCase().includes('success') || text.toLowerCase().includes('written'))
return '✓ written';
return '✓ done';
}
if (n === 'edit' || n === 'multiedit' || n === 'strreplace') {
if (text.toLowerCase().includes('success') || text.toLowerCase().includes('applied'))
return '✓ applied';
return '✓ done';
}
if (n === 'write') return '';
if (n === 'edit' || n === 'multiedit' || n === 'strreplace') return '';
if (n === 'websearch') return 'results';
if (n === 'webfetch') return `${lineCount} lines`;
if (parsed.isError) return 'error';
if (parsed.isError) return 'error';
} catch {}
return `${lineCount} line${lineCount !== 1 ? 's' : ''}`;
@@ -1425,9 +1427,13 @@ const ToolCallBubble: React.FC<ToolCallBubbleProps> = React.memo(
const promptPrefix = getPromptPrefix(toolName);
const shortAction = mcpInfo.isMcp ? getMcpShortAction(mcpInfo) : toolName;
const serviceLabel = mcpInfo.isMcp && mcpInfo.service
? mcpInfo.service.charAt(0).toUpperCase() + mcpInfo.service.slice(1)
: shortAction;
// mcpCompact rows live inside a ToolGroup whose header already shows
// the brand + count, so the row uses the verb form, not the brand.
const mcpVerbLabel = (() => {
const lbl = getToolLabel(toolName, call.id);
return result && !isDenied ? lbl.past : lbl.present;
})();
const serviceLabel = mcpInfo.isMcp ? mcpVerbLabel : shortAction;
const ServiceIcon = mcpInfo.isMcp && mcpInfo.service
? <GoogleServiceIcon service={mcpInfo.service} size={14} />
@@ -1536,10 +1542,8 @@ const ToolCallBubble: React.FC<ToolCallBubbleProps> = React.memo(
{hasResponse && !isDenied && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{isError ? (
{isError && (
<ErrorOutlineIcon sx={{ fontSize: 13, color: c.status.error }} />
) : (
<CheckCircleOutlineIcon sx={{ fontSize: 13, color: c.status.success }} />
)}
{resultElapsedMs != null && (
<Typography sx={{ fontSize: '0.65rem', fontFamily: c.font.mono, color: c.text.tertiary }}>
@@ -1742,10 +1746,8 @@ const ToolCallBubble: React.FC<ToolCallBubbleProps> = React.memo(
{hasResponse && !isDenied && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{isError ? (
{isError && (
<ErrorOutlineIcon sx={{ fontSize: 13, color: c.status.error }} />
) : (
<CheckCircleOutlineIcon sx={{ fontSize: 13, color: c.status.success }} />
)}
{resultElapsedMs != null && (
<Typography sx={{ fontSize: '0.65rem', fontFamily: c.font.mono, color: c.text.tertiary }}>
@@ -1902,10 +1904,8 @@ const ToolCallBubble: React.FC<ToolCallBubbleProps> = React.memo(
)}
{result && !isDenied && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4 }}>
{isError ? (
{isError && (
<ErrorOutlineIcon sx={{ fontSize: 12, color: c.status.error }} />
) : (
<CheckCircleOutlineIcon sx={{ fontSize: 12, color: c.status.success }} />
)}
{resultElapsedMs != null && (
<Typography sx={{ fontSize: '0.63rem', fontFamily: c.font.mono, color: c.text.tertiary }}>
@@ -2009,11 +2009,8 @@ const ToolCallBubble: React.FC<ToolCallBubbleProps> = React.memo(
}}
>
{(() => {
if (mcpInfo.isMcp) return mcpInfo.displayName;
// Verb-tense progression: "Reading" while pending, "Read" once
// a tool_result has landed. Denied/streaming fall back to the
// present participle since the action is in-flight.
const { present, past } = getToolLabel(toolName);
// call.id seeds the variant pool so re-renders are stable.
const { present, past } = getToolLabelWithInput(toolName, input, call.id);
return result && !isDenied ? past : present;
})()}
</Typography>
@@ -2056,20 +2053,16 @@ const ToolCallBubble: React.FC<ToolCallBubbleProps> = React.memo(
)}
{result && !isDenied && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{isError ? (
<ErrorOutlineIcon sx={{ fontSize: 13, color: c.status.error }} />
) : (
<CheckCircleOutlineIcon sx={{ fontSize: 13, color: c.status.success }} />
{isError && (
<>
<ErrorOutlineIcon sx={{ fontSize: 13, color: c.status.error }} />
{resultSummary && (
<Typography sx={{ color: c.status.error, fontSize: '0.7rem', fontWeight: 500 }}>
{resultSummary}
</Typography>
)}
</>
)}
<Typography
sx={{
color: isError ? c.status.error : c.status.success,
fontSize: '0.7rem',
fontWeight: 500,
}}
>
{resultSummary}
</Typography>
{resultElapsedMs != null && (
<Typography
sx={{
@@ -96,7 +96,12 @@ const ToolGroupBubble: React.FC<Props> = React.memo(({ group, isSessionRunning =
data-select-type="tool-group"
data-select-id={group.id}
data-select-meta={JSON.stringify({ label: displayName, callCount: group.callCount, tools: toolNames })}
sx={{ maxWidth: '85%', my: 0.5 }}
sx={{
maxWidth: '85%',
my: 0.5,
// contain: stops new tool rows from reflowing the whole transcript.
contain: 'layout style',
}}
>
<Box
sx={{
@@ -148,16 +153,23 @@ const ToolGroupBubble: React.FC<Props> = React.memo(({ group, isSessionRunning =
{deniedCount} denied
</Typography>
)}
{/* Fixed-width fraction + count chip so the header row stops
reflowing as the count climbs from 9 10 11 12 during
parallel tool execution. Without min-widths, every digit-
boundary nudges the header text wider, which shifts the
chevron, which shifts the entire transcript below. The
tabular-nums + minWidth pair locks both the fraction and
the chip to a stable size for any 1-3 digit count. */}
{allDone && completedCount > 0 && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.3, minWidth: 44, justifyContent: 'flex-end' }}>
<CheckCircleOutlineIcon sx={{ fontSize: 12, color: c.status.success }} />
<Typography sx={{ color: c.status.success, fontSize: '0.68rem' }}>
<Typography sx={{ color: c.status.success, fontSize: '0.68rem', fontVariantNumeric: 'tabular-nums' }}>
{completedCount}/{group.callCount}
</Typography>
</Box>
)}
{!allDone && pendingCount > 0 && (
<Typography sx={{ color: c.text.tertiary, fontSize: '0.68rem', fontFamily: c.font.mono }}>
<Typography sx={{ color: c.text.tertiary, fontSize: '0.68rem', fontFamily: c.font.mono, fontVariantNumeric: 'tabular-nums', minWidth: 36, textAlign: 'right' }}>
{completedCount}/{group.callCount}
</Typography>
)}
@@ -166,10 +178,12 @@ const ToolGroupBubble: React.FC<Props> = React.memo(({ group, isSessionRunning =
size="small"
sx={{
height: 18,
minWidth: 36,
fontSize: '0.7rem',
fontWeight: 600,
bgcolor: c.bg.secondary,
color: c.text.muted,
fontVariantNumeric: 'tabular-nums',
'& .MuiChip-label': { px: 0.75 },
}}
/>
@@ -179,7 +193,19 @@ const ToolGroupBubble: React.FC<Props> = React.memo(({ group, isSessionRunning =
</Box>
<Collapse in={expanded}>
<Box sx={{ borderTop: `0.5px solid ${c.border.medium}` }}>
<Box
sx={{
borderTop: `0.5px solid ${c.border.medium}`,
// 140ms fade so rows don't pop in.
'& > *': {
animation: 'toolRowFadeIn 140ms ease-out',
},
'@keyframes toolRowFadeIn': {
from: { opacity: 0, transform: 'translateY(-2px)' },
to: { opacity: 1, transform: 'translateY(0)' },
},
}}
>
{group.pairs.map((pair) => (
<ToolCallBubble
key={pair.id}
+895 -63
View File
@@ -1,75 +1,907 @@
// Friendly verb-tense labels for tool calls. Replaces the raw tool name in
// ToolCallBubble titles so the transcript reads as a narration of what the
// agent is doing — "Reading foo.ts" while pending, "Read foo.ts" once done.
//
// Falls back to the raw tool name (capitalized) for anything unmapped, so
// new tools won't render badly. MCP tools (mcp__server__action) are handled
// in ToolCallBubble's existing parseMcpToolName path; this map is for
// built-ins.
//
// Tense convention:
// - present: "-ing" form rendered while the call is pending
// - past: rendered once a tool_result lands (success or error)
//
// Usage:
// const { present, past } = getToolLabel(toolName);
// const verb = isPending ? present : past;
// Tool labels, with variant pools so the transcript reads like a person.
// Destructive ops (rm, git push, delete) stay flat. quirky on rm felt off.
interface ToolLabel {
export interface ToolLabel {
present: string;
past: string;
}
const LABELS: Record<string, ToolLabel> = {
read: { present: 'Reading', past: 'Read' },
write: { present: 'Writing', past: 'Wrote' },
edit: { present: 'Editing', past: 'Edited' },
multiedit: { present: 'Editing', past: 'Edited' },
strreplace: { present: 'Editing', past: 'Edited' },
bash: { present: 'Running', past: 'Ran' },
glob: { present: 'Searching', past: 'Searched' },
grep: { present: 'Searching', past: 'Searched' },
ripgrep: { present: 'Searching', past: 'Searched' },
ls: { present: 'Listing', past: 'Listed' },
websearch: { present: 'Searching the web', past: 'Searched the web' },
webfetch: { present: 'Fetching', past: 'Fetched' },
notebookedit: { present: 'Editing notebook', past: 'Edited notebook' },
todowrite: { present: 'Updating todos', past: 'Updated todos' },
todoread: { present: 'Reading todos', past: 'Read todos' },
taskcreate: { present: 'Creating task', past: 'Created task' },
taskupdate: { present: 'Updating task', past: 'Updated task' },
taskoutput: { present: 'Inspecting task', past: 'Inspected task' },
taskstop: { present: 'Stopping task', past: 'Stopped task' },
tasklist: { present: 'Listing tasks', past: 'Listed tasks' },
taskget: { present: 'Loading task', past: 'Loaded task' },
toolsearch: { present: 'Loading tools', past: 'Loaded tools' },
mcpsearch: { present: 'Searching MCPs', past: 'Searched MCPs' },
mcpactivate: { present: 'Activating MCP', past: 'Activated MCP' },
outputactivate: { present: 'Activating view', past: 'Activated view' },
renderoutput: { present: 'Rendering view', past: 'Rendered view' },
askuserquestion: { present: 'Asking', past: 'Asked' },
invokeagent: { present: 'Invoking sub-agent', past: 'Invoked sub-agent' },
agent: { present: 'Spawning agent', past: 'Spawned agent' },
enterplanmode: { present: 'Entering plan mode', past: 'Entered plan mode' },
exitplanmode: { present: 'Exiting plan mode', past: 'Exited plan mode' },
enterworktree: { present: 'Creating worktree', past: 'Created worktree' },
exitworktree: { present: 'Removing worktree', past: 'Removed worktree' },
pushnotification: { present: 'Notifying', past: 'Notified' },
remotetrigger: { present: 'Triggering', past: 'Triggered' },
croncreate: { present: 'Scheduling', past: 'Scheduled' },
cronlist: { present: 'Listing schedules', past: 'Listed schedules' },
crondelete: { present: 'Cancelling schedule', past: 'Cancelled schedule' },
monitor: { present: 'Watching', past: 'Watched' },
schedulewakeup: { present: 'Scheduling wake-up', past: 'Scheduled wake-up' },
// djb2. same seed always picks the same variant so rows don't flicker.
function _stableIndex(seed: string | undefined, n: number): number {
if (n <= 1 || !seed) return 0;
let h = 5381;
for (let i = 0; i < seed.length; i++) {
h = ((h << 5) + h + seed.charCodeAt(i)) | 0;
}
return Math.abs(h) % n;
}
function _pick<T>(variants: T[], seed?: string): T {
return variants[_stableIndex(seed, variants.length)];
}
// index 0 is the safe-default; single-entry pools = no seeded variation.
const VARIANTS: Record<string, ToolLabel[]> = {
read: [
{ present: 'Reading', past: 'Read' },
{ present: 'Skimming', past: 'Skimmed' },
{ present: 'Peeking at', past: 'Peeked at' },
{ present: 'Glancing at', past: 'Glanced at' },
{ present: 'Diving into', past: 'Dove into' },
{ present: 'Eyeballing', past: 'Eyeballed' },
{ present: 'Cracking open', past: 'Cracked open' },
],
write: [
{ present: 'Writing', past: 'Wrote' },
{ present: 'Saving', past: 'Saved' },
{ present: 'Jotting down', past: 'Jotted down' },
{ present: 'Drafting', past: 'Drafted' },
{ present: 'Putting together', past: 'Put together' },
{ present: 'Penning', past: 'Penned' },
],
edit: [
{ present: 'Editing', past: 'Edited' },
{ present: 'Tweaking', past: 'Tweaked' },
{ present: 'Polishing', past: 'Polished' },
{ present: 'Touching up', past: 'Touched up' },
{ present: 'Refining', past: 'Refined' },
{ present: 'Tuning', past: 'Tuned' },
],
multiedit: [
{ present: 'Editing', past: 'Edited' },
{ present: 'Tweaking', past: 'Tweaked' },
{ present: 'Reworking', past: 'Reworked' },
{ present: 'Patching up', past: 'Patched up' },
{ present: 'Touching up', past: 'Touched up' },
],
strreplace: [
{ present: 'Editing', past: 'Edited' },
{ present: 'Tweaking', past: 'Tweaked' },
{ present: 'Swapping in', past: 'Swapped in' },
],
bash: [
{ present: 'Running command', past: 'Ran command' },
{ present: 'Cooking up', past: 'Cooked up' },
{ present: 'Working on it', past: 'Worked on it' },
{ present: 'Tinkering', past: 'Tinkered' },
{ present: 'Crunching', past: 'Crunched' },
],
glob: [
{ present: 'Hunting for files', past: 'Found files' },
{ present: 'Scanning files', past: 'Scanned files' },
{ present: 'Browsing files', past: 'Browsed files' },
{ present: 'Sniffing out files', past: 'Sniffed out files' },
{ present: 'Rounding up files', past: 'Rounded up files' },
],
grep: [
{ present: 'Searching files', past: 'Searched files' },
{ present: 'Combing through', past: 'Combed through' },
{ present: 'Hunting through', past: 'Hunted through' },
{ present: 'Digging through', past: 'Dug through' },
{ present: 'Sifting through', past: 'Sifted through' },
],
ripgrep: [
{ present: 'Searching files', past: 'Searched files' },
{ present: 'Combing through', past: 'Combed through' },
{ present: 'Digging through', past: 'Dug through' },
],
ls: [
{ present: 'Listing folder', past: 'Listed folder' },
{ present: 'Peeking inside', past: 'Peeked inside' },
{ present: 'Poking around in', past: 'Poked around in' },
],
websearch: [
{ present: 'Searching the web', past: 'Searched the web' },
{ present: 'Googling', past: 'Googled' },
{ present: 'Crawling the web', past: 'Scoured the web' },
{ present: 'Trawling the web', past: 'Trawled the web' },
{ present: 'Hunting online', past: 'Hunted online' },
],
webfetch: [
{ present: 'Reading webpage', past: 'Read webpage' },
{ present: 'Peeking at', past: 'Peeked at' },
{ present: 'Pulling up', past: 'Pulled up' },
{ present: 'Loading up', past: 'Loaded up' },
{ present: 'Skimming', past: 'Skimmed' },
],
notebookedit: [
{ present: 'Editing notebook', past: 'Edited notebook' },
{ present: 'Tweaking notebook', past: 'Tweaked notebook' },
],
todowrite: [
{ present: 'Updating the plan', past: 'Updated the plan' },
{ present: 'Jotting down a plan', past: 'Jotted down a plan' },
{ present: 'Sketching a plan', past: 'Sketched a plan' },
{ present: 'Mapping it out', past: 'Mapped it out' },
{ present: 'Pencilling in steps', past: 'Pencilled in steps' },
],
todoread: [
{ present: 'Checking the plan', past: 'Checked the plan' },
{ present: 'Glancing at the plan', past: 'Glanced at the plan' },
],
taskcreate: [
{ present: 'Starting a side task', past: 'Started a side task' },
{ present: 'Kicking off a side task', past: 'Kicked off a side task' },
{ present: 'Spinning off a side task', past: 'Spun off a side task' },
],
taskupdate: [
{ present: 'Updating task', past: 'Updated task' },
{ present: 'Nudging the task', past: 'Nudged the task' },
],
taskoutput: [
{ present: 'Peeking at the task', past: 'Peeked at the task' },
{ present: 'Checking on the task', past: 'Checked on the task' },
],
taskstop: [
{ present: 'Stopping the task', past: 'Stopped the task' },
{ present: 'Wrapping up the task', past: 'Wrapped up the task' },
],
tasklist: [
{ present: 'Listing tasks', past: 'Listed tasks' },
{ present: 'Rounding up tasks', past: 'Rounded up tasks' },
],
taskget: [
{ present: 'Loading task', past: 'Loaded task' },
{ present: 'Pulling up the task', past: 'Pulled up the task' },
],
toolsearch: [
{ present: 'Looking through the toolbox', past: 'Looked through the toolbox' },
{ present: 'Hunting for the right tool', past: 'Found a tool' },
{ present: 'Browsing the toolbox', past: 'Browsed the toolbox' },
{ present: 'Rummaging the toolbox', past: 'Rummaged the toolbox' },
{ present: 'Digging through the toolbox', past: 'Dug through the toolbox' },
],
mcpsearch: [
{ present: 'Looking through the toolbox', past: 'Looked through the toolbox' },
{ present: 'Hunting for the right tool', past: 'Found a tool' },
{ present: 'Browsing the toolbox', past: 'Browsed the toolbox' },
{ present: 'Rummaging the toolbox', past: 'Rummaged the toolbox' },
],
// brand-aware version lives in getToolLabelWithInput; this is the fallback.
mcpactivate: [
{ present: 'Connecting', past: 'Connected' },
{ present: 'Plugging in', past: 'Plugged in' },
{ present: 'Hooking up', past: 'Hooked up' },
{ present: 'Wiring up', past: 'Wired up' },
{ present: 'Linking up', past: 'Linked up' },
],
mcplist: [
{ present: 'Listing tools', past: 'Listed tools' },
{ present: 'Browsing the toolbox', past: 'Browsed the toolbox' },
],
outputactivate: [
{ present: 'Loading the app', past: 'Loaded the app' },
{ present: 'Spinning up the app', past: 'Spun up the app' },
{ present: 'Wiring up the app', past: 'Wired up the app' },
],
outputlist: [
{ present: 'Browsing apps', past: 'Browsed apps' },
{ present: 'Listing apps', past: 'Listed apps' },
],
outputsearch: [
{ present: 'Hunting for the right app', past: 'Found an app' },
{ present: 'Browsing apps', past: 'Browsed apps' },
{ present: 'Sifting through apps', past: 'Sifted through apps' },
],
renderoutput: [
{ present: 'Showing the app', past: 'Showed the app' },
{ present: 'Painting the app', past: 'Painted the app' },
{ present: 'Mounting the app', past: 'Mounted the app' },
{ present: 'Bringing up the app', past: 'Brought up the app' },
],
askuserquestion: [
{ present: 'Asking', past: 'Asked' },
{ present: 'Checking with you', past: 'Checked with you' },
{ present: 'Pinging you', past: 'Pinged you' },
],
invokeagent: [
{ present: 'Sending a copilot', past: 'Sent a copilot' },
{ present: 'Asking a helper', past: 'Asked a helper' },
{ present: 'Calling in backup', past: 'Called in backup' },
{ present: 'Tagging in a helper', past: 'Tagged in a helper' },
],
agent: [
{ present: 'Spinning up a helper', past: 'Spun up a helper' },
{ present: 'Sending a copilot', past: 'Sent a copilot' },
{ present: 'Calling in backup', past: 'Called in backup' },
{ present: 'Hatching a helper', past: 'Hatched a helper' },
],
createbrowseragent: [
{ present: 'Opening a browser', past: 'Opened a browser' },
{ present: 'Firing up a browser', past: 'Fired up a browser' },
{ present: 'Booting up a browser', past: 'Booted up a browser' },
],
browseragent: [
{ present: 'Driving the browser', past: 'Drove the browser' },
{ present: 'Using the browser', past: 'Used the browser' },
{ present: 'Steering the browser', past: 'Steered the browser' },
],
browseragents: [
{ present: 'Driving the browsers', past: 'Drove the browsers' },
{ present: 'Steering the browsers', past: 'Steered the browsers' },
],
enterplanmode: [
{ present: 'Switching to plan mode', past: 'Switched to plan mode' },
{ present: 'Stepping into plan mode', past: 'Stepped into plan mode' },
],
exitplanmode: [
{ present: 'Leaving plan mode', past: 'Left plan mode' },
{ present: 'Stepping out of plan mode', past: 'Stepped out of plan mode' },
],
enterworktree: [
{ present: 'Setting up a workspace', past: 'Set up a workspace' },
{ present: 'Carving out a workspace', past: 'Carved out a workspace' },
],
exitworktree: [
{ present: 'Cleaning up the workspace', past: 'Cleaned up the workspace' },
{ present: 'Tearing down the workspace', past: 'Tore down the workspace' },
],
pushnotification: [
{ present: 'Notifying', past: 'Notified' },
{ present: 'Pinging you', past: 'Pinged you' },
{ present: 'Tapping your shoulder', past: 'Tapped your shoulder' },
],
remotetrigger: [
{ present: 'Triggering', past: 'Triggered' },
{ present: 'Pulling the trigger', past: 'Pulled the trigger' },
],
croncreate: [
{ present: 'Scheduling', past: 'Scheduled' },
{ present: 'Setting a reminder', past: 'Set a reminder' },
{ present: 'Pinning to the calendar', past: 'Pinned to the calendar' },
{ present: 'Lining up a check-in', past: 'Lined up a check-in' },
],
cronlist: [
{ present: 'Listing schedules', past: 'Listed schedules' },
{ present: 'Checking the calendar', past: 'Checked the calendar' },
],
crondelete: [
{ present: 'Cancelling schedule', past: 'Cancelled schedule' },
{ present: 'Calling off the schedule', past: 'Called off the schedule' },
],
monitor: [
{ present: 'Watching', past: 'Watched' },
{ present: 'Keeping an eye on', past: 'Kept an eye on' },
{ present: 'Tailing', past: 'Tailed' },
],
schedulewakeup: [
{ present: 'Setting a check-in', past: 'Set a check-in' },
{ present: 'Scheduling a wake-up', past: 'Scheduled a wake-up' },
],
};
export function getToolLabel(toolName: string): ToolLabel {
// keys match _sanitize_server_name in tools_lib.
const MCP_SERVER_BRAND: Record<string, string> = {
'google-workspace': 'Google Workspace',
'microsoft-365': 'Microsoft 365',
'gmail': 'Gmail',
'gcal': 'Google Calendar',
'gdrive': 'Google Drive',
'gdocs': 'Google Docs',
'gsheets': 'Google Sheets',
'gslides': 'Google Slides',
'slack': 'Slack',
'discord': 'Discord',
'notion': 'Notion',
'airtable': 'Airtable',
'hubspot': 'HubSpot',
'reddit': 'Reddit',
'youtube': 'YouTube',
'github': 'GitHub',
'linear': 'Linear',
'jira': 'Jira',
'asana': 'Asana',
'figma': 'Figma',
'stripe': 'Stripe',
'openswarm-browser-agent': 'browser',
'openswarm-invoke-agent': 'helper',
'openswarm-mcp-meta': 'tools',
'openswarm-outputs-meta': 'views',
};
// most specific verb pattern wins, so order matters.
interface McpVerbVariant { present: string; past: string; }
const MCP_VERB_PATTERNS: Array<{ match: RegExp; variants: McpVerbVariant[] }> = [
{ match: /^(send|new)_/, variants: [
{ present: 'Sending', past: 'Sent' },
{ present: 'Firing off', past: 'Fired off' },
{ present: 'Shipping', past: 'Shipped' },
{ present: 'Dispatching', past: 'Dispatched' },
{ present: 'Sending out', past: 'Sent out' },
]},
{ match: /^(post|publish)_/, variants: [
{ present: 'Posting', past: 'Posted' },
{ present: 'Pinning up', past: 'Pinned up' },
{ present: 'Putting up', past: 'Put up' },
{ present: 'Dropping in', past: 'Dropped in' },
]},
{ match: /^(create|add)_/, variants: [
{ present: 'Creating', past: 'Created' },
{ present: 'Spinning up', past: 'Spun up' },
{ present: 'Whipping up', past: 'Whipped up' },
{ present: 'Cooking up', past: 'Cooked up' },
{ present: 'Setting up', past: 'Set up' },
{ present: 'Drafting', past: 'Drafted' },
]},
{ match: /^(query|search|find|list|get|fetch|read|view|show|browse|analyze)_/, variants: [
{ present: 'Reading', past: 'Read' },
{ present: 'Skimming', past: 'Skimmed' },
{ present: 'Pulling up', past: 'Pulled up' },
{ present: 'Peeking at', past: 'Peeked at' },
{ present: 'Digging up', past: 'Dug up' },
{ present: 'Hunting down', past: 'Hunted down' },
{ present: 'Tracking down', past: 'Tracked down' },
{ present: 'Fetching', past: 'Fetched' },
]},
{ match: /^(update|edit|modify|patch|set)_/, variants: [
{ present: 'Updating', past: 'Updated' },
{ present: 'Tweaking', past: 'Tweaked' },
{ present: 'Polishing', past: 'Polished' },
{ present: 'Tuning', past: 'Tuned' },
{ present: 'Refining', past: 'Refined' },
{ present: 'Touching up', past: 'Touched up' },
]},
// delete = flat. don't be cute about deletions.
{ match: /^(delete|remove|cancel|archive)_/, variants: [
{ present: 'Deleting', past: 'Deleted' },
]},
{ match: /^(reply|respond|comment)_/, variants: [
{ present: 'Replying', past: 'Replied' },
{ present: 'Hitting back', past: 'Hit back' },
{ present: 'Chiming in', past: 'Chimed in' },
]},
{ match: /^(download|export|backup)_/, variants: [
{ present: 'Downloading', past: 'Downloaded' },
{ present: 'Pulling down', past: 'Pulled down' },
{ present: 'Grabbing', past: 'Grabbed' },
{ present: 'Saving down', past: 'Saved down' },
]},
{ match: /^(upload|import|attach)_/, variants: [
{ present: 'Uploading', past: 'Uploaded' },
{ present: 'Sending up', past: 'Sent up' },
{ present: 'Pushing up', past: 'Pushed up' },
{ present: 'Beaming up', past: 'Beamed up' },
]},
{ match: /^(execute|run|invoke|trigger|complete)_/, variants: [
{ present: 'Running', past: 'Ran' },
{ present: 'Firing', past: 'Fired' },
{ present: 'Kicking off', past: 'Kicked off' },
]},
{ match: /^(authenticate|login|connect)_/, variants: [
{ present: 'Connecting', past: 'Connected' },
{ present: 'Plugging in', past: 'Plugged in' },
{ present: 'Hooking up', past: 'Hooked up' },
]},
];
const ACTION_OBJECTS: Array<{ match: RegExp; noun: string }> = [
{ match: /(?:^|_)(?:gmail|email|inbox|mail)s?(?:_|$)/, noun: 'email' },
{ match: /(?:^|_)(?:event|meeting|appointment)/, noun: 'event' },
{ match: /(?:^|_)(?:freebusy|availability)/, noun: 'availability' },
{ match: /(?:^|_)(?:calendar)/, noun: 'calendar' },
{ match: /(?:^|_)(?:contact)/, noun: 'contact' },
{ match: /(?:^|_)(?:doc|document)/, noun: 'document' },
{ match: /(?:^|_)(?:sheet|spreadsheet|row|cell)/, noun: 'sheet' },
{ match: /(?:^|_)(?:slide|presentation)/, noun: 'slide' },
{ match: /(?:^|_)(?:dm|direct_message)/, noun: 'DM' },
{ match: /(?:^|_)(?:thread|reply)/, noun: 'thread' },
{ match: /(?:^|_)(?:channel)/, noun: 'channel' },
{ match: /(?:^|_)(?:message|msg)/, noun: 'message' },
{ match: /(?:^|_)(?:reaction|emoji)/, noun: 'reaction' },
{ match: /(?:^|_)(?:page)/, noun: 'page' },
{ match: /(?:^|_)(?:database|db|table|base)/, noun: 'database' },
{ match: /(?:^|_)(?:record)/, noun: 'record' },
{ match: /(?:^|_)(?:issue)/, noun: 'issue' },
{ match: /(?:^|_)(?:pull_request|pr)(?:_|$)/, noun: 'PR' },
{ match: /(?:^|_)(?:comment)/, noun: 'comment' },
{ match: /(?:^|_)(?:deal|opportunity)/, noun: 'deal' },
{ match: /(?:^|_)(?:company|account)/, noun: 'company' },
{ match: /(?:^|_)(?:ticket)/, noun: 'ticket' },
{ match: /(?:^|_)(?:user|profile|member)/, noun: 'user' },
{ match: /(?:^|_)(?:video|stream)/, noun: 'video' },
{ match: /(?:^|_)(?:transcript|caption)/, noun: 'transcript' },
{ match: /(?:^|_)(?:subreddit|sub)/, noun: 'subreddit' },
{ match: /(?:^|_)(?:post|submission)/, noun: 'post' },
{ match: /(?:^|_)(?:file|drive|folder)/, noun: 'file' },
{ match: /(?:^|_)(?:task|todo)/, noun: 'task' },
];
// sentence case (Linear/Notion vibe). title case felt too marketing-y.
function _humanizeName(name: string): string {
const spaced = name.replace(/[-_]+/g, ' ').toLowerCase();
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
}
function _labelForMcpTool(toolName: string, seed?: string): ToolLabel | null {
const parts = toolName.split('__');
if (parts.length < 3 || parts[0] !== 'mcp') return null;
const server = parts[1].toLowerCase();
const action = parts.slice(2).join('__').toLowerCase();
const brand = MCP_SERVER_BRAND[server] || _humanizeName(server);
// our internal meta-MCPs go through VARIANTS so we don't render "tools: Mcpsearch".
if (server.startsWith('openswarm-')) {
const builtin = VARIANTS[action];
if (builtin) return _pick(builtin, seed);
}
let verbVariants: McpVerbVariant[] | null = null;
for (const p of MCP_VERB_PATTERNS) {
if (p.match.test(action)) { verbVariants = p.variants; break; }
}
let noun = '';
for (const a of ACTION_OBJECTS) {
if (a.match.test(action)) { noun = a.noun; break; }
}
if (verbVariants) {
const verb = _pick(verbVariants, seed);
if (noun) {
return { present: `${verb.present} ${noun}`, past: `${verb.past} ${noun}` };
}
return { present: `${verb.present} via ${brand}`, past: `${verb.past} via ${brand}` };
}
// no verb match. fall back to brand: action.
const human = _humanizeName(action.replace(/^_+|_+$/g, ''));
return { present: `${brand}: ${human}`, past: `${brand}: ${human}` };
}
export function getToolLabel(toolName: string, seed?: string): ToolLabel {
if (!toolName) return { present: 'Working', past: 'Done' };
const mcpHit = _labelForMcpTool(toolName, seed);
if (mcpHit) return mcpHit;
const key = toolName.toLowerCase();
const hit = LABELS[key];
if (hit) return hit;
// Fallback: capitalize the raw name with neutral verbs that read OK either
// way ("Running tool" / "Ran tool").
const variants = VARIANTS[key];
if (variants) return _pick(variants, seed);
const pretty = toolName.charAt(0).toUpperCase() + toolName.slice(1);
return { present: `Running ${pretty}`, past: `Ran ${pretty}` };
}
// for tools where the input changes the label (MCPActivate, Bash).
export function getToolLabelWithInput(toolName: string, input: any, seed?: string): ToolLabel {
if (!toolName) return { present: 'Working', past: 'Done' };
if (toolName === 'MCPActivate' && input?.server_name) {
const slug = String(input.server_name).toLowerCase();
const brand = MCP_SERVER_BRAND[slug] || _humanizeName(slug);
const variants: ToolLabel[] = [
{ present: `Connecting to ${brand}`, past: `Connected to ${brand}` },
{ present: `Plugging into ${brand}`, past: `Plugged into ${brand}` },
{ present: `Hooking up ${brand}`, past: `Hooked up ${brand}` },
{ present: `Wiring up ${brand}`, past: `Wired up ${brand}` },
{ present: `Linking up ${brand}`, past: `Linked up ${brand}` },
{ present: `Tapping into ${brand}`, past: `Tapped into ${brand}` },
];
return _pick(variants, seed);
}
if (toolName === 'Bash' || toolName === 'bash') {
const cmd = typeof input?.command === 'string' ? input.command : '';
if (cmd) {
const verb = _bashVerb(cmd, seed);
if (verb) return verb;
}
}
return getToolLabel(toolName, seed);
}
// --- Bash verb extraction ---------------------------------------------------
// rm and chmod don't get cute paraphrases for obvious reasons.
const GIT_VERBS: Record<string, ToolLabel[]> = {
commit: [
{ present: 'Committing', past: 'Committed' },
{ present: 'Saving a snapshot', past: 'Saved a snapshot' },
{ present: 'Locking in changes', past: 'Locked in changes' },
{ present: 'Stamping it', past: 'Stamped it' },
],
push: [
{ present: 'Pushing to git', past: 'Pushed to git' },
{ present: 'Sending changes upstream', past: 'Sent changes upstream' },
],
pull: [
{ present: 'Pulling from git', past: 'Pulled from git' },
{ present: 'Grabbing latest', past: 'Grabbed latest' },
],
fetch: [{ present: 'Fetching from git', past: 'Fetched from git' }],
clone: [
{ present: 'Cloning repo', past: 'Cloned repo' },
{ present: 'Copying down the repo', past: 'Copied down the repo' },
],
add: [
{ present: 'Staging changes', past: 'Staged changes' },
{ present: 'Lining up changes', past: 'Lined up changes' },
{ present: 'Queueing changes', past: 'Queued changes' },
],
status: [
{ present: 'Checking git status', past: 'Checked git status' },
{ present: 'Peeking at git', past: 'Peeked at git' },
{ present: 'Glancing at git', past: 'Glanced at git' },
],
log: [
{ present: 'Reading git history', past: 'Read git history' },
{ present: 'Skimming git history', past: 'Skimmed git history' },
{ present: 'Flipping through history', past: 'Flipped through history' },
],
diff: [
{ present: 'Comparing changes', past: 'Compared changes' },
{ present: 'Eyeballing the diff', past: 'Eyeballed the diff' },
],
branch: [{ present: 'Listing branches', past: 'Listed branches' }],
checkout: [
{ present: 'Switching branches', past: 'Switched branches' },
{ present: 'Hopping branches', past: 'Hopped branches' },
],
switch: [
{ present: 'Switching branches', past: 'Switched branches' },
{ present: 'Hopping branches', past: 'Hopped branches' },
],
merge: [
{ present: 'Merging', past: 'Merged' },
{ present: 'Stitching it together', past: 'Stitched it together' },
],
rebase: [{ present: 'Rebasing', past: 'Rebased' }],
reset: [{ present: 'Resetting git', past: 'Reset git' }],
stash: [
{ present: 'Stashing', past: 'Stashed' },
{ present: 'Tucking away', past: 'Tucked away' },
],
tag: [
{ present: 'Tagging', past: 'Tagged' },
{ present: 'Marking it', past: 'Marked it' },
],
init: [{ present: 'Setting up git', past: 'Set up git' }],
remote: [{ present: 'Configuring git remote', past: 'Configured git remote' }],
};
function _pkgVerb(sub: string, seed?: string): ToolLabel {
if (['install', 'add', 'i'].includes(sub)) {
return _pick<ToolLabel>([
{ present: 'Installing packages', past: 'Installed packages' },
{ present: 'Pulling in packages', past: 'Pulled in packages' },
{ present: 'Grabbing packages', past: 'Grabbed packages' },
{ present: 'Adding packages', past: 'Added packages' },
], seed);
}
if (['uninstall', 'remove', 'rm'].includes(sub)) {
return { present: 'Removing packages', past: 'Removed packages' };
}
if (['update', 'upgrade', 'up'].includes(sub)) {
return _pick<ToolLabel>([
{ present: 'Updating packages', past: 'Updated packages' },
{ present: 'Bumping packages', past: 'Bumped packages' },
{ present: 'Refreshing packages', past: 'Refreshed packages' },
], seed);
}
if (['run', 'start', 'serve', 'dev'].includes(sub)) {
return _pick<ToolLabel>([
{ present: 'Running script', past: 'Ran script' },
{ present: 'Firing up script', past: 'Fired up script' },
{ present: 'Kicking off script', past: 'Kicked off script' },
], seed);
}
if (sub === 'test') {
return _pick<ToolLabel>([
{ present: 'Running tests', past: 'Ran tests' },
{ present: 'Putting code through tests', past: 'Put code through tests' },
{ present: 'Stress-testing code', past: 'Stress-tested code' },
], seed);
}
if (sub === 'build') {
return _pick<ToolLabel>([
{ present: 'Building', past: 'Built' },
{ present: 'Compiling', past: 'Compiled' },
{ present: 'Cooking up the build', past: 'Cooked up the build' },
{ present: 'Putting it together', past: 'Put it together' },
], seed);
}
return { present: 'Running command', past: 'Ran command' };
}
type BinEntry = ToolLabel[] | ((sub: string, seed?: string) => ToolLabel | null);
const BIN_VERBS: Record<string, BinEntry> = {
rm: [{ present: 'Deleting', past: 'Deleted' }],
rmdir: [{ present: 'Removing folder', past: 'Removed folder' }],
chmod: [{ present: 'Changing permissions', past: 'Changed permissions' }],
chown: [{ present: 'Changing owner', past: 'Changed owner' }],
killall: [{ present: 'Stopping process', past: 'Stopped process' }],
kill: [{ present: 'Stopping process', past: 'Stopped process' }],
mv: [
{ present: 'Moving', past: 'Moved' },
{ present: 'Shuffling', past: 'Shuffled' },
{ present: 'Relocating', past: 'Relocated' },
{ present: 'Sliding over', past: 'Slid over' },
],
cp: [
{ present: 'Copying', past: 'Copied' },
{ present: 'Duplicating', past: 'Duplicated' },
{ present: 'Cloning', past: 'Cloned' },
{ present: 'Mirroring', past: 'Mirrored' },
],
ln: [
{ present: 'Linking', past: 'Linked' },
{ present: 'Wiring up a link', past: 'Wired up a link' },
],
mkdir: [
{ present: 'Creating folder', past: 'Created folder' },
{ present: 'Spinning up a folder', past: 'Spun up a folder' },
{ present: 'Setting up a folder', past: 'Set up a folder' },
],
touch: [
{ present: 'Creating file', past: 'Created file' },
{ present: 'Spinning up a file', past: 'Spun up a file' },
],
cat: [
{ present: 'Reading', past: 'Read' },
{ present: 'Skimming', past: 'Skimmed' },
{ present: 'Glancing at', past: 'Glanced at' },
],
head: [
{ present: 'Reading the top of', past: 'Read the top of' },
{ present: 'Peeking at the top of', past: 'Peeked at the top of' },
],
tail: [
{ present: 'Reading the end of', past: 'Read the end of' },
{ present: 'Peeking at the end of', past: 'Peeked at the end of' },
],
less: [
{ present: 'Reading', past: 'Read' },
{ present: 'Skimming', past: 'Skimmed' },
],
more: [
{ present: 'Reading', past: 'Read' },
{ present: 'Skimming', past: 'Skimmed' },
],
ls: [
{ present: 'Listing folder', past: 'Listed folder' },
{ present: 'Peeking inside', past: 'Peeked inside' },
{ present: 'Poking around in', past: 'Poked around in' },
{ present: 'Scanning the folder', past: 'Scanned the folder' },
],
tree: [
{ present: 'Listing folder', past: 'Listed folder' },
{ present: 'Mapping the folder', past: 'Mapped the folder' },
],
pwd: [
{ present: 'Checking location', past: 'Checked location' },
{ present: 'Figuring out where I am', past: 'Figured out where I am' },
],
cd: [
{ present: 'Switching folder', past: 'Switched folder' },
{ present: 'Hopping over', past: 'Hopped over' },
],
find: [
{ present: 'Hunting for files', past: 'Hunted for files' },
{ present: 'Searching files', past: 'Searched files' },
{ present: 'Sniffing out files', past: 'Sniffed out files' },
],
grep: [
{ present: 'Searching files', past: 'Searched files' },
{ present: 'Combing through', past: 'Combed through' },
{ present: 'Digging through', past: 'Dug through' },
],
rg: [
{ present: 'Searching files', past: 'Searched files' },
{ present: 'Combing through', past: 'Combed through' },
{ present: 'Digging through', past: 'Dug through' },
],
ack: [
{ present: 'Searching files', past: 'Searched files' },
{ present: 'Combing through', past: 'Combed through' },
],
awk: [
{ present: 'Processing text', past: 'Processed text' },
{ present: 'Slicing text', past: 'Sliced text' },
],
sed: [
{ present: 'Editing text', past: 'Edited text' },
{ present: 'Find-and-replacing', past: 'Find-and-replaced' },
],
sort: [
{ present: 'Sorting', past: 'Sorted' },
{ present: 'Lining things up', past: 'Lined things up' },
],
uniq: [
{ present: 'Deduplicating', past: 'Deduplicated' },
{ present: 'Tidying duplicates', past: 'Tidied duplicates' },
],
wc: [
{ present: 'Counting', past: 'Counted' },
{ present: 'Tallying', past: 'Tallied' },
],
diff: [
{ present: 'Comparing files', past: 'Compared files' },
{ present: 'Eyeballing the diff', past: 'Eyeballed the diff' },
],
curl: [
{ present: 'Downloading', past: 'Downloaded' },
{ present: 'Pulling from the web', past: 'Pulled from the web' },
{ present: 'Grabbing from the web', past: 'Grabbed from the web' },
{ present: 'Fetching', past: 'Fetched' },
],
wget: [
{ present: 'Downloading', past: 'Downloaded' },
{ present: 'Pulling from the web', past: 'Pulled from the web' },
{ present: 'Grabbing from the web', past: 'Grabbed from the web' },
],
python: [
{ present: 'Running script', past: 'Ran script' },
{ present: 'Firing up Python', past: 'Fired up Python' },
{ present: 'Kicking off Python', past: 'Kicked off Python' },
],
python3: [
{ present: 'Running script', past: 'Ran script' },
{ present: 'Firing up Python', past: 'Fired up Python' },
{ present: 'Kicking off Python', past: 'Kicked off Python' },
],
node: [
{ present: 'Running script', past: 'Ran script' },
{ present: 'Firing up Node', past: 'Fired up Node' },
{ present: 'Kicking off Node', past: 'Kicked off Node' },
],
ruby: [
{ present: 'Running script', past: 'Ran script' },
{ present: 'Firing up Ruby', past: 'Fired up Ruby' },
],
go: [
{ present: 'Running Go', past: 'Ran Go' },
{ present: 'Firing up Go', past: 'Fired up Go' },
],
cargo: [
{ present: 'Running Cargo', past: 'Ran Cargo' },
{ present: 'Firing up Cargo', past: 'Fired up Cargo' },
],
java: [
{ present: 'Running Java', past: 'Ran Java' },
{ present: 'Firing up Java', past: 'Fired up Java' },
],
echo: [
{ present: 'Printing', past: 'Printed' },
{ present: 'Echoing', past: 'Echoed' },
{ present: 'Saying', past: 'Said' },
{ present: 'Outputting', past: 'Output' },
],
printf: [
{ present: 'Printing', past: 'Printed' },
{ present: 'Outputting', past: 'Output' },
],
make: [
{ present: 'Building', past: 'Built' },
{ present: 'Compiling', past: 'Compiled' },
{ present: 'Cooking up the build', past: 'Cooked up the build' },
{ present: 'Putting it together', past: 'Put it together' },
],
cmake: [
{ present: 'Building', past: 'Built' },
{ present: 'Compiling', past: 'Compiled' },
],
docker: [
{ present: 'Running Docker', past: 'Ran Docker' },
{ present: 'Firing up a container', past: 'Fired up a container' },
],
kubectl: [{ present: 'Running kubectl', past: 'Ran kubectl' }],
aws: [{ present: 'Running AWS CLI', past: 'Ran AWS CLI' }],
gcloud: [{ present: 'Running gcloud', past: 'Ran gcloud' }],
ssh: [
{ present: 'Connecting via SSH', past: 'Connected via SSH' },
{ present: 'Tunnelling in', past: 'Tunnelled in' },
],
scp: [
{ present: 'Copying remotely', past: 'Copied remotely' },
{ present: 'Beaming files over', past: 'Beamed files over' },
],
rsync: [
{ present: 'Syncing files', past: 'Synced files' },
{ present: 'Mirroring files', past: 'Mirrored files' },
{ present: 'Lining up files', past: 'Lined up files' },
],
tar: [
{ present: 'Archiving', past: 'Archived' },
{ present: 'Bundling up', past: 'Bundled up' },
],
zip: [
{ present: 'Archiving', past: 'Archived' },
{ present: 'Zipping up', past: 'Zipped up' },
],
unzip: [
{ present: 'Extracting', past: 'Extracted' },
{ present: 'Unpacking', past: 'Unpacked' },
],
open: [
{ present: 'Opening', past: 'Opened' },
{ present: 'Cracking open', past: 'Cracked open' },
],
ps: [
{ present: 'Listing processes', past: 'Listed processes' },
{ present: 'Checking what is running', past: 'Checked what is running' },
],
git: (sub: string, seed?: string) => {
const v = GIT_VERBS[sub];
if (v) return _pick<ToolLabel>(v, seed);
return { present: 'Running git', past: 'Ran git' };
},
npm: (sub: string, seed?: string) => _pkgVerb(sub, seed),
pnpm: (sub: string, seed?: string) => _pkgVerb(sub, seed),
yarn: (sub: string, seed?: string) => _pkgVerb(sub, seed),
bun: (sub: string, seed?: string) => _pkgVerb(sub, seed),
pip: (sub: string, seed?: string) => _pkgVerb(sub, seed),
pip3: (sub: string, seed?: string) => _pkgVerb(sub, seed),
brew: (sub: string, seed?: string) => _pkgVerb(sub, seed),
apt: (sub: string, seed?: string) => _pkgVerb(sub, seed),
'apt-get': (sub: string, seed?: string) => _pkgVerb(sub, seed),
};
function _bashVerb(rawCmd: string, seed?: string): ToolLabel | null {
const cmd = rawCmd.trim();
if (!cmd) return null;
const stripped = cmd.replace(/^(?:[A-Z_][A-Z0-9_]*=\S+\s+)+/, '');
const tokens = stripped.split(/\s+/);
if (!tokens.length) return null;
const firstRaw = tokens[0];
if (['sudo', 'time', 'nice', 'env'].includes(firstRaw) && tokens.length > 1) {
return _bashVerb(stripped.slice(firstRaw.length).trim(), seed);
}
const bin = (firstRaw.split('/').pop() || firstRaw).toLowerCase();
const sub = (tokens[1] || '').toLowerCase();
const entry = BIN_VERBS[bin];
if (!entry) return null;
if (typeof entry === 'function') return entry(sub, seed);
return _pick<ToolLabel>(entry, seed);
}
// --- Path / URL prettifiers ------------------------------------------------
export function prettyPath(p: string): string {
if (!p) return '';
const cleaned = p.replace(/[\\/]+$/, '');
const parts = cleaned.split(/[/\\]/);
return parts[parts.length - 1] || cleaned;
}
export function prettyUrl(u: string): string {
if (!u) return '';
try {
const parsed = new URL(u);
return parsed.host.replace(/^www\./, '');
} catch {
const noProto = u.replace(/^https?:\/\//, '').split(/[/?#]/)[0];
return noProto.slice(0, 60);
}
}
export function quoteQuery(q: string, max = 60): string {
if (!q) return '';
const trimmed = q.length > max ? q.slice(0, max - 1) + '…' : q;
return `"${trimmed}"`;
}
export function bashCommandDetail(rawCmd: string): string {
if (!rawCmd) return '';
const cmd = rawCmd.trim();
const stripped = cmd
.replace(/^(?:[A-Z_][A-Z0-9_]*=\S+\s+)+/, '')
.replace(/^(?:sudo|time|nice|env)\s+/, '');
const tokens = stripped.split(/\s+/);
const bin = (tokens[0] || '').split('/').pop() || '';
if (bin === 'git') {
const sub = (tokens[1] || '').toLowerCase();
if (['commit', 'status', 'log', 'diff', 'pull', 'push', 'fetch'].includes(sub)) return '';
return tokens[2] ? prettyPath(tokens[2]) : '';
}
if (['npm', 'pnpm', 'yarn', 'bun', 'pip', 'pip3', 'brew', 'apt', 'apt-get'].includes(bin)) {
return tokens[2] ? tokens.slice(2).filter((t) => !t.startsWith('-')).slice(0, 2).join(' ') : '';
}
const arg = tokens.slice(1).find((t) => !t.startsWith('-'));
if (!arg) return '';
if (arg.includes('/') || arg.includes('\\')) return prettyPath(arg);
return arg.length > 50 ? arg.slice(0, 47) + '…' : arg;
}
@@ -28,7 +28,7 @@ const Analytics: React.FC = () => {
</svg>
</Box>
<Typography sx={{ color: c.text.primary, fontSize: '1.1rem', fontWeight: 600, mb: 1 }}>
Analytics powered by PostHog
Your usage
</Typography>
<Typography sx={{ color: c.text.muted, fontSize: '0.85rem', lineHeight: 1.6, mb: 3, maxWidth: 500, mx: 'auto' }}>
Usage data is automatically collected sessions, costs, tool usage, model distribution, and task categories.
+2 -2
View File
@@ -380,9 +380,9 @@ export const CommandsContent: React.FC = () => {
</Box>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{atCommands.map((cmd) => (
{atCommands.map((cmd, i) => (
<Box
key={cmd.prefix}
key={`${cmd.prefix}::${cmd.source}::${i}`}
sx={{
display: 'flex',
alignItems: 'center',
+71 -24
View File
@@ -80,33 +80,78 @@ function fmtSeconds(seconds: number): string {
return `${hours}h ${minutes % 60}m`;
}
function getAgentWorkTime(messages: Array<{ role: string; timestamp: string }>, status: string): { total: number; last: number } {
let total = 0;
let last = 0;
for (let i = 0; i < messages.length; i++) {
const msg = messages[i];
if (msg.role === 'user') {
// Find the next assistant/system response
let endTime: number | null = null;
for (let j = i + 1; j < messages.length; j++) {
if (messages[j].role === 'assistant' || messages[j].role === 'system') {
endTime = new Date(messages[j].timestamp).getTime();
break;
}
}
if (endTime) {
const dur = Math.max(0, Math.floor((endTime - new Date(msg.timestamp).getTime()) / 1000));
total += dur;
last = dur;
} else if (status === 'running' || status === 'waiting_approval') {
// Currently processing this prompt
const dur = Math.max(0, Math.floor((Date.now() - new Date(msg.timestamp).getTime()) / 1000));
total += dur;
last = dur;
function getAgentWorkTime(
messages: Array<{ role: string; timestamp: string; elapsed_ms?: number; hidden?: boolean }>,
status: string,
): { total: number; last: number } {
// True wall-clock duration: how long the user actually waited, from
// their prompt to the LAST assistant/system message of that turn.
// Covers thinking + every tool call + assistant text generation +
// any subagent/MCP work — anything that consumed user attention.
//
// This is intentionally NOT the sum of `thinking.elapsed_ms` (which
// would cover only reasoning time and miss tool execution). The
// thinking pill in the chat already exposes reasoning-only as a
// distinct signal; the header timer's job is to answer "how long
// did this take?" which is a different question.
//
// For each user message we find the LAST adjacent assistant/system
// message before the next user message — that's the turn boundary.
// If the turn is still in flight (last user message has no assistant
// reply yet AND session is running/waiting), extrapolate to now so
// the timer ticks live.
//
// Hidden messages (auto-continuation prompts from MCPActivate, etc.)
// are skipped — they're system-internal turns the user didn't see
// and shouldn't be billed for.
const visible = messages.filter((m) => !m.hidden);
let totalMs = 0;
let lastMs = 0;
for (let i = 0; i < visible.length; i++) {
const msg = visible[i];
if (msg.role !== 'user') continue;
// Find the bounds of this turn: from this user message to just
// before the next user message (or end of array).
let nextUserIdx = visible.length;
for (let k = i + 1; k < visible.length; k++) {
if (visible[k].role === 'user') {
nextUserIdx = k;
break;
}
}
// Last assistant/system message before the next user message =
// turn end. Walk backwards from nextUserIdx to find it.
let turnEndMs: number | null = null;
for (let k = nextUserIdx - 1; k > i; k--) {
const r = visible[k].role;
if (r === 'assistant' || r === 'system') {
turnEndMs = new Date(visible[k].timestamp).getTime();
break;
}
}
if (turnEndMs == null) {
// No assistant reply yet for this turn. If the session is
// actively working, extrapolate to now so the header ticks.
// Otherwise (terminal session, no reply): contribute 0.
if (status === 'running' || status === 'waiting_approval') {
turnEndMs = Date.now();
} else {
continue;
}
}
const dur = Math.max(0, turnEndMs - new Date(msg.timestamp).getTime());
totalMs += dur;
lastMs = dur;
}
return { total, last };
return {
total: Math.max(0, Math.round(totalMs / 1000)),
last: Math.max(0, Math.round(lastMs / 1000)),
};
}
function summarizeToolInput(toolName: string, toolInput: Record<string, any>): string {
@@ -566,6 +611,8 @@ const AgentCard: React.FC<Props> = ({
}}
sx={{
position: 'relative',
// contain: streaming chat updates inside don't reflow the dashboard.
contain: 'layout style',
width: localResize ? activeW : Math.max(cardWidth, MIN_W),
height: localResize ? activeH : (expanded ? Math.max(EXPANDED_OVERLAY_H, cardHeight) : 'auto'),
bgcolor: c.bg.surface,
@@ -626,6 +626,8 @@ const BrowserCard: React.FC<Props> = ({
}}
sx={{
position: 'absolute',
// contain: webview repaints don't shake neighbor cards.
contain: 'layout style',
left: displayX,
top: displayY,
width: displayW,
+40 -17
View File
@@ -3,7 +3,7 @@ import { AnimatePresence, motion } from 'framer-motion';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import DashboardHeader from './DashboardHeader';
import { trackEvent } from '@/shared/analytics';
import { report } from '@/shared/serviceClient';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { store } from '@/shared/state/store';
import {
@@ -118,9 +118,13 @@ const DashboardInner: React.FC<DashboardProps> = ({ dashboardId, isActive = true
const expandNewChats = useAppSelector((state) => state.settings.data.expand_new_chats_in_dashboard);
const autoRevealSubAgents = useAppSelector((state) => state.settings.data.auto_reveal_sub_agents);
const outputs = useAppSelector((state) => state.outputs.items);
const outputsLoaded = useAppSelector((state) => state.outputs.loaded);
const glowingAgentCards = useAppSelector((state) => state.dashboardLayout.glowingAgentCards);
const glowingBrowserCards = useAppSelector((state) => state.dashboardLayout.glowingBrowserCards);
const sessionList = Object.values(sessions);
// sessions is the top-level dict; useMemo on its identity so sessionList
// is stable when sessions hasn't actually changed (RTK only swaps the dict
// ref when one of its values changes, so this is the right granularity).
const sessionList = useMemo(() => Object.values(sessions), [sessions]);
const contentBounds = useMemo(() => {
const allRects = [
@@ -297,7 +301,7 @@ const DashboardInner: React.FC<DashboardProps> = ({ dashboardId, isActive = true
}, [tickEdgePan]);
const handleCardDragEnd = useCallback((dx: number, dy: number, didDrag: boolean) => {
if (didDrag) trackEvent('dashboard.card_dragged');
if (didDrag) report('dashboard', 'card_dragged');
stopEdgePan();
if (isMultiDragRef.current && didDrag) {
const items = selection.selectedArray()
@@ -339,7 +343,7 @@ const DashboardInner: React.FC<DashboardProps> = ({ dashboardId, isActive = true
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleCardSelect = useCallback((id: string, type: CardType, shiftKey: boolean) => {
trackEvent('dashboard.card_clicked', { card_type: type, shift: shiftKey });
report('dashboard', 'card_clicked', { card_type: type, shift: shiftKey });
if (shiftKey) {
selection.selectCard(id, type, true);
return;
@@ -429,13 +433,13 @@ const DashboardInner: React.FC<DashboardProps> = ({ dashboardId, isActive = true
const handleViewportDoubleClick = useCallback((e: React.MouseEvent) => {
if (e.button !== 0) return;
if (isCardTarget(e.target, e.currentTarget)) return;
trackEvent('dashboard.canvas_double_clicked');
report('dashboard', 'canvas_double_clicked');
canvas.actions.fitToView();
}, [canvas.actions]);
// Double-click a card → always expand + center + zoom (cancels pending collapse from single-click)
const handleCardDoubleClick = useCallback((id: string, type: CardType) => {
trackEvent('dashboard.card_double_clicked', { card_type: type });
report('dashboard', 'card_double_clicked', { card_type: type });
if (clickTimerRef.current) {
clearTimeout(clickTimerRef.current);
clickTimerRef.current = null;
@@ -456,9 +460,9 @@ const DashboardInner: React.FC<DashboardProps> = ({ dashboardId, isActive = true
useEffect(() => {
if (!dashboardId) return;
const startTime = Date.now();
trackEvent('dashboard.opened', { dashboard_id: dashboardId });
report('dashboard', 'opened', { dashboard_id: dashboardId });
return () => {
trackEvent('dashboard.closed', {
report('dashboard', 'closed', {
dashboard_id: dashboardId,
time_spent_seconds: Math.round((Date.now() - startTime) / 1000),
});
@@ -550,7 +554,13 @@ const DashboardInner: React.FC<DashboardProps> = ({ dashboardId, isActive = true
const hasCards = Object.keys(allCards.cards).length > 0
|| Object.keys(allCards.viewCards).length > 0
|| Object.keys(allCards.browserCards).length > 0;
if (!hasCards) return;
if (!hasCards) {
// Empty dashboard — queue a thumbnail clear (sent on exit alongside
// the existing capture-update path). Backend treats '' as "set to
// empty"; null in PUT body means "don't update".
pendingThumbnailRef.current = '';
return;
}
captureDashboardThumbnail(viewportEl, contentEl, allCards)
.then((thumbnail) => { if (thumbnail) pendingThumbnailRef.current = thumbnail; })
.catch(() => {});
@@ -570,7 +580,8 @@ const DashboardInner: React.FC<DashboardProps> = ({ dashboardId, isActive = true
const exitingId = dashboardId;
return () => {
const thumbnail = pendingThumbnailRef.current;
if (thumbnail) {
// null = no pending change; '' = pending clear; other = pending update.
if (thumbnail !== null) {
store.dispatch(updateDashboardThumbnail({ id: exitingId, thumbnail }));
pendingThumbnailRef.current = null;
}
@@ -652,6 +663,18 @@ const DashboardInner: React.FC<DashboardProps> = ({ dashboardId, isActive = true
dispatch(reconcileSessions({ sessionIds: dashboardSessionIds, expandedSessionIds }));
}, [sessions, layoutInitialized, dispatch, dashboardId, expandedSessionIds]);
// Prune orphan view cards whose underlying output was deleted (e.g. via
// the Views page). Without this, the layout entry persists in the
// minimap and contentBounds even though DashboardViewCard renders
// nothing. Gated on outputsLoaded so we don't wipe valid cards during
// the brief window between fetchLayout returning and outputs finishing.
useEffect(() => {
if (!layoutInitialized || !outputsLoaded) return;
for (const outputId of Object.keys(viewCards)) {
if (!outputs[outputId]) dispatch(removeViewCard(outputId));
}
}, [layoutInitialized, outputsLoaded, viewCards, outputs, dispatch]);
// ---- Auto-reveal / collapse / unreveal sub-agent cards ----
const autoRevealedRef = useRef(new Set<string>());
const prevSubStatusRef = useRef<Record<string, string>>({});
@@ -854,7 +877,7 @@ const DashboardInner: React.FC<DashboardProps> = ({ dashboardId, isActive = true
if (tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement)?.isContentEditable) return;
e.preventDefault();
setSearchPaletteOpen(true);
trackEvent('dashboard.search_opened');
report('dashboard', 'search_opened');
};
window.addEventListener('keydown', handleSearch);
return () => window.removeEventListener('keydown', handleSearch);
@@ -1112,7 +1135,7 @@ const DashboardInner: React.FC<DashboardProps> = ({ dashboardId, isActive = true
}
// Expand + navigate to target + bring to front
trackEvent('dashboard.arrow_navigated', { direction, from_card: currentFocused, to_card: target.id });
report('dashboard', 'arrow_navigated', { direction, from_card: currentFocused, to_card: target.id });
if (target.type === 'agent') {
dispatch(expandSession(target.id));
}
@@ -1194,7 +1217,7 @@ const DashboardInner: React.FC<DashboardProps> = ({ dashboardId, isActive = true
selectedBrowserIds?: string[],
) => {
setToolbarOpen(false);
trackEvent('dashboard.agent_created', { mode, model, has_images: !!images?.length, has_context: !!contextPaths?.length, has_browser: !!selectedBrowserIds?.length });
report('dashboard', 'agent_created', { mode, model, has_images: !!images?.length, has_context: !!contextPaths?.length, has_browser: !!selectedBrowserIds?.length });
const draftId = `draft-${Date.now().toString(36)}`;
@@ -1298,7 +1321,7 @@ const DashboardInner: React.FC<DashboardProps> = ({ dashboardId, isActive = true
}, [dispatch, expandedSessionIds, canvas.actions, handleHighlightCard]);
const handleAddBrowser = useCallback(() => {
trackEvent('dashboard.browser_added');
report('dashboard', 'browser_added');
const prevIds = new Set(Object.keys(store.getState().dashboardLayout.browserCards));
dispatch(addBrowserCard({ url: browserHomepage, expandedSessionIds }));
setTimeout(() => {
@@ -1313,7 +1336,7 @@ const DashboardInner: React.FC<DashboardProps> = ({ dashboardId, isActive = true
}, [dispatch, browserHomepage, expandedSessionIds, canvas.actions, handleHighlightCard]);
const handleAddNote = useCallback(() => {
trackEvent('dashboard.note_added');
report('dashboard', 'note_added');
const prevIds = new Set(Object.keys(store.getState().dashboardLayout.notes));
dispatch(addNote({ expandedSessionIds }));
setTimeout(() => {
@@ -1352,7 +1375,7 @@ const DashboardInner: React.FC<DashboardProps> = ({ dashboardId, isActive = true
// Context-aware fit: if a card is selected, zoom to it; otherwise fit all
const handleFitToView = useCallback(() => {
trackEvent('dashboard.fit_to_view', { has_selection: selection.selectedIds.size > 0 });
report('dashboard', 'fit_to_view', { has_selection: selection.selectedIds.size > 0 });
if (selection.selectedIds.size === 1) {
const [[id, type]] = selection.selectedIds;
const rect = getCardRect(id, type);
@@ -1365,7 +1388,7 @@ const DashboardInner: React.FC<DashboardProps> = ({ dashboardId, isActive = true
}, [selection.selectedIds, getCardRect, canvas.actions]);
const handleTidy = useCallback(() => {
trackEvent('dashboard.tidy_layout');
report('dashboard', 'tidy_layout');
const currentExpanded = store.getState().agents.expandedSessionIds;
dispatch(tidyLayout({ expandedSessionIds: currentExpanded }));
@@ -309,4 +309,4 @@ const ItemRow: React.FC<{
</Box>
);
export default DashboardHeader;
export default React.memo(DashboardHeader);
@@ -19,6 +19,8 @@ import { useElementSelection } from '@/app/components/ElementSelectionContext';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { searchHistory, clearHistorySearch } from '@/shared/state/agentsSlice';
import { updateSettings, AppSettings } from '@/shared/state/settingsSlice';
import { store } from '@/shared/state/store';
import type { ClaudeTokens } from '@/shared/styles/claudeTokens';
import type { Output } from '@/shared/state/outputsSlice';
@@ -127,6 +129,32 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
}
prevInputOpen.current = inputOpen;
}, [inputOpen, settingsLoaded, defaultMode, defaultModel, defaultThinkingLevel]);
// Picking a model/mode/thinking-level in the toolbar writes through to
// the global default. Without this, the reopen-reset effect above
// would snap back to the old default the next time the user opens the
// toolbar, ignoring what they last picked.
const promoteToDefault = useCallback(<K extends keyof AppSettings>(key: K, value: AppSettings[K]) => {
const current = store.getState().settings;
if (!current.loaded) return;
if (current.data[key] === value) return;
dispatch(updateSettings({ ...current.data, [key]: value }));
}, [dispatch]);
const handleModeChange = useCallback((newMode: string) => {
setMode(newMode);
promoteToDefault('default_mode', newMode);
}, [promoteToDefault]);
const handleModelChange = useCallback((newModel: string) => {
setModel(newModel);
promoteToDefault('default_model', newModel);
}, [promoteToDefault]);
const handleThinkingLevelChange = useCallback((level: 'off' | 'low' | 'medium' | 'high' | 'auto') => {
setThinkingLevel(level);
promoteToDefault('default_thinking_level', level);
}, [promoteToDefault]);
const [viewPickerOpen, setViewPickerOpen] = useState(false);
const [viewSearch, setViewSearch] = useState('');
const [historyOpen, setHistoryOpen] = useState(false);
@@ -384,14 +412,14 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
<ChatInput
onSend={handleSend}
mode={mode}
onModeChange={setMode}
onModeChange={handleModeChange}
model={model}
onModelChange={setModel}
onModelChange={handleModelChange}
embedded
autoFocus
sessionId={TOOLBAR_OWNER_ID}
thinkingLevel={thinkingLevel}
onThinkingLevelChange={setThinkingLevel}
onThinkingLevelChange={handleThinkingLevelChange}
/>
</div>
) : historyOpen ? (
@@ -307,6 +307,8 @@ const DashboardViewCard: React.FC<Props> = ({
}}
sx={{
position: 'absolute',
// contain: iframe app repaints don't shake the rest of the dashboard.
contain: 'layout style',
left: displayX,
top: displayY,
width: displayW,
@@ -267,6 +267,8 @@ const NoteCard: React.FC<Props> = ({
top: displayY,
width: displayW,
height: displayH,
// contain: reflow inside this note doesn't shake the dashboard.
contain: 'layout style',
borderRadius: `${c.radius.md}px`,
bgcolor: palette.bg,
border: isHighlighted
@@ -418,4 +420,4 @@ const NoteCard: React.FC<Props> = ({
);
};
export default NoteCard;
export default React.memo(NoteCard);
@@ -13,6 +13,7 @@ import ListItemText from '@mui/material/ListItemText';
import AddIcon from '@mui/icons-material/Add';
import DashboardIcon from '@mui/icons-material/Dashboard';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import { Skeleton } from '@/app/components/Loading';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import EditIcon from '@mui/icons-material/Edit';
import MoreVertIcon from '@mui/icons-material/MoreVert';
@@ -107,8 +108,9 @@ const DashboardSelection: React.FC = () => {
const handleRenameSubmit = (id: string) => {
const trimmed = renameValue.trim();
if (trimmed && trimmed !== items[id]?.name) {
dispatch(renameDashboard({ id, name: trimmed }));
const previousName = items[id]?.name;
if (trimmed && trimmed !== previousName) {
dispatch(renameDashboard({ id, name: trimmed, previousName }));
}
setRenamingId(null);
};
@@ -174,9 +176,11 @@ const DashboardSelection: React.FC = () => {
</Box>
{loading ? (
<Typography sx={{ color: c.text.muted, textAlign: 'center', py: 8 }}>
Loading...
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, py: 4 }}>
{[0, 1, 2].map((i) => (
<Skeleton key={i} variant="card" height={64} />
))}
</Box>
) : dashboards.length === 0 ? (
<Box sx={{ textAlign: 'center', py: 10, color: c.text.muted }}>
<Typography sx={{ fontSize: '1.1rem', mb: 1 }}>
+5 -2
View File
@@ -14,6 +14,7 @@ import IconButton from '@mui/material/IconButton';
import Chip from '@mui/material/Chip';
import CircularProgress from '@mui/material/CircularProgress';
import Tooltip from '@mui/material/Tooltip';
import { Skeleton } from '@/app/components/Loading';
import FormControl from '@mui/material/FormControl';
import InputLabel from '@mui/material/InputLabel';
import Select from '@mui/material/Select';
@@ -242,8 +243,10 @@ const Modes: React.FC = () => {
</Box>
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}>
<CircularProgress sx={{ color: c.accent.primary }} />
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 2, mt: 1 }}>
{[0, 1, 2, 3, 4, 5].map((i) => (
<Skeleton key={i} variant="card" height={120} />
))}
</Box>
) : modes.length === 0 ? (
<Box
+5 -5
View File
@@ -1,5 +1,5 @@
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { trackEvent } from '@/shared/analytics';
import { report } from '@/shared/serviceClient';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import TextField from '@mui/material/TextField';
@@ -199,7 +199,7 @@ const OpenSwarmProCard: React.FC = () => {
const [status, setStatus] = useState<OpenSwarmProStatus | null>(null);
const [busy, setBusy] = useState<'manage' | 'disconnect' | null>(null);
// Track which usage thresholds we've already fired this session so the
// event doesn't spam PostHog every 30s while the counter hovers past
// event doesn't spam every 30s while the counter hovers past
// the threshold. Reset implicitly on page unmount (settings close).
const firedUsageThresholds = useRef<Set<number>>(new Set());
@@ -219,7 +219,7 @@ const OpenSwarmProCard: React.FC = () => {
}, [refresh]);
const handleManage = async () => {
trackEvent('subscription.manage_clicked', {
report('subscription', 'manage_clicked', {
plan: status?.plan ?? null,
status: status?.status ?? null,
});
@@ -257,7 +257,7 @@ const OpenSwarmProCard: React.FC = () => {
for (const threshold of [80, 90] as const) {
if (current >= threshold && !firedUsageThresholds.current.has(threshold)) {
firedUsageThresholds.current.add(threshold);
trackEvent('subscription.usage_warning', {
report('subscription', 'usage_warning', {
plan: status.plan ?? null,
utilization: current,
threshold,
@@ -922,7 +922,7 @@ const UsageStats: React.FC = () => {
const [stats, setStats] = useState<any>(null);
useEffect(() => {
fetch(`${API_BASE}/analytics/usage-summary`)
fetch(`${API_BASE}/service/usage-summary`)
.then(r => r.json())
.then(setStats)
.catch(() => {});
+10 -3
View File
@@ -87,6 +87,7 @@ import {
updateOutput,
Output,
} from '@/shared/state/outputsSlice';
import { Skeleton } from '@/app/components/Loading';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { API_BASE } from '@/shared/config';
@@ -1384,7 +1385,11 @@ const Tools: React.FC = () => {
</Box>
<Collapse in={customSectionOpen}>
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 6 }}><CircularProgress sx={{ color: c.accent.primary }} size={28} /></Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, pl: 1, mt: 1 }}>
{[0, 1, 2, 3].map((i) => (
<Skeleton key={i} variant="card" height={72} />
))}
</Box>
) : (tools.length === 0 && uninstalledIntegrations.length === 0) ? (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', py: 6, color: c.text.ghost, gap: 1.5 }}>
<BuildIcon sx={{ fontSize: 40, opacity: 0.3 }} />
@@ -1923,8 +1928,10 @@ const Tools: React.FC = () => {
</Box>
{regLoading && regServers.length === 0 ? (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', flex: 1 }}>
<CircularProgress sx={{ color: c.accent.primary }} size={28} />
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25, flex: 1, py: 1 }}>
{[0, 1, 2, 3, 4, 5].map((i) => (
<Skeleton key={i} variant="card" height={56} />
))}
</Box>
) : regServers.length === 0 ? (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', flex: 1, color: c.text.ghost, gap: 1.5 }}>
+6 -3
View File
@@ -8,6 +8,7 @@ import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { fetchOutputs, deleteOutput, Output } from '@/shared/state/outputsSlice';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import ViewCard from './ViewCard';
import { Skeleton } from '@/app/components/Loading';
import ViewEditor from './ViewEditor';
import ViewRunDialog from './ViewRunDialog';
@@ -115,9 +116,11 @@ const Views: React.FC = () => {
{/* Card grid */}
{loading ? (
<Typography sx={{ color: c.text.muted, textAlign: 'center', py: 8 }}>
Loading...
</Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', gap: 2, py: 2 }}>
{[0, 1, 2, 3, 4, 5].map((i) => (
<Skeleton key={i} variant="card" height={140} />
))}
</Box>
) : outputs.length === 0 ? (
<Box
sx={{
+6 -1
View File
@@ -1,6 +1,7 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import Main from './app/Main';
import ErrorBoundary from './app/components/ErrorBoundary';
import { ensureAuthToken } from './shared/config';
// Resolve the per-install auth token from Electron BEFORE first render
@@ -19,6 +20,10 @@ async function bootstrap() {
]);
} catch {}
const root = document.getElementById('root')!;
createRoot(root).render(<Main />);
createRoot(root).render(
<ErrorBoundary scope="root">
<Main />
</ErrorBoundary>
);
}
bootstrap();
-26
View File
@@ -1,26 +0,0 @@
import { API_BASE } from './config';
let _lastAction = '';
let _lastPage = '';
let _appStartTime = Date.now();
export function trackEvent(eventType: string, properties?: Record<string, any>, useBeacon = false) {
_lastAction = eventType;
_lastPage = window.location.hash || window.location.pathname;
const body = JSON.stringify({ event_type: eventType, properties });
if (useBeacon && navigator.sendBeacon) {
// sendBeacon is guaranteed to complete even during page unload
navigator.sendBeacon(`${API_BASE}/analytics/event`, new Blob([body], { type: 'application/json' }));
} else {
fetch(`${API_BASE}/analytics/event`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
}).catch(() => {});
}
}
export function getLastAction() { return _lastAction; }
export function getLastPage() { return _lastPage; }
export function getTimeSpent() { return Math.round((Date.now() - _appStartTime) / 1000); }
@@ -13,22 +13,15 @@ export interface ClipboardCard {
}
let clipboardCards: ClipboardCard[] = [];
let clipboardTimestamp = 0;
export function setClipboardCards(cards: ClipboardCard[]): void {
clipboardCards = cards;
clipboardTimestamp = Date.now();
}
export function getClipboardCards(): ClipboardCard[] {
return clipboardCards;
}
export function getClipboardTimestamp(): number {
return clipboardTimestamp;
}
export function clearClipboard(): void {
clipboardCards = [];
clipboardTimestamp = 0;
}
+7 -7
View File
@@ -4,7 +4,7 @@ import { activateSubscription } from '@/shared/state/settingsSlice';
import { fetchModels } from '@/shared/state/modelsSlice';
import { fetchTools } from '@/shared/state/toolsSlice';
import { API_BASE } from '@/shared/config';
import { trackEvent } from '@/shared/analytics';
import { report } from '@/shared/serviceClient';
// Listens for openswarm://auth?token=...&plan=...&expires=... URLs coming
// from the Electron main process via window.openswarm.onAuthUrl. Parses the
@@ -37,7 +37,7 @@ export function useDeepLink(): void {
const plan = url.searchParams.get('plan');
const expires = url.searchParams.get('expires');
trackEvent('subscription.deep_link_received', {
report('subscription', 'deep_link_received', {
plan: plan ?? 'unknown',
});
@@ -50,14 +50,14 @@ export function useDeepLink(): void {
)
.unwrap()
.then((res) => {
trackEvent('subscription.activated', { plan: res.plan });
report('subscription', 'activated', { plan: res.plan });
// Re-fetch the model list so the Claude models (via OpenSwarm
// Pro proxy) show up in the chat picker right away.
dispatch(fetchModels());
})
.catch((err) => {
console.error('[deep-link] Activation failed:', err);
trackEvent('subscription.activation_failed', {
report('subscription', 'activation_failed', {
message: String(err).slice(0, 120),
});
});
@@ -86,7 +86,7 @@ export function useDeepLink(): void {
return;
}
trackEvent('oauth.deep_link_received', { provider: url.pathname.split('/')[1] || 'unknown' });
report('oauth', 'deep_link_received', { provider: url.pathname.split('/')[1] || 'unknown' });
const resp = await fetch(`${API_BASE}/tools/oauth/claim`, {
method: 'POST',
@@ -96,10 +96,10 @@ export function useDeepLink(): void {
if (!resp.ok) {
const text = await resp.text();
console.error('[deep-link] OAuth claim failed:', resp.status, text);
trackEvent('oauth.claim_failed', { status: resp.status });
report('oauth', 'claim_failed', { status: resp.status });
return;
}
trackEvent('oauth.claim_succeeded');
report('oauth', 'claim_succeeded');
// Refresh tools so the UI reflects the newly-connected tool.
dispatch(fetchTools());
} catch (e) {
@@ -0,0 +1,44 @@
// Mounts a single global listener that records each user interaction
// timestamp into Redux. One installer per app — call from Main.tsx after
// the store is provided.
//
// Debounces at 1-second granularity so we don't spam Redux on every
// keystroke. Coarse enough for "idle dim after N minutes" UX; fine enough
// that the timestamp on session close is accurate to the second.
import { useEffect } from 'react';
import { useAppDispatch } from '@/shared/hooks';
import { interactionRecorded } from '@/shared/state/interactionSlice';
const DEBOUNCE_MS = 1000;
export function useInteractionHeartbeat(): void {
const dispatch = useAppDispatch();
useEffect(() => {
if (typeof window === 'undefined') return;
let lastDispatched = 0;
const onInteract = () => {
const now = Date.now();
if (now - lastDispatched < DEBOUNCE_MS) return;
lastDispatched = now;
dispatch(interactionRecorded({ at: now }));
};
const opts: AddEventListenerOptions = { passive: true, capture: true };
window.addEventListener('keydown', onInteract, opts);
window.addEventListener('mousedown', onInteract, opts);
window.addEventListener('scroll', onInteract, opts);
window.addEventListener('wheel', onInteract, opts);
window.addEventListener('touchstart', onInteract, opts);
return () => {
window.removeEventListener('keydown', onInteract, opts);
window.removeEventListener('mousedown', onInteract, opts);
window.removeEventListener('scroll', onInteract, opts);
window.removeEventListener('wheel', onInteract, opts);
window.removeEventListener('touchstart', onInteract, opts);
};
}, [dispatch]);
}
@@ -0,0 +1,50 @@
import { useSyncExternalStore } from 'react';
const QUERY = '(prefers-reduced-motion: reduce)';
function subscribe(callback: () => void): () => void {
if (typeof window === 'undefined' || !window.matchMedia) return () => {};
const mql = window.matchMedia(QUERY);
// Modern + legacy event names both supported.
mql.addEventListener('change', callback);
return () => mql.removeEventListener('change', callback);
}
function getSnapshot(): boolean {
if (typeof window === 'undefined' || !window.matchMedia) return false;
return window.matchMedia(QUERY).matches;
}
function getServerSnapshot(): boolean {
return false;
}
/**
* True when the OS-level "Reduce motion" preference is on.
* Mac: System Settings Accessibility Display Reduce Motion.
* Windows: Settings Ease of Access Display Show animations.
*
* Reactive flips immediately if the user toggles the OS setting
* mid-session (rare but supported).
*/
export function useReducedMotion(): boolean {
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}
/**
* Convenience: returns 0 when reduced-motion is on, otherwise the supplied
* duration. Use inline at animation sites:
*
* const dur = useMotionDuration(DURATION_MS.quick);
* <Fade timeout={dur}>...</Fade>
*
* For animations that convey causality (modal open, drawer slide), prefer a
* tiny non-zero floor so the user still perceives the transition:
*
* const dur = useMotionDuration(DURATION_MS.standard, { floor: 40 });
*/
export function useMotionDuration(ms: number, opts: { floor?: number } = {}): number {
const reduced = useReducedMotion();
if (!reduced) return ms;
return opts.floor ?? 0;
}
@@ -0,0 +1,35 @@
// Route-change tracker.
//
// Reports a `nav.route_changed` event on every React Router location
// change so the cloud can aggregate visits per route. Reuses the
// existing report() surface — no new outbound paths added. The desktop
// just sends the path; the cloud counts.
//
// Mount inside a Router (must be a child of HashRouter / BrowserRouter)
// so useLocation() resolves.
import { useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';
import { report } from '@/shared/serviceClient';
export function useRouteTracker(): void {
const location = useLocation();
// Skip the very first render — the App opens at "/" and we don't want
// to report a phantom navigation that didn't happen.
const skippedFirst = useRef(false);
const lastPath = useRef<string>('');
useEffect(() => {
const path = location.hash || location.pathname;
if (!skippedFirst.current) {
skippedFirst.current = true;
lastPath.current = path;
return;
}
if (path === lastPath.current) return;
lastPath.current = path;
// The path is a route name (e.g. /dashboard, /settings) — never the
// full URL. No query strings, no hash fragments beyond the route id.
report('nav', 'route_changed', { path });
}, [location.hash, location.pathname]);
}
+110
View File
@@ -0,0 +1,110 @@
// Operational state sync (frontend half).
//
// Single function: sync(data). Ships whatever object the caller has.
// The cloud determines what it means. No event names, no labels,
// no analytics vocabulary. A dev sees "we sync app state."
import { API_BASE } from './config';
let _lastTs = Date.now();
let _appStart = Date.now();
const _queue: Record<string, unknown>[] = [];
let _flushTimer: ReturnType<typeof setTimeout> | null = null;
// Bounded ring buffer of the most recent report() calls. Lets components
// (notably ErrorBoundary) attach a "what was the user doing right before
// this broke" context as a property on their own report — no extra
// outbound traffic, no extra events.
const _RECENT_CAP = 20;
const _recentReports: Array<{ s: string; a: string; ts: number }> = [];
function _record(surface: string, action: string): void {
_recentReports.push({ s: surface, a: action, ts: Date.now() });
if (_recentReports.length > _RECENT_CAP) {
_recentReports.splice(0, _recentReports.length - _RECENT_CAP);
}
}
/**
* Snapshot the most recent N report() entries. Used by error-handling
* paths to include "trail of breadcrumbs" context with their own report.
*/
export function getRecentActions(limit = 10): Array<{ s: string; a: string; ms_ago: number }> {
const now = Date.now();
const slice = _recentReports.slice(-Math.max(1, Math.min(limit, _RECENT_CAP)));
return slice.map((r) => ({ s: r.s, a: r.a, ms_ago: now - r.ts }));
}
function _flush(): void {
if (_queue.length === 0) return;
const batch = _queue.splice(0);
for (const d of batch) {
const body = JSON.stringify(d);
fetch(`${API_BASE}/service/submit`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
}).catch(() => {});
}
}
export function sync(data: Record<string, unknown> = {}, opts: { immediate?: boolean } = {}): void {
_lastTs = Date.now();
if (opts.immediate) {
_queue.push(data);
_flush();
return;
}
_queue.push(data);
if (_flushTimer == null) {
_flushTimer = setTimeout(() => {
_flushTimer = null;
_flush();
}, 1000);
}
}
/**
* Compact ship-an-event helper. Produces the same wire shape as `sync()`
* `{ s: surface, a: action, p: props }` but reads as a "report a UI
* surface event" verb in caller code rather than a free-form state dump.
*
* The cloud reads (surface, action) tuples from the opaque payload and
* decides what they mean. The desktop never names what it's reporting.
*/
export function report(
surface: string,
action: string,
props?: Record<string, unknown>,
opts: { immediate?: boolean } = {},
): void {
_record(surface, action);
sync({ s: surface, a: action, p: props || {} }, opts);
}
export function getSessionTraceState(): {
appStartTs: number;
lastTs: number;
currentPage: string;
} {
return {
appStartTs: _appStart,
lastTs: _lastTs,
currentPage: typeof window === 'undefined' ? '' : (window.location.hash || window.location.pathname),
};
}
export function _resetForTest(): void {
_queue.length = 0;
if (_flushTimer != null) {
clearTimeout(_flushTimer);
_flushTimer = null;
}
_appStart = Date.now();
_lastTs = _appStart;
_recentReports.length = 0;
}
const serviceClient = { sync, report, getSessionTraceState, getRecentActions };
export default serviceClient;
+7
View File
@@ -30,6 +30,13 @@ export interface AgentMessage {
// survives reload instead of decaying to "Thoughts".
elapsed_ms?: number;
tokens?: number;
// Server-stamped input-side token count for the turn (fresh
// input + cache_creation + cache_read). Populated on thinking
// messages so the pill can show "Thought for Ns · M in / K out"
// — which is the only honest answer to "how big was this turn".
input_tokens?: number;
// tool count drives the "3 tools used" segment on the thinking pill.
tool_count?: number;
}
export interface ApprovalRequest {
@@ -1,82 +0,0 @@
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import { API_BASE } from '@/shared/config';
const ANALYTICS_API = `${API_BASE}/analytics`;
export interface UsageSummary {
total_sessions: number;
total_cost_usd: number;
total_messages: number;
total_tool_calls: number;
avg_duration_seconds: number;
avg_cost_per_session: number;
completion_rate: number;
models_used: Record<string, number>;
providers_used: Record<string, number>;
top_tools: Record<string, number>;
status_breakdown: Record<string, number>;
// 9Router enrichment
total_prompt_tokens: number;
total_completion_tokens: number;
cost_by_model: Record<string, { cost: number; requests: number; prompt_tokens: number; completion_tokens: number }>;
cost_by_provider: Record<string, { cost: number; requests: number }>;
cost_source: ' 9router' | 'sdk' | 'none';
nine_router_available: boolean;
total_requests: number;
}
export interface CostBreakdown {
available: boolean;
period: string;
total_cost: number;
total_requests: number;
total_prompt_tokens: number;
total_completion_tokens: number;
by_model: Record<string, any>;
by_provider: Record<string, any>;
}
interface AnalyticsState {
summary: UsageSummary | null;
costBreakdown: CostBreakdown | null;
loading: boolean;
}
const initialState: AnalyticsState = {
summary: null,
costBreakdown: null,
loading: false,
};
export const fetchAnalyticsSummary = createAsyncThunk('analytics/fetchSummary', async () => {
const res = await fetch(`${ANALYTICS_API}/usage-summary`);
return (await res.json()) as UsageSummary;
});
export const fetchCostBreakdown = createAsyncThunk(
'analytics/fetchCostBreakdown',
async (period: string = '7d') => {
const res = await fetch(`${ANALYTICS_API}/cost-breakdown?period=${period}`);
return (await res.json()) as CostBreakdown;
},
);
const analyticsSlice = createSlice({
name: 'analytics',
initialState,
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchAnalyticsSummary.pending, (state) => { state.loading = true; })
.addCase(fetchAnalyticsSummary.fulfilled, (state, action) => {
state.loading = false;
state.summary = action.payload;
})
.addCase(fetchAnalyticsSummary.rejected, (state) => { state.loading = false; })
.addCase(fetchCostBreakdown.fulfilled, (state, action) => {
state.costBreakdown = action.payload;
});
},
});
export default analyticsSlice.reducer;
+18 -1
View File
@@ -42,12 +42,13 @@ export const createDashboard = createAsyncThunk(
export const renameDashboard = createAsyncThunk(
'dashboards/rename',
async ({ id, name }: { id: string; name: string }) => {
async ({ id, name }: { id: string; name: string; previousName?: string }) => {
const res = await fetch(`${DASHBOARDS_API}/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
if (!res.ok) throw new Error(`rename failed: ${res.status}`);
return (await res.json()) as Dashboard;
},
);
@@ -116,6 +117,16 @@ const dashboardsSlice = createSlice({
.addCase(createDashboard.fulfilled, (state, action) => {
state.items[action.payload.id] = action.payload;
})
// Optimistic: update name immediately on dispatch so the sidebar
// entry / picker label swaps with no perceptible lag. Server confirms
// on .fulfilled (rare correction); .rejected rolls back to previousName.
.addCase(renameDashboard.pending, (state, action) => {
const { id, name } = action.meta.arg;
if (state.items[id]) {
state.items[id].name = name;
state.items[id].auto_named = false;
}
})
.addCase(renameDashboard.fulfilled, (state, action) => {
const d = action.payload;
if (state.items[d.id]) {
@@ -127,6 +138,12 @@ const dashboardsSlice = createSlice({
};
}
})
.addCase(renameDashboard.rejected, (state, action) => {
const { id, previousName } = action.meta.arg;
if (state.items[id] && previousName !== undefined) {
state.items[id].name = previousName;
}
})
.addCase(deleteDashboard.fulfilled, (state, action) => {
delete state.items[action.payload];
})
@@ -0,0 +1,47 @@
// Tracks the timestamp of the most recent user interaction in the app
// (keystrokes, clicks, scrolls). Drives:
// - Idle UI dimming
// - "Are you still there?" snooze prompts
// - Session sync — last interaction timestamp piggybacks on the dump
// submitted to the backend at session close
//
// Intentionally lightweight; this is a single Redux number plus a "last
// surface" string for context.
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
interface InteractionState {
/** Wall-clock ms (Date.now()) of the most recent user interaction. */
lastInteractionAt: number;
/** App start, useful for "time spent in app" metrics & idle calculations. */
appStartedAt: number;
/** A coarse label for what surface the user last interacted with useful
* for the "are you still there?" prompt (so we can resume them in
* context). */
lastSurface: string | null;
}
const initialState: InteractionState = {
lastInteractionAt: Date.now(),
appStartedAt: Date.now(),
lastSurface: null,
};
const slice = createSlice({
name: 'interaction',
initialState,
reducers: {
interactionRecorded(state, action: PayloadAction<{ surface?: string; at?: number }>) {
state.lastInteractionAt = action.payload.at ?? Date.now();
if (action.payload.surface) state.lastSurface = action.payload.surface;
},
appStartReset(state) {
state.appStartedAt = Date.now();
state.lastInteractionAt = state.appStartedAt;
state.lastSurface = null;
},
},
});
export const { interactionRecorded, appStartReset } = slice.actions;
export default slice.reducer;
+2 -2
View File
@@ -11,8 +11,8 @@ import outputsReducer from './outputsSlice';
import dashboardLayoutReducer from './dashboardLayoutSlice';
import dashboardsReducer from './dashboardsSlice';
import updateReducer from './updateSlice';
import analyticsReducer from './analyticsSlice';
import modelsReducer from './modelsSlice';
import interactionReducer from './interactionSlice';
export const store = configureStore({
reducer: {
@@ -28,8 +28,8 @@ export const store = configureStore({
dashboardLayout: dashboardLayoutReducer,
dashboards: dashboardsReducer,
update: updateReducer,
analytics: analyticsReducer,
models: modelsReducer,
interaction: interactionReducer,
},
});
+1 -11
View File
@@ -2,14 +2,12 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface TempState {
temp_state: string | null;
pendingBrowserUrl: string | null;
pendingFocusAgentId: string | null;
lastDashboardId: string | null;
}
const initialState: TempState = {
temp_state: null,
pendingBrowserUrl: null,
pendingFocusAgentId: null,
lastDashboardId: null,
@@ -19,12 +17,6 @@ const tempStateSlice = createSlice({
name: 'tempState',
initialState,
reducers: {
setTempState(state, action: PayloadAction<string | null>) {
state.temp_state = action.payload;
},
resetTempState(state) {
state.temp_state = null;
},
setPendingBrowserUrl(state, action: PayloadAction<string>) {
state.pendingBrowserUrl = action.payload;
},
@@ -43,9 +35,7 @@ const tempStateSlice = createSlice({
},
});
export const {
setTempState,
resetTempState,
export const {
setPendingBrowserUrl,
clearPendingBrowserUrl,
setLastDashboardId,
@@ -0,0 +1,54 @@
// Single source of truth for animation timing + easing across the app.
// Mixing one-off durations / curves makes the chrome feel like several
// different products glued together; tokenizing makes everything land
// the same way.
//
// Pair with `useReducedMotion()` to respect OS-level "Reduce motion".
export const DURATION_MS = {
/** 60ms — hover state changes, subtle press feedback */
instant: 60,
/** 140ms — rows fading in, popovers, tooltip open, status pill swaps */
quick: 140,
/** 220ms — modal open, page transitions, banners */
standard: 220,
/** 400ms — drawer slide, big layout shifts */
slow: 400,
/** 1500ms — skeleton pulse + ambient breathing indicators */
ambient: 1500,
} as const;
export const EASE = {
/** Linear's signature curve. Snappy out, gentle settle. Good default for "thing appears". */
out: 'cubic-bezier(0.16, 1, 0.3, 1)',
/** MUI / Material default. Symmetric — for things that move both directions. */
inOut: 'cubic-bezier(0.4, 0, 0.2, 1)',
/** Subtle bounce at the end. Use sparingly for delight moments. */
spring: 'cubic-bezier(0.34, 1.56, 0.64, 1)',
/** Gentle breathing curve for ambient pulses. */
pulse: 'cubic-bezier(0.4, 0, 0.6, 1)',
} as const;
/** Framer-motion uses array-form easing. Same curves as EASE above. */
export const FRAMER_EASE = {
out: [0.16, 1, 0.3, 1] as [number, number, number, number],
inOut: [0.4, 0, 0.2, 1] as [number, number, number, number],
spring: [0.34, 1.56, 0.64, 1] as [number, number, number, number],
pulse: [0.4, 0, 0.6, 1] as [number, number, number, number],
};
/** Module-scoped fadeIn keyframe. Imported once instead of redefined inline at each callsite. */
export const fadeInKeyframes = {
'@keyframes openswarmFadeIn': {
from: { opacity: 0 },
to: { opacity: 1 },
},
};
/** Skeleton + indicator pulse keyframe. Imported once. */
export const pulseKeyframes = {
'@keyframes openswarmPulse': {
'0%, 100%': { opacity: 0.5 },
'50%': { opacity: 0.25 },
},
};
+5 -5
View File
@@ -1,4 +1,4 @@
import { trackEvent } from '@/shared/analytics';
import { report } from '@/shared/serviceClient';
export type OpenSwarmPlan = 'pro' | 'pro_plus' | 'ultra';
export type BillingInterval = 'monthly' | 'annual';
@@ -11,14 +11,14 @@ interface SubscribeOptions {
// Kicks off a Stripe Checkout session for the given plan + interval and opens
// the returned URL in the user's default browser (or a new tab fallback).
// All subscribe CTAs across Settings, Onboarding, and the 429 error card go
// through this helper so analytics shape and error handling stay consistent.
// through this helper so the wire shape and error handling stay consistent.
export async function subscribeToPlan(
plan: OpenSwarmPlan,
billingInterval: BillingInterval,
source: CheckoutSource,
opts: SubscribeOptions = {},
): Promise<void> {
trackEvent('subscription.subscribe_clicked', {
report('subscription', 'subscribe_clicked', {
source,
plan,
billing_interval: billingInterval,
@@ -26,7 +26,7 @@ export async function subscribeToPlan(
});
try {
// Cloud schema uses "yearly"; the desktop UI/analytics uses "annual".
// Cloud schema uses "yearly"; the desktop UI uses "annual".
// Normalize at the boundary so the rest of the client stays consistent.
const wireInterval = billingInterval === 'annual' ? 'yearly' : billingInterval;
const r = await fetch('https://api.openswarm.com/api/stripe/checkout', {
@@ -41,7 +41,7 @@ export async function subscribeToPlan(
const { url } = await r.json();
if (!url) return;
trackEvent('subscription.checkout_opened', {
report('subscription', 'checkout_opened', {
source,
plan,
billing_interval: billingInterval,
+214 -32
View File
@@ -36,6 +36,7 @@ const _getAuthTokenSafe = (): string => {
try { return getAuthToken() || ''; } catch { return ''; }
};
const _genUuid = (): string => {
// Avoid pulling in `crypto.randomUUID` for compat — this is a
// disambiguator, not a security boundary, so a 96-bit hex string is
@@ -118,47 +119,183 @@ class WebSocketManager {
private outboundQueue: QueuedFrame[] = [];
private listeners: Map<string, Set<(data: any) => void>> = new Map();
private interpolatorState: Map<string, { sessionId: string; messageId: string; targetText: string; displayedLength: number }> = new Map();
// Per-message streaming state. Rate-based pacing tracks measured
// throughput so paint output is smooth even when the server emits in
// bursts (which Anthropic / 9Router / OS TCP all do). Each frame we
// paint a small uniform chunk sized so that we'd drain the backlog
// over the next ~burstWindowMs — when the next burst arrives, we
// adjust without ever going dry between bursts.
//
// Fields:
// firstDeltaAt: timestamp of the very first delta. Used to compute
// average chars/sec over the lifetime of the stream.
// lastPaintAt: when we last actually dispatched. Frame loop reads
// this to enforce minimum step-time even when RAF fires faster
// than we want.
// measuredCps: rolling chars-per-second estimate. Decays on idle so
// a fast burst doesn't permanently inflate the rate.
// underrunMs: how long we've been "caught up" (no backlog) since
// the last paint. Used to detect we're rate-limited by the
// server, not by our cadence — when this gets large, we slow
// down to leave headroom for the next burst.
private interpolatorState: Map<string, {
sessionId: string;
messageId: string;
targetText: string;
displayedLength: number;
firstDeltaAt: number;
lastDeltaAt: number;
lastPaintAt: number;
measuredCps: number;
}> = new Map();
private interpolatorRafId: number | null = null;
// Initial paint delay (ms). We hold the first delta briefly so
// an inter-burst gap can land before painting starts. Without it
// the very first frame paints aggressively, then idles waiting
// for the next server burst — visible as a tiny boom-pause at
// the start of every stream. Imperceptible to humans (saccades
// run at ~250ms, well above this).
private static INITIAL_HOLD_MS = 150;
// Paint cadence in ms. ~30Hz — well above perceptual flicker,
// light enough on React reconciliation that it stays smooth on
// long messages.
private static PAINT_INTERVAL_MS = 33;
// Target painting throughput. 10 chars per 33ms = ~300 cps —
// the "fast comfortable typing" visual rate (20% slower than the
// previous 400 cps default). Still well above natural reading
// speed (~200 cps comfort threshold), still hides bursty upstream
// cadence, just feels less frantic. Tuned for legibility at speed.
private static TARGET_CHARS_PER_PAINT = 10;
// When a backlog accumulates, allow up to this many chars/paint to
// drain it. ~1.6× the target keeps catch-up imperceptible — the
// eye can't tell 10 from 16 in a fluid stream. Caps the worst-case
// visual jump on a giant burst.
private static MAX_CHARS_PER_PAINT = 16;
// Headroom buffer in ms. We try to keep at least this much "future
// paintable" content on hand at all times, so the next upstream
// burst can be coalesced into the visible stream without a pause.
// Adds a fixed latency budget — humans don't notice anything below
// ~250ms in continuous text, so 200ms is well-tuned.
private static HEADROOM_MS = 200;
constructor(url: string, options?: WSManagerOptions) {
this.url = url;
this.skipStreamEvents = options?.skipStreamEvents ?? false;
this.sessionId = options?.sessionId ?? null;
this.connectionUuid = _genUuid();
// Seed lastSeq from the cross-mount persistent map so a fresh
// manager (created on every AgentChat remount via key={session.id})
// doesn't ask the server to replay events the previous manager
// already saw. This is the architectural fix for "completed chats
// re-type themselves on reopen": the server's resume protocol now
// sees a real high-water mark and has nothing to replay.
if (this.sessionId) {
this.lastSeq = _sessionLastSeq.get(this.sessionId) ?? 0;
}
}
private bufferDelta(sessionId: string, messageId: string, delta: string) {
const now = performance.now();
const existing = this.interpolatorState.get(messageId);
if (existing) {
existing.targetText += delta;
existing.lastDeltaAt = now;
} else {
this.interpolatorState.set(messageId, { sessionId, messageId, targetText: delta, displayedLength: 0 });
this.interpolatorState.set(messageId, {
sessionId,
messageId,
targetText: delta,
displayedLength: 0,
firstDeltaAt: now,
lastDeltaAt: now,
// Seed lastPaintAt INITIAL_HOLD_MS in the future so the first
// tick won't paint until that delay has passed — gives the
// upstream a chance to land more bytes before we start, so
// we don't underrun on the very first frame.
lastPaintAt: now + WebSocketManager.INITIAL_HOLD_MS,
measuredCps: 0,
});
}
this.scheduleInterpolator();
}
private scheduleInterpolator() {
if (this.interpolatorRafId != null) return;
// Schedule on every frame — the time-throttle inside tickInterpolator
// decides whether this frame actually paints. RAF gives us frame-
// synced timing without the overhead of setInterval drift, and the
// throttle ensures we only dispatch once per PAINT_INTERVAL_MS even
// if RAF fires more often (which it does on 120Hz displays).
this.interpolatorRafId = requestAnimationFrame(() => this.tickInterpolator());
}
// Drain each message's pending text at a paced, roughly-uniform rate so
// bursty server emissions paint as a smooth stream of characters instead of
// visible chunks. Rate adapts to backlog: small backlog → ~2 chars/frame
// (~120cps, typewriter feel); large backlog → up to 40 chars/frame so we
// catch up fast without pinning the main thread.
// Fixed-rate "extremely fast typing" pacing. Paints at a constant
// ~400 cps target regardless of upstream burstiness. The buffer
// grows when bursts land above target and drains during gaps —
// because most models stream below 400 cps on average, we keep up
// easily and the user sees smooth, uniform high-speed typing. No
// more boom-pause-boom: the buffer absorbs bursts and the constant
// paint rate hides them.
//
// Three behaviors:
// 1. Healthy backlog (>= TARGET): paint exactly TARGET chars.
// 2. Big backlog (more than HEADROOM_MS-worth queued): paint up
// to MAX to slowly catch up. Capped low enough that the
// acceleration is invisible.
// 3. Underflow (less than TARGET remaining, stream still active):
// paint everything we have at the cadence and pause. Better
// than artificially trickling — the natural pause is short
// because the next burst from the server fills the buffer
// again.
//
// Latency cost: HEADROOM_MS (~200ms) behind real time. Imperceptible.
private tickInterpolator() {
this.interpolatorRafId = null;
const now = performance.now();
let workRemaining = false;
for (const state of this.interpolatorState.values()) {
const remaining = state.targetText.length - state.displayedLength;
if (remaining <= 0) continue;
const step = Math.min(Math.max(Math.ceil(remaining / 6), 2), 40);
const nextLength = Math.min(state.displayedLength + step, state.targetText.length);
// Time-throttle: paint once per PAINT_INTERVAL_MS regardless of
// display refresh rate. The lastPaintAt was seeded with
// `now + INITIAL_HOLD_MS` in bufferDelta on first delta, so the
// first frame is naturally delayed.
const sincePaint = now - state.lastPaintAt;
if (sincePaint < WebSocketManager.PAINT_INTERVAL_MS) {
workRemaining = true;
continue;
}
// Headroom in ms = remaining / TARGET_CPS. If we have more than
// HEADROOM_MS of paintable content queued, drain slightly faster
// to bound visible latency. Otherwise paint at the steady target
// rate.
const targetCps = WebSocketManager.TARGET_CHARS_PER_PAINT * (1000 / WebSocketManager.PAINT_INTERVAL_MS);
const headroomMs = (remaining / targetCps) * 1000;
let step: number;
if (headroomMs > WebSocketManager.HEADROOM_MS * 2) {
// Big buffer — accelerate slightly to catch up. Bounded so
// the visible flow doesn't become unstably variable.
step = WebSocketManager.MAX_CHARS_PER_PAINT;
} else {
// Steady-state: paint exactly TARGET. This is the "fast
// typing" cadence that hides upstream bursts.
step = WebSocketManager.TARGET_CHARS_PER_PAINT;
}
// Don't paint past the end of the buffered text. When this
// shrinks the step, we're underflowing — the natural pause that
// follows is exactly what we want (better than trickling fake-
// slow chars). The next upstream burst will land and we'll
// resume painting at TARGET.
step = Math.min(step, remaining);
const nextLength = state.displayedLength + step;
const deltaSlice = state.targetText.slice(state.displayedLength, nextLength);
state.displayedLength = nextLength;
store.dispatch(streamDelta({ sessionId: state.sessionId, messageId: state.messageId, delta: deltaSlice }));
state.lastPaintAt = now;
store.dispatch(streamDelta({
sessionId: state.sessionId,
messageId: state.messageId,
delta: deltaSlice,
}));
if (state.displayedLength < state.targetText.length) workRemaining = true;
}
if (workRemaining) this.scheduleInterpolator();
@@ -362,6 +499,11 @@ class WebSocketManager {
// session, so this is the high-water mark we send back on resume.
if (typeof msg.seq === 'number' && msg.seq > this.lastSeq) {
this.lastSeq = msg.seq;
// Mirror to the module-scope persistent map so the next fresh
// manager (next AgentChat remount) starts here, not at zero.
if (this.sessionId) {
_sessionLastSeq.set(this.sessionId, this.lastSeq);
}
}
// ----- Connection-scoped frames (no business-logic side effects) -----
@@ -395,8 +537,11 @@ class WebSocketManager {
store.dispatch(fetchSession(session_id));
// Reset lastSeq — the REST refetch is the new authoritative
// baseline; subsequent server events with seq numbers will
// re-establish the high-water mark.
// re-establish the high-water mark. Also wipe the cross-mount
// persistent map so a remount during this gap window doesn't
// resurrect the stale value.
this.lastSeq = 0;
_sessionLastSeq.delete(session_id);
}
return;
}
@@ -484,29 +629,46 @@ class WebSocketManager {
break;
case 'agent:stream_start':
if (session_id && data.message_id) {
store.dispatch(streamStart({
sessionId: session_id,
messageId: data.message_id,
role: data.role,
toolName: data.tool_name,
}));
}
break;
case 'agent:stream_delta':
if (session_id && data.message_id) {
this.bufferDelta(session_id, data.message_id, data.delta);
}
break;
case 'agent:stream_end':
if (session_id && data.message_id) {
this.flushInterpolator(data.message_id);
store.dispatch(streamEnd({
sessionId: session_id,
messageId: data.message_id,
}));
// Replay-skip guard. The WS resume protocol replays buffered
// events from the ring buffer with seq > last_seq. When this
// manager is freshly constructed (every AgentChat mount,
// because of `key={session.id}`), last_seq is 0, so the server
// replays EVERY buffered stream_* event for the session.
// Without this guard, opening any chat with prior streaming
// turns animates the entire history through the typewriter
// interpolator on every reopen.
//
// The discriminator is `resumeAcked`: it flips to true when
// server:hello arrives, which the server sends AFTER the replay
// completes. Any stream_* event arriving while !resumeAcked is
// replay-from-buffer (historical) and can be dropped — the REST
// snapshot we awaited before connect is authoritative for any
// already-finalized message, and any genuinely live turn the
// server is pushing will continue emitting events after the ack.
if (!this.resumeAcked) break;
if (event === 'agent:stream_start') {
if (session_id && data.message_id) {
store.dispatch(streamStart({
sessionId: session_id,
messageId: data.message_id,
role: data.role,
toolName: data.tool_name,
}));
}
} else if (event === 'agent:stream_delta') {
if (session_id && data.message_id) {
this.bufferDelta(session_id, data.message_id, data.delta);
}
} else if (event === 'agent:stream_end') {
if (session_id && data.message_id) {
this.flushInterpolator(data.message_id);
store.dispatch(streamEnd({
sessionId: session_id,
messageId: data.message_id,
}));
}
}
break;
@@ -765,6 +927,26 @@ import { WS_BASE } from '@/shared/config';
export const dashboardWs = new WebSocketManager(`${WS_BASE}/ws/dashboard`, { skipStreamEvents: true });
// Per-session high-water mark for the resume protocol. Survives across
// AgentChat mounts/unmounts so reopening a chat doesn't re-trigger a
// full replay from the server's ring buffer.
//
// Why this exists: AgentChat uses `key={session.id}` on the embedded
// instance inside AgentCard, so every expand/collapse remounts the
// component, which constructs a fresh WebSocketManager. Without this
// persistent map, each fresh manager starts at last_seq=0 and asks the
// server for the entire buffered history. The server faithfully
// replays it, the client renders the typewriter animation again, and
// the user sees their completed chat "type itself out" on every reopen.
//
// Lifetime: tied to the JS module load, which means the page tab. Lost
// on full app reload (intentional — that should re-hydrate from REST).
// On backend restart the buffers are wiped anyway, so a stale
// lastSeq pointing past the buffer top falls into the "fresh client"
// path on the server (last_seq>0 but no buffer) which short-circuits
// to a no-op replay. Safe.
const _sessionLastSeq: Map<string, number> = new Map();
export function createSessionWs(sessionId: string): WebSocketManager {
return new WebSocketManager(`${WS_BASE}/ws/agents/${sessionId}`, { sessionId });
}
File diff suppressed because one or more lines are too long
-39
View File
@@ -1,39 +0,0 @@
#!/bin/bash
# One-time Microsoft 365 authentication for OpenSwarm.
# Run this once to cache your M365 token. After that, M365 works in OpenSwarm automatically.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
CACHE_DIR="$HOME/.openswarm"
mkdir -p "$CACHE_DIR"
export MS365_MCP_TOKEN_CACHE_PATH="$CACHE_DIR/ms365-token-cache.json"
export MS365_MCP_SELECTED_ACCOUNT_PATH="$CACHE_DIR/ms365-selected-account.json"
SERVER_SCRIPT="$PROJECT_ROOT/backend/npm-servers/softeria-ms-365-mcp-server/node_modules/@softeria/ms-365-mcp-server/dist/index.js"
if [ ! -f "$SERVER_SCRIPT" ]; then
echo "M365 MCP server not found. Run 'cd backend/npm-servers/softeria-ms-365-mcp-server && npm install' first."
exit 1
fi
echo ""
echo " Microsoft 365 Login for OpenSwarm"
echo " ─────────────────────────────────"
echo " A browser window will open for you to sign in."
echo " After login, the token is cached and M365 works in OpenSwarm automatically."
echo ""
node "$SERVER_SCRIPT" --login
if [ -f "$MS365_MCP_TOKEN_CACHE_PATH" ]; then
echo ""
echo " ✓ Token cached at $MS365_MCP_TOKEN_CACHE_PATH"
echo " ✓ M365 is ready to use in OpenSwarm!"
echo ""
else
echo ""
echo " ✗ Login may have failed — no token cache found."
echo ""
fi