mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-11 12:17:45 +02:00
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:
+27
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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 [])
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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']}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.")
|
||||
@@ -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")
|
||||
@@ -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),
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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}]
|
||||
@@ -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
|
||||
@@ -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 = {}
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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("```"):
|
||||
|
||||
@@ -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")
|
||||
@@ -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 {})
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -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())}
|
||||
@@ -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
|
||||
|
||||
@@ -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()}
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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."
|
||||
}
|
||||
]
|
||||
|
||||
+1240
-135
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"}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
@@ -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
@@ -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
@@ -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() {
|
||||
|
||||
Generated
+2
-2
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "openswarm",
|
||||
"version": "1.0.27",
|
||||
"version": "1.0.28",
|
||||
"description": "OpenSwarm — AI Agent Orchestrator",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -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
@@ -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}>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(() => {});
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
Reference in New Issue
Block a user