mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
[eric] backend: one-line comments + collapse blank runs across backend root + tests (AST-verified, 1 test skipped: embedded #-lines)
This commit is contained in:
@@ -79,8 +79,7 @@ class AgentManager(SessionLifecycle, SessionPersistence, Messaging, SessionContr
|
||||
|
||||
builtin_perms = load_builtin_permissions()
|
||||
|
||||
# Builtins default to always_allow (frictionless); path_gate still force-prompts on catastrophic patterns (rm -rf), OS-scheduling, and sensitive paths, so poisoned-email -> destructive-command is still caught. Flip Bash to "ask" in the UI for a prompt on every command.
|
||||
# Bind turn + stderr first: build_agent_options can raise early (no provider) and the except hands both to handle_run_error.
|
||||
# Builtins default to always_allow (frictionless); path_gate still force-prompts on catastrophic patterns (rm -rf), OS-scheduling, and sensitive paths, so poisoned-email -> destructive-command is still caught. Flip Bash to "ask" in the UI for a prompt on every command. Bind turn + stderr first: build_agent_options can raise early (no provider) and the except hands both to handle_run_error.
|
||||
turn = TurnState()
|
||||
p_stderr_buffer: List[str] = []
|
||||
try:
|
||||
|
||||
@@ -80,10 +80,7 @@ class SessionPersistence(AgentManagerProtocol):
|
||||
if session.closed_at is not None:
|
||||
continue
|
||||
if session.status in ("running", "waiting_approval"):
|
||||
# The app died mid-turn. If the last message in the active branch is already an
|
||||
# assistant reply, the turn finished streaming and only the status finalize was lost
|
||||
# (-> completed, no spurious "Resume" button); otherwise the agent was genuinely cut
|
||||
# off owing a response (-> stopped, resumable).
|
||||
# The app died mid-turn. If the last message in the active branch is already an assistant reply, the turn finished streaming and only the status finalize was lost (-> completed, no spurious "Resume" button); otherwise the agent was genuinely cut off owing a response (-> stopped, resumable).
|
||||
branch = session.active_branch_id or "main"
|
||||
p_branch_msgs = [m for m in session.messages if (m.branch_id or "main") == branch]
|
||||
p_last = p_branch_msgs[-1] if p_branch_msgs else None
|
||||
|
||||
+2
-8
@@ -179,15 +179,9 @@ P_AUTH_EXEMPT_EXACT = {
|
||||
"/api/subscription/activate",
|
||||
"/api/auth/signin-activate",
|
||||
"/api/version",
|
||||
# Local Google OAuth token-endpoint proxy: hit by the
|
||||
# google-workspace-mcp subprocess we spawn. It doesn't (and can't
|
||||
# easily) carry the install bearer in google-auth's refresh post.
|
||||
# Localhost binding is the gate, and the route does nothing the
|
||||
# public api.openswarm.com/api/oauth/google/refresh doesn't already
|
||||
# do for any internet caller, so no new attack surface.
|
||||
# Local Google OAuth token-endpoint proxy: hit by the google-workspace-mcp subprocess we spawn. It doesn't (and can't easily) carry the install bearer in google-auth's refresh post. Localhost binding is the gate, and the route does nothing the public api.openswarm.com/api/oauth/google/refresh doesn't already do for any internet caller, so no new attack surface.
|
||||
"/api/tools/google-oauth-token",
|
||||
# Dev-only token handoff for the split-port frontend (no Electron preload
|
||||
# to read the token from). The route itself 404s in packaged builds.
|
||||
# Dev-only token handoff for the split-port frontend (no Electron preload to read the token from). The route itself 404s in packaged builds.
|
||||
"/api/dev/token",
|
||||
}
|
||||
|
||||
|
||||
@@ -30,11 +30,7 @@ class MainApp:
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
async with AsyncExitStack() as stack:
|
||||
# [perf] per-lifespan boot timing. debug() is a no-op in the
|
||||
# packaged build, so without this the packaged backend.log has no
|
||||
# per-SubApp markers and a cold-start stall can only be guessed at.
|
||||
# One perf_counter + flushed print per app pins exactly which
|
||||
# lifespan (or the cold first-touch I/O entering it) dominates.
|
||||
# [perf] per-lifespan boot timing. debug() is a no-op in the packaged build, so without this the packaged backend.log has no per-SubApp markers and a cold-start stall can only be guessed at. One perf_counter + flushed print per app pins exactly which lifespan (or the cold first-touch I/O entering it) dominates.
|
||||
p_boot_t0 = time.perf_counter()
|
||||
for sub_app in sub_apps:
|
||||
debug(sub_app.name)
|
||||
|
||||
+29
-127
@@ -4,10 +4,7 @@ import logging
|
||||
import os
|
||||
from uuid import uuid4
|
||||
|
||||
# App-level INFO logs (fast-path gates, skill recording, replay decisions) were
|
||||
# invisible because nothing configured the 'backend' logger; every debugging
|
||||
# session re-paid that blindness. Idempotent so uvicorn reloads don't stack
|
||||
# handlers; uvicorn's own access logs are untouched.
|
||||
# App-level INFO logs (fast-path gates, skill recording, replay decisions) were invisible because nothing configured the 'backend' logger; every debugging session re-paid that blindness. Idempotent so uvicorn reloads don't stack handlers; uvicorn's own access logs are untouched.
|
||||
p_backend_logger = logging.getLogger("backend")
|
||||
if not p_backend_logger.handlers:
|
||||
p_h = logging.StreamHandler()
|
||||
@@ -54,8 +51,7 @@ import json
|
||||
main_app = MainApp([health, agents, skills, tools_lib, modes, settings, mcp_registry, skill_registry, outputs, output_versions, dashboards, swarm, service, subscription, auth, web, anthropic_proxy, workflows])
|
||||
app = main_app.app
|
||||
|
||||
# Generate per-install auth token BEFORE we bind the HTTP port. By the
|
||||
# time any request lands, the token file exists. See backend/auth.py.
|
||||
# Generate per-install auth token BEFORE we bind the HTTP port. By the time any request lands, the token file exists. See backend/auth.py.
|
||||
from backend.auth import (
|
||||
init_auth_token,
|
||||
install_token_scrubber,
|
||||
@@ -64,19 +60,10 @@ from backend.auth import (
|
||||
is_origin_allowed,
|
||||
)
|
||||
init_auth_token()
|
||||
# Install the log scrubber AFTER the token exists so any log line that
|
||||
# accidentally embeds it (subprocess env dumps, urllib retry traces,
|
||||
# proxied-request error bodies) gets redacted before hitting handlers.
|
||||
# Install the log scrubber AFTER the token exists so any log line that accidentally embeds it (subprocess env dumps, urllib retry traces, proxied-request error bodies) gets redacted before hitting handlers.
|
||||
install_token_scrubber()
|
||||
|
||||
# Generate the per-install id (installation_id) at the same pre-bind moment
|
||||
# as the auth token. It is otherwise created lazily on the first analytics
|
||||
# submission, so on a clean install the sign-in window can render and build
|
||||
# its Google/email OAuth URL (which embeds install_id) before that
|
||||
# submission fires, producing an empty install_id that the cloud rejects.
|
||||
# Generating here guarantees the very first GET /api/settings already
|
||||
# carries it. Platform-agnostic; wrapped so a settings hiccup never blocks
|
||||
# startup, and the lazy path stays as a fallback.
|
||||
# Generate the per-install id (installation_id) at the same pre-bind moment as the auth token. It is otherwise created lazily on the first analytics submission, so on a clean install the sign-in window can render and build its Google/email OAuth URL (which embeds install_id) before that submission fires, producing an empty install_id that the cloud rejects. Generating here guarantees the very first GET /api/settings already carries it. Platform-agnostic; wrapped so a settings hiccup never blocks startup, and the lazy path stays as a fallback.
|
||||
try:
|
||||
import uuid as p_uuid
|
||||
from backend.apps.settings.store import load_settings as p_load_boot_settings, save_settings as p_save_boot_settings
|
||||
@@ -88,12 +75,7 @@ except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# CORS: previously wide open (`allow_origins=["*"]`), which combined with
|
||||
# `allow_credentials=True` was a security footgun, any external origin
|
||||
# could CORS-preflight us. Now restricted to Electron renderer origins +
|
||||
# localhost dev servers. The token middleware below provides the
|
||||
# *primary* defense; CORS is defense-in-depth so a misconfigured page
|
||||
# can't even reach us.
|
||||
# CORS: previously wide open (`allow_origins=["*"]`), which combined with `allow_credentials=True` was a security footgun, any external origin could CORS-preflight us. Now restricted to Electron renderer origins + localhost dev servers. The token middleware below provides the *primary* defense; CORS is defense-in-depth so a misconfigured page can't even reach us.
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[
|
||||
@@ -106,13 +88,7 @@ app.add_middleware(
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
# Every cross-origin POST from the Electron renderer (file:// → http://localhost:8324)
|
||||
# carries Authorization: Bearer, which CORS classifies as non-simple and
|
||||
# forces a preflight OPTIONS before EACH POST. With no max_age the browser
|
||||
# re-preflights on a tight schedule (~5 s in Chromium); under heavy
|
||||
# interaction we observed a 1:1 OPTIONS-to-POST ratio in the dev log,
|
||||
# doubling roundtrip count for no reason. Caching the preflight result
|
||||
# for 10 minutes drops that to one OPTIONS per ~600 POSTs.
|
||||
# Every cross-origin POST from the Electron renderer (file:// → http://localhost:8324) carries Authorization: Bearer, which CORS classifies as non-simple and forces a preflight OPTIONS before EACH POST. With no max_age the browser re-preflights on a tight schedule (~5 s in Chromium); under heavy interaction we observed a 1:1 OPTIONS-to-POST ratio in the dev log, doubling roundtrip count for no reason. Caching the preflight result for 10 minutes drops that to one OPTIONS per ~600 POSTs.
|
||||
max_age=600,
|
||||
)
|
||||
|
||||
@@ -142,13 +118,10 @@ async def p_auth_middleware(request: Request, call_next):
|
||||
elif is_path_exempt(request.url.path):
|
||||
response = await call_next(request)
|
||||
else:
|
||||
# Accept Authorization Bearer, x-openswarm-token, OR x-api-key
|
||||
# (CLI path, CLI sends x-api-key with our token as value).
|
||||
# Accept Authorization Bearer, x-openswarm-token, OR x-api-key (CLI path, CLI sends x-api-key with our token as value).
|
||||
headers = dict(request.headers)
|
||||
x_api_key = headers.get("x-api-key") or headers.get("X-API-Key")
|
||||
# Accept `?token=<token>` query param too. Required for browser-driven
|
||||
# GETs that can't set headers, notably the App Builder iframe loading
|
||||
# /api/outputs/.../serve/index.html via <iframe src="...">.
|
||||
# Accept `?token=<token>` query param too. Required for browser-driven GETs that can't set headers, notably the App Builder iframe loading /api/outputs/.../serve/index.html via <iframe src="...">.
|
||||
auth_ok = request_matches_token(headers, query_params=dict(request.query_params))
|
||||
if not auth_ok and x_api_key:
|
||||
import secrets as p_s
|
||||
@@ -165,8 +138,7 @@ async def p_auth_middleware(request: Request, call_next):
|
||||
)
|
||||
response = await call_next(request)
|
||||
|
||||
# Private-Network-Access header for the one remaining public-origin
|
||||
# path (OAuth callback). Harmless on other requests.
|
||||
# Private-Network-Access header for the one remaining public-origin path (OAuth callback). Harmless on other requests.
|
||||
response.headers.setdefault("Access-Control-Allow-Private-Network", "true")
|
||||
return response
|
||||
|
||||
@@ -199,12 +171,7 @@ async def websocket_session(websocket: WebSocket, session_id: str):
|
||||
payload = msg.get("data", {})
|
||||
|
||||
if event == "client:hello":
|
||||
# Resume handshake. The client sends this immediately
|
||||
# after the WS opens, with `last_seq` = the highest
|
||||
# seq it has applied. We replay anything newer; on
|
||||
# first connect last_seq=0 and replay() correctly
|
||||
# returns nothing (empty buffer) or the persisted
|
||||
# terminal event for already-finished sessions.
|
||||
# Resume handshake. The client sends this immediately after the WS opens, with `last_seq` = the highest seq it has applied. We replay anything newer; on first connect last_seq=0 and replay() correctly returns nothing (empty buffer) or the persisted terminal event for already-finished sessions.
|
||||
last_seq = int(payload.get("last_seq") or 0)
|
||||
connection_uuid = payload.get("connection_uuid") or ""
|
||||
ack = await ws_manager.replay_to(session_id, websocket, last_seq)
|
||||
@@ -219,10 +186,7 @@ async def websocket_session(websocket: WebSocket, session_id: str):
|
||||
},
|
||||
}))
|
||||
elif event == "client:ping":
|
||||
# Heartbeat. Cheap, keeps NATs/firewalls from
|
||||
# silently dropping the connection. Carry the
|
||||
# client's nonce back so it can match pong→ping for
|
||||
# round-trip latency tracking if it wants.
|
||||
# Heartbeat. Cheap, keeps NATs/firewalls from silently dropping the connection. Carry the client's nonce back so it can match pong→ping for round-trip latency tracking if it wants.
|
||||
await websocket.send_text(json.dumps({
|
||||
"event": "server:pong",
|
||||
"session_id": session_id,
|
||||
@@ -257,8 +221,7 @@ async def websocket_session(websocket: WebSocket, session_id: str):
|
||||
from backend.apps.agents.agent_manager import agent_manager
|
||||
await agent_manager.stop_agent(session_id)
|
||||
except WebSocketDisconnect:
|
||||
# Drops the socket from the connection list. Does NOT cancel
|
||||
# the agent task, that's intentional. See module docstring.
|
||||
# Drops the socket from the connection list. Does NOT cancel the agent task, that's intentional. See module docstring.
|
||||
ws_manager.disconnect_session(session_id, websocket)
|
||||
|
||||
def p_ws_auth_ok(websocket: WebSocket) -> bool:
|
||||
@@ -275,8 +238,7 @@ def p_ws_auth_ok(websocket: WebSocket) -> bool:
|
||||
if not (token_ok and origin_ok):
|
||||
reason = "bad token" if not token_ok else f"bad origin ({origin})"
|
||||
logger.warning(f"ws: rejecting connection to {websocket.url.path}, {reason}")
|
||||
# Can't `await websocket.close()` before accept(), so schedule the
|
||||
# close in a task. The client receives a 403 on handshake.
|
||||
# Can't `await websocket.close()` before accept(), so schedule the close in a task. The client receives a 403 on handshake.
|
||||
import asyncio as p_asyncio
|
||||
p_asyncio.create_task(websocket.close(code=4401))
|
||||
return False
|
||||
@@ -295,12 +257,7 @@ async def websocket_runtime_logs(websocket: WebSocket, workspace_id: str):
|
||||
from backend.apps.outputs.runtime import manager as runtime_manager
|
||||
rt = runtime_manager.get(workspace_id)
|
||||
if rt is None:
|
||||
# No active runtime, surface that to the client and close. The
|
||||
# frontend will call /runtime/start and reconnect. Also emit a
|
||||
# status frame with is_new_mode (computed from disk) so the
|
||||
# preview pane shows the "starting preview…" placeholder for
|
||||
# webapp_template workspaces instead of falling back to the
|
||||
# legacy /serve/index.html URL (which 404s in new-mode).
|
||||
# No active runtime, surface that to the client and close. The frontend will call /runtime/start and reconnect. Also emit a status frame with is_new_mode (computed from disk) so the preview pane shows the "starting preview…" placeholder for webapp_template workspaces instead of falling back to the legacy /serve/index.html URL (which 404s in new-mode).
|
||||
try:
|
||||
from backend.apps.outputs.outputs import runtime_status_payload
|
||||
status = runtime_status_payload(workspace_id)
|
||||
@@ -316,10 +273,7 @@ async def websocket_runtime_logs(websocket: WebSocket, workspace_id: str):
|
||||
finally:
|
||||
await websocket.close()
|
||||
return
|
||||
# Buffer log lines from the synchronous subscriber callback into an
|
||||
# asyncio.Queue we can `await` on the WS sender side. The subscribe
|
||||
# call replays the ring buffer synchronously, so the queue gets
|
||||
# primed with existing lines before we enter the loop.
|
||||
# Buffer log lines from the synchronous subscriber callback into an asyncio.Queue we can `await` on the WS sender side. The subscribe call replays the ring buffer synchronously, so the queue gets primed with existing lines before we enter the loop.
|
||||
queue: asyncio.Queue[tuple[str, str]] = asyncio.Queue()
|
||||
|
||||
def p_on_line(line) -> None:
|
||||
@@ -345,11 +299,7 @@ async def websocket_runtime_logs(websocket: WebSocket, workspace_id: str):
|
||||
}
|
||||
|
||||
try:
|
||||
# Initial status frame so the client knows port/running state
|
||||
# without a second HTTP round-trip. `frontend_url` is the
|
||||
# new-mode preview pointer (Vite dev server); `backend_url` is
|
||||
# the workspace's optional FastAPI backend (old-mode backend.py
|
||||
# OR new-mode post-backend_init.sh).
|
||||
# Initial status frame so the client knows port/running state without a second HTTP round-trip. `frontend_url` is the new-mode preview pointer (Vite dev server); `backend_url` is the workspace's optional FastAPI backend (old-mode backend.py OR new-mode post-backend_init.sh).
|
||||
await websocket.send_text(json.dumps(p_build_status_frame()))
|
||||
while True:
|
||||
stream, text = await queue.get()
|
||||
@@ -358,12 +308,7 @@ async def websocket_runtime_logs(websocket: WebSocket, workspace_id: str):
|
||||
"workspace_id": workspace_id,
|
||||
"data": {"stream": stream, "text": text},
|
||||
}))
|
||||
# Runtime-level events (start, frontend-ready, exit) flow
|
||||
# through the same log channel with stream="runtime". When
|
||||
# the client sees one, it usually wants the fresh status;
|
||||
# bind-ready in particular flips frontend_url from null
|
||||
# to the Vite URL and the preview pane has to know to
|
||||
# switch over. Re-push status after every runtime line.
|
||||
# Runtime-level events (start, frontend-ready, exit) flow through the same log channel with stream="runtime". When the client sees one, it usually wants the fresh status; bind-ready in particular flips frontend_url from null to the Vite URL and the preview pane has to know to switch over. Re-push status after every runtime line.
|
||||
if stream == "runtime":
|
||||
await websocket.send_text(json.dumps(p_build_status_frame()))
|
||||
except WebSocketDisconnect:
|
||||
@@ -479,19 +424,13 @@ async def subscriptions_callback(request: Request):
|
||||
error = request.query_params.get("error", "")
|
||||
|
||||
if error:
|
||||
# Escape both inputs, `error_description` and `error` are attacker-
|
||||
# controllable query params and the endpoint is auth-exempt, so an
|
||||
# unescaped interpolation here is a reflected XSS in the localhost
|
||||
# origin (loadable inside the Electron app context, where same-origin
|
||||
# JS has access to the install token).
|
||||
# Escape both inputs, `error_description` and `error` are attacker- controllable query params and the endpoint is auth-exempt, so an unescaped interpolation here is a reflected XSS in the localhost origin (loadable inside the Electron app context, where same-origin JS has access to the install token).
|
||||
desc = html.escape(request.query_params.get("error_description", error))
|
||||
return HTMLResponse(f'<html><body style="background:#1a1a1a;color:#fff;display:flex;align-items:center;justify-content:center;height:100vh;font-family:sans-serif"><div style="text-align:center"><h2>Authorization failed</h2><p style="color:#888">{desc}</p></div></body></html>')
|
||||
|
||||
pending = pending_oauth.pop(state, None)
|
||||
if not pending:
|
||||
# Either a duplicate callback for a state we've already exchanged,
|
||||
# or a truly stale state. Duplicates are the expected case:
|
||||
# Chrome's prefetcher and some extensions speculatively GET URLs.
|
||||
# Either a duplicate callback for a state we've already exchanged, or a truly stale state. Duplicates are the expected case: Chrome's prefetcher and some extensions speculatively GET URLs.
|
||||
if state and state in completed_oauth:
|
||||
logger.info(f"Duplicate OAuth callback for state {state[:8]}... (already completed)")
|
||||
return HTMLResponse(P_SUCCESS_HTML)
|
||||
@@ -503,10 +442,7 @@ async def subscriptions_callback(request: Request):
|
||||
await exchange_oauth(pending["provider"], code, pending["redirect_uri"], pending["code_verifier"], state)
|
||||
except Exception as e:
|
||||
logger.warning(f"OAuth exchange failed for provider={pending.get('provider')}: {e}")
|
||||
# Escape the exception message, upstream OAuth provider errors can
|
||||
# echo back attacker-influenced strings (e.g. error_description from
|
||||
# the original request URL), and this response is rendered in the
|
||||
# localhost origin.
|
||||
# Escape the exception message, upstream OAuth provider errors can echo back attacker-influenced strings (e.g. error_description from the original request URL), and this response is rendered in the localhost origin.
|
||||
safe_e = html.escape(str(e))
|
||||
return HTMLResponse(f'<html><body style="background:#1a1a1a;color:#fff;display:flex;align-items:center;justify-content:center;height:100vh;font-family:sans-serif"><div style="text-align:center"><h2>Connection failed</h2><p style="color:#888">{safe_e}</p></div></body></html>')
|
||||
|
||||
@@ -560,12 +496,7 @@ async def mcp_meta(action: str, request: Request):
|
||||
body = await request.json()
|
||||
parent_session_id = body.get("parent_session_id", "")
|
||||
|
||||
# Aliases that broaden the search corpus for common user intents. Without
|
||||
# these, MCPSearch("email") fails to surface Google Workspace because
|
||||
# the tool's stored description says "Gmail" not "email". Keys are
|
||||
# sanitized server names; values are extra search-hint tokens appended
|
||||
# to the haystack. Only generic synonyms, anything that's already in
|
||||
# the description doesn't need to be listed.
|
||||
# Aliases that broaden the search corpus for common user intents. Without these, MCPSearch("email") fails to surface Google Workspace because the tool's stored description says "Gmail" not "email". Keys are sanitized server names; values are extra search-hint tokens appended to the haystack. Only generic synonyms, anything that's already in the description doesn't need to be listed.
|
||||
P_SERVER_SEARCH_ALIASES: dict[str, list[str]] = {
|
||||
"google-workspace": [
|
||||
"email", "inbox", "mail", "gmail", "calendar", "schedule",
|
||||
@@ -592,8 +523,7 @@ async def mcp_meta(action: str, request: Request):
|
||||
if not (t.mcp_config and t.enabled and t.auth_status in ("configured", "connected")):
|
||||
continue
|
||||
sanitized = sanitize_server_name(t.name)
|
||||
# Pull tool sub-action names from tool_permissions._tool_descriptions
|
||||
# so MCPSearch can match against capability names (e.g. "send_email").
|
||||
# Pull tool sub-action names from tool_permissions._tool_descriptions so MCPSearch can match against capability names (e.g. "send_email").
|
||||
action_names: list[str] = []
|
||||
try:
|
||||
td = (t.tool_permissions or {}).get("_tool_descriptions", {})
|
||||
@@ -626,11 +556,7 @@ async def mcp_meta(action: str, request: Request):
|
||||
servers = p_connected_servers()
|
||||
session = agent_manager.sessions.get(parent_session_id) if parent_session_id else None
|
||||
active_set = set(session.active_mcps) if session else set()
|
||||
# Ranking: substring hits across name+description+sub-tool names+
|
||||
# generic-purpose aliases. The aliases are what let "email" match
|
||||
# google-workspace even though the description says "Gmail".
|
||||
# Active-first tiebreak so the model prefers servers it has already
|
||||
# activated when both score equally.
|
||||
# Ranking: substring hits across name+description+sub-tool names+ generic-purpose aliases. The aliases are what let "email" match google-workspace even though the description says "Gmail". Active-first tiebreak so the model prefers servers it has already activated when both score equally.
|
||||
scored: list[tuple[int, dict]] = []
|
||||
for s in servers:
|
||||
extras = s.get("_search_extras", "")
|
||||
@@ -638,9 +564,7 @@ async def mcp_meta(action: str, request: Request):
|
||||
score = 0
|
||||
for tok in query.split():
|
||||
if tok and tok in hay:
|
||||
# Hits in the canonical name count more; alias hits
|
||||
# count once so a "drive" query doesn't beat the actual
|
||||
# Drive tool description.
|
||||
# Hits in the canonical name count more; alias hits count once so a "drive" query doesn't beat the actual Drive tool description.
|
||||
if tok in s["name"]:
|
||||
score += 2
|
||||
elif tok in s["description"].lower():
|
||||
@@ -686,23 +610,9 @@ async def mcp_meta(action: str, request: Request):
|
||||
logger.exception("Failed to broadcast post-activate session status")
|
||||
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
|
||||
# call), the agent loop dispatches a synthetic "continue" turn
|
||||
# with the freshly-activated tools available. Race-free, read
|
||||
# at the natural turn-boundary inside _run_agent_loop instead of
|
||||
# racing a background task against the turn's completion path.
|
||||
# Turns the typical 3-prompt flow ("check email" → MCPActivate
|
||||
# → "do it") into a 1-prompt flow.
|
||||
# Auto-continue: flag the session so that after its current turn ends (which is the turn that contains this MCPActivate tool call), the agent loop dispatches a synthetic "continue" turn with the freshly-activated tools available. Race-free, read at the natural turn-boundary inside _run_agent_loop instead of racing a background task against the turn's completion path. Turns the typical 3-prompt flow ("check email" → MCPActivate → "do it") into a 1-prompt flow.
|
||||
session.pending_continuation = True
|
||||
# Enumerate the just-activated server's callable tool names so the
|
||||
# continuation turn can call them directly. Without this the model
|
||||
# often burns a turn on tool-discovery guesses (Bash "mcp list",
|
||||
# Ls /toolbox, ToolSearch fallbacks) before landing on the right
|
||||
# mcp__server__action name. Cap at 16 + clip descriptions so the
|
||||
# prompt stays bounded for kitchen-sink servers (google-workspace
|
||||
# exposes ~30 tools). Best-effort; any lookup failure silently
|
||||
# falls back to the same prompt this code shipped with before.
|
||||
# Enumerate the just-activated server's callable tool names so the continuation turn can call them directly. Without this the model often burns a turn on tool-discovery guesses (Bash "mcp list", Ls /toolbox, ToolSearch fallbacks) before landing on the right mcp__server__action name. Cap at 16 + clip descriptions so the prompt stays bounded for kitchen-sink servers (google-workspace exposes ~30 tools). Best-effort; any lookup failure silently falls back to the same prompt this code shipped with before.
|
||||
tool_hint = ""
|
||||
try:
|
||||
for t in load_all_tools():
|
||||
@@ -773,11 +683,7 @@ async def settings_meta(action: str, request: Request):
|
||||
|
||||
valid_fields = set(AppSettings.model_fields.keys())
|
||||
outcomes: dict[str, dict] = {}
|
||||
# Serialize the read-modify-write: SettingsWrite goes through apply_settings_update,
|
||||
# which awaits (so two autonomous agents would interleave and clobber each
|
||||
# other's fields while BOTH got an "applied" result). The lock makes agent
|
||||
# writes serial so the last load always sees the prior write. (Agent vs the
|
||||
# renderer's own PUT stays the pre-existing full-object-replace race.)
|
||||
# Serialize the read-modify-write: SettingsWrite goes through apply_settings_update, which awaits (so two autonomous agents would interleave and clobber each other's fields while BOTH got an "applied" result). The lock makes agent writes serial so the last load always sees the prior write. (Agent vs the renderer's own PUT stays the pre-existing full-object-replace race.)
|
||||
async with settings_write_lock():
|
||||
settings = load_settings()
|
||||
session = agent_manager.sessions.get(parent_session_id) if parent_session_id else None
|
||||
@@ -787,8 +693,7 @@ async def settings_meta(action: str, request: Request):
|
||||
# No live session to anchor the guard: fail safe, protect every credential.
|
||||
powering = PoweringCredential(kind="unknown", provider="unknown", label="this run")
|
||||
|
||||
# The credential field(s) the second-wall restore in apply_settings_update
|
||||
# must never let a write blank (independent of the per-field guard below).
|
||||
# The credential field(s) the second-wall restore in apply_settings_update must never let a write blank (independent of the per-field guard below).
|
||||
if powering.kind == "unknown":
|
||||
protect_fields = set(ALL_API_KEY_FIELDS)
|
||||
elif powering.kind == "api_key" and powering.protected_field:
|
||||
@@ -833,10 +738,7 @@ async def settings_meta(action: str, request: Request):
|
||||
outcomes[f] = {"status": "error", "reason": f"write failed: {e}"}
|
||||
|
||||
if any(o.get("status") == "applied" for o in outcomes.values()):
|
||||
# An agent wrote settings (not the user via the modal), so nudge every
|
||||
# open window to refetch instead of waiting for the next window-focus.
|
||||
# Pure signal: the renderer refetches the authoritative state, so nothing
|
||||
# (least of all a secret) needs to ride the broadcast.
|
||||
# An agent wrote settings (not the user via the modal), so nudge every open window to refetch instead of waiting for the next window-focus. Pure signal: the renderer refetches the authoritative state, so nothing (least of all a secret) needs to ride the broadcast.
|
||||
from backend.apps.agents.core.ws_manager import ws_manager as p_wsm
|
||||
await p_wsm.broadcast_global("settings:changed", {})
|
||||
|
||||
|
||||
@@ -37,8 +37,7 @@ def _isolate_browser_state(monkeypatch):
|
||||
m.clear(wipe_disk=True)
|
||||
except Exception:
|
||||
pass
|
||||
# metrics caches its dir at first use; drop it so each test writes
|
||||
# where ITS env var points, not where the first test's pointed
|
||||
# metrics caches its dir at first use; drop it so each test writes where ITS env var points, not where the first test's pointed
|
||||
try:
|
||||
from backend.apps.agents.browser import browser_metrics as _bm
|
||||
_bm.p_metrics_dir_cache = None
|
||||
@@ -126,8 +125,7 @@ class FakeAgentManager:
|
||||
self.tasks: dict[str, object] = {}
|
||||
self.launched_configs: list[object] = []
|
||||
self.sent_messages: list[str] = []
|
||||
# statuses[i] is the session status after the i-th send_message; absent
|
||||
# entries default to 'completed'. cost_usd lands on the run.
|
||||
# statuses[i] is the session status after the i-th send_message; absent entries default to 'completed'. cost_usd lands on the run.
|
||||
self.statuses: list[str] = []
|
||||
self.cost_usd: float = 0.0
|
||||
|
||||
|
||||
@@ -65,8 +65,7 @@ def main() -> None:
|
||||
# C. The permission gate still binds: a denied server is never forwarded.
|
||||
ok &= prove("denied(t) => !forwarded(t)", Implies(denied, Not(fwd)))
|
||||
|
||||
# Teeth: the buggy gate (activation check dropped) MUST be refutable, else
|
||||
# the proof above would be vacuous.
|
||||
# Teeth: the buggy gate (activation check dropped) MUST be refutable, else the proof above would be vacuous.
|
||||
print("Sanity-checking the proof has teeth (a buggy gate must be refuted):")
|
||||
bug = buggy_forwarded(installed, allowed, denied, active_is_none, active_t)
|
||||
s = Solver()
|
||||
|
||||
@@ -20,9 +20,7 @@ def client():
|
||||
so the LocalAuthMiddleware doesn't reject our requests with 401."""
|
||||
import backend.auth as auth_mod
|
||||
if not auth_mod.TOKEN:
|
||||
# Tests sometimes run without backend.main's startup hook firing.
|
||||
# Generate a token directly so request_matches_token has something
|
||||
# to compare against.
|
||||
# Tests sometimes run without backend.main's startup hook firing. Generate a token directly so request_matches_token has something to compare against.
|
||||
import secrets
|
||||
auth_mod.TOKEN = secrets.token_urlsafe(32)
|
||||
return TestClient(app, headers={"Authorization": f"Bearer {auth_mod.TOKEN}"})
|
||||
@@ -38,9 +36,7 @@ def reset_settings():
|
||||
save_settings(original)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /api/auth/signin-activate
|
||||
# ---------------------------------------------------------------------------
|
||||
# --------------------------------------------------------------------------- /api/auth/signin-activate ---------------------------------------------------------------------------
|
||||
|
||||
def test_signin_activate_persists_user_id(client, reset_settings):
|
||||
fake_response = AsyncMock()
|
||||
@@ -135,9 +131,7 @@ def test_signin_activate_short_token_rejected_locally(client, reset_settings):
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /api/auth/signout
|
||||
# ---------------------------------------------------------------------------
|
||||
# --------------------------------------------------------------------------- /api/auth/signout ---------------------------------------------------------------------------
|
||||
|
||||
def test_signout_clears_local_identity(client, reset_settings):
|
||||
from backend.apps.settings.settings import load_settings, save_settings
|
||||
@@ -186,9 +180,7 @@ def test_signout_succeeds_even_when_cloud_unreachable(client, reset_settings):
|
||||
assert s2.openswarm_bearer_token is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The dev-token handoff must be dev-only so it can't widen prod surface (#49).
|
||||
# ---------------------------------------------------------------------------
|
||||
# --------------------------------------------------------------------------- The dev-token handoff must be dev-only so it can't widen prod surface (#49). ---------------------------------------------------------------------------
|
||||
|
||||
def test_dev_token_is_dev_only():
|
||||
"""/api/dev/token hands the install token to the split-port dev frontend
|
||||
|
||||
@@ -90,24 +90,20 @@ def p_install(monkeypatch, primary, aux):
|
||||
|
||||
async def p_send_browser_command(request_id, action, browser_id, params, tab_id=""):
|
||||
sent.append({"action": action, "params": params})
|
||||
# smart-wait probes via evaluate; report 'settled' so BrowserWait returns
|
||||
# fast in tests instead of riding the full cap.
|
||||
# smart-wait probes via evaluate; report 'settled' so BrowserWait returns fast in tests instead of riding the full cap.
|
||||
if action == "evaluate" and "getEntriesByType('resource')" in str(params.get("expression", "")):
|
||||
expr = str(params.get("expression", ""))
|
||||
# a confirm/target probe embeds a non-empty `const spec="..."`; report it found
|
||||
found = "const spec=" in expr and 'const spec=""' not in expr
|
||||
return {"text": json.dumps({"ready": True, "quiet": 9999, "elems": 100, "found": found}), "url": DOC_URL}
|
||||
# generic evaluate echoes its expression so distinct reads yield distinct
|
||||
# results (lets a test exercise new-data-each-turn gather vs spinning)
|
||||
# generic evaluate echoes its expression so distinct reads yield distinct results (lets a test exercise new-data-each-turn gather vs spinning)
|
||||
if action == "evaluate":
|
||||
return {"text": f"eval:{str(params.get('expression',''))[:120]}", "url": DOC_URL}
|
||||
if action == "list_interactives":
|
||||
# a non-irreversible label on purpose: Send/Submit-named steps are
|
||||
# refused by the replay send-gate, which has its own test below
|
||||
# a non-irreversible label on purpose: Send/Submit-named steps are refused by the replay send-gate, which has its own test below
|
||||
return {"text": '1 interactive elements:\n[1]<button "Search">', "url": DOC_URL}
|
||||
if action == "click_index":
|
||||
# frontend surfaces the clicked element's role/name for skill recording;
|
||||
# index 99 is the test sentinel for the irreversible "Send" button
|
||||
# frontend surfaces the clicked element's role/name for skill recording; index 99 is the test sentinel for the irreversible "Send" button
|
||||
p_nm = "Send" if params.get("index") == 99 else "Search"
|
||||
return {"text": f"Clicked index {params.get('index')}", "url": DOC_URL, "clickedRole": "button", "clickedName": p_nm}
|
||||
if action == "click_by_name":
|
||||
@@ -153,8 +149,7 @@ def test_full_loop_goal_stagnation_adjudication_and_hint_write(monkeypatch):
|
||||
))
|
||||
assert result["browser_id"] == "b1"
|
||||
|
||||
# 1) goal threaded into the loop's list_interactives call (a no-goal perception
|
||||
# front-load may precede it now, so assert SOME call carries the goal)
|
||||
# 1) goal threaded into the loop's list_interactives call (a no-goal perception front-load may precede it now, so assert SOME call carries the goal)
|
||||
list_calls = [c for c in sent if c["action"] == "list_interactives"]
|
||||
assert any(c["params"].get("goal") == "click the Search button" for c in list_calls)
|
||||
|
||||
@@ -171,9 +166,7 @@ def test_full_loop_goal_stagnation_adjudication_and_hint_write(monkeypatch):
|
||||
|
||||
|
||||
def test_action_with_expect_is_confirmed(monkeypatch):
|
||||
# An action that declares `expect` is CONFIRMED after it runs: the loop issues a
|
||||
# target-aware confirm probe and feeds the next turn a tool_result stating the
|
||||
# expected change is present (observed success, never assumed).
|
||||
# An action that declares `expect` is CONFIRMED after it runs: the loop issues a target-aware confirm probe and feeds the next turn a tool_result stating the expected change is present (observed success, never assumed).
|
||||
BH.BROWSER_HISTORY.clear(); BH.DOMAIN_NOTES.clear()
|
||||
primary = FakeLLM([
|
||||
Resp([p_rp("click submit and confirm"),
|
||||
@@ -194,8 +187,7 @@ def test_action_with_expect_is_confirmed(monkeypatch):
|
||||
|
||||
|
||||
def test_missing_report_progress_runs_the_action_and_reminds_not_rejects(monkeypatch):
|
||||
# The model acts WITHOUT ReportProgress. Old behavior rejected the turn (wasted
|
||||
# a round-trip); new behavior runs the action and folds in a one-line reminder.
|
||||
# The model acts WITHOUT ReportProgress. Old behavior rejected the turn (wasted a round-trip); new behavior runs the action and folds in a one-line reminder.
|
||||
BH.BROWSER_HISTORY.clear(); BH.DOMAIN_NOTES.clear()
|
||||
primary = FakeLLM([
|
||||
Resp([p_tu("BrowserClickIndex", index=2)]), # NO ReportProgress this turn
|
||||
@@ -215,9 +207,7 @@ def test_missing_report_progress_runs_the_action_and_reminds_not_rejects(monkeyp
|
||||
|
||||
|
||||
def test_confirmed_send_ends_the_run_instead_of_stalling(monkeypatch):
|
||||
# After an irreversible send CONFIRMS, the model must not burn turns re-verifying.
|
||||
# Here it sends (index 99 = "Send", expect confirms) then tries to stall forever
|
||||
# with pure-perception turns; the loop must END within a turn or two, not spin.
|
||||
# After an irreversible send CONFIRMS, the model must not burn turns re-verifying. Here it sends (index 99 = "Send", expect confirms) then tries to stall forever with pure-perception turns; the loop must END within a turn or two, not spin.
|
||||
BH.BROWSER_HISTORY.clear(); BH.DOMAIN_NOTES.clear()
|
||||
primary = FakeLLM([
|
||||
Resp([p_rp("send the message"), p_tu("BrowserClickIndex", index=99, expect="Sent")]),
|
||||
@@ -230,8 +220,7 @@ def test_confirmed_send_ends_the_run_instead_of_stalling(monkeypatch):
|
||||
|
||||
result = asyncio.run(BA.run_browser_agent(task="text Tyler hello", browser_id="b1", model="sonnet"))
|
||||
|
||||
# the send ran and the run ended FAST (the stall guard stopped it), well before
|
||||
# consuming all 8 scripted stall turns
|
||||
# the send ran and the run ended FAST (the stall guard stopped it), well before consuming all 8 scripted stall turns
|
||||
assert any(c["action"] == "click_index" and c["params"].get("index") == 99 for c in sent)
|
||||
assert primary.turn <= 4, f"run stalled {primary.turn} turns after a confirmed send"
|
||||
# structured success + a clean human summary, never the internal tag
|
||||
@@ -241,8 +230,7 @@ def test_confirmed_send_ends_the_run_instead_of_stalling(monkeypatch):
|
||||
|
||||
|
||||
def test_done_tool_delivers_a_clean_human_summary(monkeypatch):
|
||||
# Canonical finish: the model calls Done(message); that message is the user's
|
||||
# reply verbatim (no OUTCOME tag, no UI mechanics) and `done` is True.
|
||||
# Canonical finish: the model calls Done(message); that message is the user's reply verbatim (no OUTCOME tag, no UI mechanics) and `done` is True.
|
||||
BH.BROWSER_HISTORY.clear(); BH.DOMAIN_NOTES.clear()
|
||||
primary = FakeLLM([
|
||||
Resp([p_rp("open profile + send"), p_tu("BrowserClickIndex", index=5, expect="Sent")]),
|
||||
@@ -257,8 +245,7 @@ def test_done_tool_delivers_a_clean_human_summary(monkeypatch):
|
||||
|
||||
|
||||
def test_done_tool_success_false_marks_not_done(monkeypatch):
|
||||
# Done(success=false) is the honest "couldn't finish": done is False so the
|
||||
# fast path knows to recover, and the message still reads like a person wrote it.
|
||||
# Done(success=false) is the honest "couldn't finish": done is False so the fast path knows to recover, and the message still reads like a person wrote it.
|
||||
BH.BROWSER_HISTORY.clear(); BH.DOMAIN_NOTES.clear()
|
||||
primary = FakeLLM([
|
||||
Resp([p_rp("look for thread"), p_tu("BrowserClickIndex", index=3)]),
|
||||
@@ -272,9 +259,7 @@ def test_done_tool_success_false_marks_not_done(monkeypatch):
|
||||
|
||||
|
||||
def test_run_that_never_calls_done_is_not_a_clean_success(monkeypatch):
|
||||
# A run that does real work but stops with plain text (never calls Done) is a
|
||||
# half-finish, not a clean success: done must be False so the fast path recovers
|
||||
# instead of shipping a silent stop (the 'Task completed.' that wasn't).
|
||||
# A run that does real work but stops with plain text (never calls Done) is a half-finish, not a clean success: done must be False so the fast path recovers instead of shipping a silent stop (the 'Task completed.' that wasn't).
|
||||
BH.BROWSER_HISTORY.clear(); BH.DOMAIN_NOTES.clear()
|
||||
primary = FakeLLM([
|
||||
Resp([p_rp("click it"), p_tu("BrowserClickIndex", index=3)]),
|
||||
@@ -287,11 +272,7 @@ def test_run_that_never_calls_done_is_not_a_clean_success(monkeypatch):
|
||||
|
||||
|
||||
def test_send_shortcut_does_not_arm_on_a_gather_task(monkeypatch):
|
||||
# The Airbnb bug: a send-class click (here the index-99 sentinel = "Send", same
|
||||
# as a cookie "Accept all" tripping the detector) on a FIND/gather task must NOT
|
||||
# arm the send-completion shortcut, there is no send to confirm. If it did, the
|
||||
# run cuts at the 2-turn post-send limit and leaks the canned "message went
|
||||
# through" line. On a gather task it should run the full perception budget.
|
||||
# The Airbnb bug: a send-class click (here the index-99 sentinel = "Send", same as a cookie "Accept all" tripping the detector) on a FIND/gather task must NOT arm the send-completion shortcut, there is no send to confirm. If it did, the run cuts at the 2-turn post-send limit and leaks the canned "message went through" line. On a gather task it should run the full perception budget.
|
||||
BH.BROWSER_HISTORY.clear(); BH.DOMAIN_NOTES.clear()
|
||||
primary = FakeLLM([
|
||||
Resp([p_rp("dismiss the cookie banner"), p_tu("BrowserClickIndex", index=99)]),
|
||||
@@ -301,16 +282,13 @@ def test_send_shortcut_does_not_arm_on_a_gather_task(monkeypatch):
|
||||
aux = FakeAux()
|
||||
p_install(monkeypatch, primary, aux)
|
||||
result = asyncio.run(BA.run_browser_agent(task="find me the top 10 repos", browser_id="b1", model="sonnet"))
|
||||
# the send shortcut never armed: it ran past the 2-turn post-send cutoff toward
|
||||
# the 6-turn perception budget, and no send-confirmation line leaked
|
||||
# the send shortcut never armed: it ran past the 2-turn post-send cutoff toward the 6-turn perception budget, and no send-confirmation line leaked
|
||||
assert primary.turn >= 6, f"gather task cut short at turn {primary.turn} (send shortcut wrongly armed)"
|
||||
assert "went through" not in result["summary"]
|
||||
|
||||
|
||||
def test_browser_save_data_writes_a_file_and_returns_a_receipt(monkeypatch, tmp_path):
|
||||
# BrowserSaveData should run the JS, write the result to a sandboxed file, and
|
||||
# return a path receipt (NOT the data), so a big list lands in one step instead
|
||||
# of a dozen reply-chunks. The mock's evaluate echoes its expression as the data.
|
||||
# BrowserSaveData should run the JS, write the result to a sandboxed file, and return a path receipt (NOT the data), so a big list lands in one step instead of a dozen reply-chunks. The mock's evaluate echoes its expression as the data.
|
||||
import os as p_os
|
||||
monkeypatch.setattr(p_os.path, "expanduser", lambda p: str(tmp_path)) # fallback workspace -> tmp
|
||||
BH.BROWSER_HISTORY.clear(); BH.DOMAIN_NOTES.clear()
|
||||
@@ -325,10 +303,7 @@ def test_browser_save_data_writes_a_file_and_returns_a_receipt(monkeypatch, tmp_
|
||||
saved = list(tmp_path.glob("**/browser-data/rows.json"))
|
||||
assert saved, "BrowserSaveData did not write the file"
|
||||
assert result.get("done") is True
|
||||
# The Airbnb regression: a page-by-page gather (a fresh Extract returning NEW
|
||||
# listings every turn) must NOT trip the spin backstop, gathering is the work,
|
||||
# not spinning. Here 9 straight Extract turns each return distinct data; the run
|
||||
# should keep going (no early wrap-up nudge) and finish on the model's own Done.
|
||||
# The Airbnb regression: a page-by-page gather (a fresh Extract returning NEW listings every turn) must NOT trip the spin backstop, gathering is the work, not spinning. Here 9 straight Extract turns each return distinct data; the run should keep going (no early wrap-up nudge) and finish on the model's own Done.
|
||||
BH.BROWSER_HISTORY.clear(); BH.DOMAIN_NOTES.clear()
|
||||
primary = FakeLLM([
|
||||
# each turn reads a DIFFERENT page (distinct expression -> distinct result)
|
||||
@@ -338,17 +313,14 @@ def test_browser_save_data_writes_a_file_and_returns_a_receipt(monkeypatch, tmp_
|
||||
aux = FakeAux()
|
||||
p_install(monkeypatch, primary, aux)
|
||||
result = asyncio.run(BA.run_browser_agent(task="find me all the airbnbs in sf", browser_id="b1", model="sonnet"))
|
||||
# it ran the full gather (all 9 extract turns) and finished on its own Done,
|
||||
# NOT cut short by a wrap-up nudge at turn 6
|
||||
# it ran the full gather (all 9 extract turns) and finished on its own Done, NOT cut short by a wrap-up nudge at turn 6
|
||||
assert primary.turn >= 9, f"gather cut short at turn {primary.turn} (new-data reads wrongly counted as spinning)"
|
||||
assert "Gathered all pages" in result["summary"]
|
||||
assert result.get("done") is True
|
||||
|
||||
|
||||
def test_spin_backstop_nudges_a_clean_wrapup_instead_of_a_midthought(monkeypatch):
|
||||
# The Airbnb mid-thought bug: a read-heavy run that trips the spin backstop must
|
||||
# get ONE wrap-up nudge to summarize via Done, not be cut off mid-sentence. The
|
||||
# final reply is the model's clean Done answer, and the nudge actually reached it.
|
||||
# The Airbnb mid-thought bug: a read-heavy run that trips the spin backstop must get ONE wrap-up nudge to summarize via Done, not be cut off mid-sentence. The final reply is the model's clean Done answer, and the nudge actually reached it.
|
||||
BH.BROWSER_HISTORY.clear(); BH.DOMAIN_NOTES.clear()
|
||||
primary = FakeLLM([
|
||||
Resp([p_rp("open the list"), p_tu("BrowserClickIndex", index=3)]), # an action arms the backstop
|
||||
@@ -365,12 +337,9 @@ def test_spin_backstop_nudges_a_clean_wrapup_instead_of_a_midthought(monkeypatch
|
||||
|
||||
|
||||
def test_early_perception_is_not_cut_short_before_any_action(monkeypatch):
|
||||
# Orienting on a cold/slow page can take several look-only turns; the stall
|
||||
# backstop must NOT fire before the agent has done anything (it only bounds a
|
||||
# POST-action spin). Here 7 perception turns precede the finish; all must run.
|
||||
# Orienting on a cold/slow page can take several look-only turns; the stall backstop must NOT fire before the agent has done anything (it only bounds a POST-action spin). Here 7 perception turns precede the finish; all must run.
|
||||
BH.BROWSER_HISTORY.clear(); BH.DOMAIN_NOTES.clear()
|
||||
# varied read tools so the (separate) identical-repeat loop detector doesn't trip;
|
||||
# this isolates the stall backstop, which must NOT fire pre-action
|
||||
# varied read tools so the (separate) identical-repeat loop detector doesn't trip; this isolates the stall backstop, which must NOT fire pre-action
|
||||
p_reads = ["BrowserListInteractives", "BrowserGetText", "BrowserScreenshot"]
|
||||
primary = FakeLLM([
|
||||
*[Resp([p_rp("still orienting"), p_tu(p_reads[i % 3])]) for i in range(7)],
|
||||
@@ -384,9 +353,7 @@ def test_early_perception_is_not_cut_short_before_any_action(monkeypatch):
|
||||
|
||||
|
||||
def test_aux_adjudication_fires_even_when_loop_detector_trips(monkeypatch):
|
||||
# Repeated IDENTICAL failing clicks trip the exact-repeat loop detector AND
|
||||
# reach stagnation exhaustion on the same turn. The aux escape hatch must
|
||||
# still fire (it was previously suppressed by the `not is_loop` guard).
|
||||
# Repeated IDENTICAL failing clicks trip the exact-repeat loop detector AND reach stagnation exhaustion on the same turn. The aux escape hatch must still fire (it was previously suppressed by the `not is_loop` guard).
|
||||
BH.BROWSER_HISTORY.clear(); BH.DOMAIN_NOTES.clear()
|
||||
primary = FakeLLM([
|
||||
Resp([p_rp("click submit"), p_tu("BrowserListInteractives")]),
|
||||
@@ -408,8 +375,7 @@ def test_aux_adjudication_fires_even_when_loop_detector_trips(monkeypatch):
|
||||
|
||||
|
||||
def test_tier1_and_tier2_tools_drive_through_the_real_loop(monkeypatch):
|
||||
# The agent can call the new tier-1 (WebMCP detect) and tier-2 (list/replay)
|
||||
# tools through the actual run_browser_agent loop, and replay threads its url.
|
||||
# The agent can call the new tier-1 (WebMCP detect) and tier-2 (list/replay) tools through the actual run_browser_agent loop, and replay threads its url.
|
||||
BH.BROWSER_HISTORY.clear(); BH.DOMAIN_NOTES.clear()
|
||||
primary = FakeLLM([
|
||||
Resp([p_rp("check for a faster path"), p_tu("BrowserDetectWebMCP")]),
|
||||
@@ -434,8 +400,7 @@ def test_tier1_and_tier2_tools_drive_through_the_real_loop(monkeypatch):
|
||||
|
||||
|
||||
def test_skill_is_recorded_then_replayed_with_zero_llm_calls(monkeypatch):
|
||||
# Run 1: full LLM agent completes a click task -> records a skill.
|
||||
# Run 2: same task/host -> replays via the no-LLM fast path (the speed win).
|
||||
# Run 1: full LLM agent completes a click task -> records a skill. Run 2: same task/host -> replays via the no-LLM fast path (the speed win).
|
||||
import backend.apps.agents.browser.browser_skills as SK
|
||||
SK.clear()
|
||||
BH.BROWSER_HISTORY.clear(); BH.DOMAIN_NOTES.clear()
|
||||
@@ -467,8 +432,7 @@ def test_skill_is_recorded_then_replayed_with_zero_llm_calls(monkeypatch):
|
||||
|
||||
|
||||
def test_replay_falls_back_to_full_agent_when_a_step_fails(monkeypatch):
|
||||
# If the page changed and a replay step errors, we must abort replay and run
|
||||
# the full LLM agent instead (never ghost-succeed on a stale skill).
|
||||
# If the page changed and a replay step errors, we must abort replay and run the full LLM agent instead (never ghost-succeed on a stale skill).
|
||||
import backend.apps.agents.browser.browser_skills as SK
|
||||
SK.clear()
|
||||
BH.BROWSER_HISTORY.clear()
|
||||
@@ -498,10 +462,7 @@ def test_replay_falls_back_to_full_agent_when_a_step_fails(monkeypatch):
|
||||
|
||||
|
||||
def test_deferred_replay_fires_after_navigating_to_the_right_host(monkeypatch):
|
||||
# The #30 fix: the orchestrator opens a fresh card on the WRONG host (google),
|
||||
# so the dispatch-time replay check misses. Once the agent navigates to the
|
||||
# host that DOES have a skill, and nothing has dirtied the page yet, the
|
||||
# deferred re-check must switch to replay instead of grinding the LLM loop.
|
||||
# The #30 fix: the orchestrator opens a fresh card on the WRONG host (google), so the dispatch-time replay check misses. Once the agent navigates to the host that DOES have a skill, and nothing has dirtied the page yet, the deferred re-check must switch to replay instead of grinding the LLM loop.
|
||||
import backend.apps.agents.browser.browser_skills as SK
|
||||
SK.clear()
|
||||
BH.BROWSER_HISTORY.clear()
|
||||
@@ -520,8 +481,7 @@ def test_deferred_replay_fires_after_navigating_to_the_right_host(monkeypatch):
|
||||
orig = BA.ws_manager.send_browser_command
|
||||
|
||||
async def p_cmd(request_id, action, browser_id, params, tab_id=""):
|
||||
# perception + reads report GOOGLE (so the DISPATCH replay misses there),
|
||||
# navigation + clicks report the doc host (so the re-check matches)
|
||||
# perception + reads report GOOGLE (so the DISPATCH replay misses there), navigation + clicks report the doc host (so the re-check matches)
|
||||
if action in ("list_interactives", "get_text"):
|
||||
return {"text": "stuff", "url": GOOGLE}
|
||||
return await orig(request_id, action, browser_id, params, tab_id)
|
||||
@@ -539,9 +499,7 @@ def test_deferred_replay_fires_after_navigating_to_the_right_host(monkeypatch):
|
||||
|
||||
|
||||
def test_deferred_replay_does_not_fire_after_the_page_was_dirtied(monkeypatch):
|
||||
# Safety guard: if the agent already typed/clicked before reaching the right
|
||||
# host, replaying from here is NOT equivalent to a clean dispatch (the page
|
||||
# state is dirty), so the re-check must stay disabled and the LLM finishes.
|
||||
# Safety guard: if the agent already typed/clicked before reaching the right host, replaying from here is NOT equivalent to a clean dispatch (the page state is dirty), so the re-check must stay disabled and the LLM finishes.
|
||||
import backend.apps.agents.browser.browser_skills as SK
|
||||
SK.clear()
|
||||
BH.BROWSER_HISTORY.clear()
|
||||
@@ -575,11 +533,7 @@ def test_deferred_replay_does_not_fire_after_the_page_was_dirtied(monkeypatch):
|
||||
|
||||
|
||||
def test_replay_resolves_host_from_live_page_when_no_initial_url(monkeypatch):
|
||||
# The real-flow fix: the parent often delegates to an EXISTING browser card
|
||||
# with no initial_url (and the backend doesn't track where that card
|
||||
# navigated). The agent must perceive the live page, learn its host, and STILL
|
||||
# replay a previously-learned skill. Without this, replay was dead in the real
|
||||
# orchestrated flow (records skills it can never look up again).
|
||||
# The real-flow fix: the parent often delegates to an EXISTING browser card with no initial_url (and the backend doesn't track where that card navigated). The agent must perceive the live page, learn its host, and STILL replay a previously-learned skill. Without this, replay was dead in the real orchestrated flow (records skills it can never look up again).
|
||||
import backend.apps.agents.browser.browser_skills as SK
|
||||
SK.clear()
|
||||
BH.BROWSER_HISTORY.clear()
|
||||
@@ -601,10 +555,7 @@ def test_replay_resolves_host_from_live_page_when_no_initial_url(monkeypatch):
|
||||
|
||||
|
||||
def test_skill_keys_on_parent_user_message_so_reformulations_share_a_skill(monkeypatch):
|
||||
# The measured real-flow blocker: the orchestrator reformulates the same user
|
||||
# request differently each run ("click the search box" vs "find the search
|
||||
# box"), so exact-key replay never hits. Keying on the parent's STABLE user
|
||||
# message instead lets two different reformulations share one skill and replay.
|
||||
# The measured real-flow blocker: the orchestrator reformulates the same user request differently each run ("click the search box" vs "find the search box"), so exact-key replay never hits. Keying on the parent's STABLE user message instead lets two different reformulations share one skill and replay.
|
||||
import backend.apps.agents.browser.browser_skills as SK
|
||||
import backend.apps.agents.agent_manager as am_mod
|
||||
SK.clear()
|
||||
@@ -618,8 +569,7 @@ def test_skill_keys_on_parent_user_message_so_reformulations_share_a_skill(monke
|
||||
messages = [p_Msg("user", 'search Wikipedia for "Ada Lovelace"')]
|
||||
monkeypatch.setattr(am_mod.agent_manager, "get_session", lambda sid: p_Parent(), raising=False)
|
||||
|
||||
# Run 1: ONE reformulation of the request -> learns a skill keyed on the
|
||||
# parent's user message (not this delegated wording).
|
||||
# Run 1: ONE reformulation of the request -> learns a skill keyed on the parent's user message (not this delegated wording).
|
||||
primary1 = FakeLLM([
|
||||
Resp([p_rp("click submit"), p_tu("BrowserListInteractives")]),
|
||||
Resp([p_rp("click it"), p_tu("BrowserClickIndex", index=1)]),
|
||||
@@ -633,8 +583,7 @@ def test_skill_keys_on_parent_user_message_so_reformulations_share_a_skill(monke
|
||||
assert SK.find_skill("docs.google.com", 'search Wikipedia for "Ada Lovelace"') is not None, \
|
||||
"skill must be keyed on the stable parent message, not the delegated reformulation"
|
||||
|
||||
# Run 2: a DIFFERENT reformulation, same parent intent -> must REPLAY (the
|
||||
# exact thing that failed live, now fixed).
|
||||
# Run 2: a DIFFERENT reformulation, same parent intent -> must REPLAY (the exact thing that failed live, now fixed).
|
||||
primary2 = FakeLLM([Resp([Blk("text", "should not be needed")], stop_reason="end_turn")])
|
||||
sent = p_install(monkeypatch, primary2, FakeAux())
|
||||
r = asyncio.run(BA.run_browser_agent(
|
||||
@@ -646,8 +595,7 @@ def test_skill_keys_on_parent_user_message_so_reformulations_share_a_skill(monke
|
||||
|
||||
|
||||
def test_skill_key_falls_back_to_delegated_task_on_multi_quote_message(monkeypatch):
|
||||
# Guard against same-host collisions: a user message with several quoted
|
||||
# values could spawn several same-host sub-tasks that must NOT share one key.
|
||||
# Guard against same-host collisions: a user message with several quoted values could spawn several same-host sub-tasks that must NOT share one key.
|
||||
import backend.apps.agents.browser.browser_skills as SK
|
||||
import backend.apps.agents.agent_manager as am_mod
|
||||
SK.clear()
|
||||
@@ -676,8 +624,7 @@ def test_skill_key_falls_back_to_delegated_task_on_multi_quote_message(monkeypat
|
||||
|
||||
|
||||
def test_replay_success_promotes_skill_to_trusted_through_the_loop(monkeypatch):
|
||||
# The verify gate, end to end: run 1 learns a PROBATION skill; run 2 replays
|
||||
# it successfully, which must PROMOTE it to trusted (proven by a real replay).
|
||||
# The verify gate, end to end: run 1 learns a PROBATION skill; run 2 replays it successfully, which must PROMOTE it to trusted (proven by a real replay).
|
||||
import backend.apps.agents.browser.browser_skills as SK
|
||||
SK.clear()
|
||||
BH.BROWSER_HISTORY.clear(); BH.DOMAIN_NOTES.clear()
|
||||
@@ -700,9 +647,7 @@ def test_replay_success_promotes_skill_to_trusted_through_the_loop(monkeypatch):
|
||||
|
||||
|
||||
def test_skill_with_send_step_never_replays_silently(monkeypatch):
|
||||
# The audit finding: replay bypasses act-and-confirm and the per-tool gate,
|
||||
# so a recorded Send/Submit must NOT auto-replay; the live agent (which
|
||||
# confirms before anything outward) runs instead, and trust is untouched.
|
||||
# The audit finding: replay bypasses act-and-confirm and the per-tool gate, so a recorded Send/Submit must NOT auto-replay; the live agent (which confirms before anything outward) runs instead, and trust is untouched.
|
||||
import backend.apps.agents.browser.browser_skills as SK
|
||||
SK.clear()
|
||||
BH.BROWSER_HISTORY.clear()
|
||||
@@ -723,9 +668,7 @@ def test_skill_with_send_step_never_replays_silently(monkeypatch):
|
||||
|
||||
|
||||
def test_unproven_skill_that_fails_is_quarantined_and_never_retried(monkeypatch):
|
||||
# The anti-ghost guard, end to end: an unproven skill that fails a replay must
|
||||
# be quarantined so the NEXT run does not even attempt the (known-bad) replay,
|
||||
# it goes straight to the pure-LLM baseline. A silent re-fail would be a ghost.
|
||||
# The anti-ghost guard, end to end: an unproven skill that fails a replay must be quarantined so the NEXT run does not even attempt the (known-bad) replay, it goes straight to the pure-LLM baseline. A silent re-fail would be a ghost.
|
||||
import backend.apps.agents.browser.browser_skills as SK
|
||||
SK.clear()
|
||||
BH.BROWSER_HISTORY.clear()
|
||||
@@ -763,10 +706,7 @@ def test_unproven_skill_that_fails_is_quarantined_and_never_retried(monkeypatch)
|
||||
|
||||
|
||||
def test_informational_run_records_no_skill_to_avoid_thin_ghost(monkeypatch):
|
||||
# The 'find me 10 X' guard: a run that did real productive actions AND
|
||||
# succeeded, but whose deliverable is gathered/judged content (a list), must
|
||||
# NOT record a replayable skill, because replay would redo the clicks and
|
||||
# falsely claim the whole task done without regenerating the judged list.
|
||||
# The 'find me 10 X' guard: a run that did real productive actions AND succeeded, but whose deliverable is gathered/judged content (a list), must NOT record a replayable skill, because replay would redo the clicks and falsely claim the whole task done without regenerating the judged list.
|
||||
import backend.apps.agents.browser.browser_skills as SK
|
||||
SK.clear()
|
||||
BH.BROWSER_HISTORY.clear()
|
||||
@@ -786,10 +726,7 @@ def test_informational_run_records_no_skill_to_avoid_thin_ghost(monkeypatch):
|
||||
|
||||
|
||||
def test_read_answered_from_frontloaded_perception_is_not_a_ghost(monkeypatch):
|
||||
# REGRESSION: front-loading reads perception into turn 1; if the agent answers
|
||||
# a read task straight from that (zero further tools), the honesty gate must
|
||||
# NOT flag it as 'declared done without taking a single action'. The front-
|
||||
# loaded reads are real and seed action_log. (This bug caused retry loops.)
|
||||
# REGRESSION: front-loading reads perception into turn 1; if the agent answers a read task straight from that (zero further tools), the honesty gate must NOT flag it as 'declared done without taking a single action'. The front- loaded reads are real and seed action_log. (This bug caused retry loops.)
|
||||
BH.BROWSER_HISTORY.clear()
|
||||
primary = FakeLLM([
|
||||
# the model answers immediately from the front-loaded page text, no tools
|
||||
@@ -814,9 +751,7 @@ def test_read_answered_from_frontloaded_perception_is_not_a_ghost(monkeypatch):
|
||||
|
||||
|
||||
def test_ghost_completion_is_reported_as_error_not_completed(monkeypatch):
|
||||
# The measured ghost, end to end: the model does a bunch of failing clicks
|
||||
# then declares done. The honesty gate must report 'error' (not 'completed')
|
||||
# and must NOT record a skill from a run that accomplished nothing.
|
||||
# The measured ghost, end to end: the model does a bunch of failing clicks then declares done. The honesty gate must report 'error' (not 'completed') and must NOT record a skill from a run that accomplished nothing.
|
||||
import backend.apps.agents.browser.browser_skills as SK
|
||||
SK.clear()
|
||||
BH.BROWSER_HISTORY.clear()
|
||||
@@ -849,9 +784,7 @@ def test_ghost_completion_is_reported_as_error_not_completed(monkeypatch):
|
||||
|
||||
|
||||
def test_dead_browser_card_aborts_fast_without_spinning(monkeypatch):
|
||||
# The measured waste: a sub-agent dispatched to a released card retried the
|
||||
# dead webview for many turns. Now a gone card must abort fast (a couple of
|
||||
# turns, not the whole budget) and report the precise reason.
|
||||
# The measured waste: a sub-agent dispatched to a released card retried the dead webview for many turns. Now a gone card must abort fast (a couple of turns, not the whole budget) and report the precise reason.
|
||||
import backend.apps.agents.browser.browser_skills as SK
|
||||
SK.clear()
|
||||
BH.BROWSER_HISTORY.clear()
|
||||
@@ -884,10 +817,7 @@ def test_dead_browser_card_aborts_fast_without_spinning(monkeypatch):
|
||||
|
||||
|
||||
def test_hung_browser_card_aborts_fast_not_a_20_minute_loop(monkeypatch):
|
||||
# THE regression from the user's 20-min LinkedIn freeze: a HUNG tab returns
|
||||
# "Browser command timed out" on every command (not "card not found"), so the
|
||||
# gone-detector never tripped and the agent spun for minutes. Now a hung card
|
||||
# feeds the same fast-fail streak and aborts in a couple of turns.
|
||||
# THE regression from the user's 20-min LinkedIn freeze: a HUNG tab returns "Browser command timed out" on every command (not "card not found"), so the gone-detector never tripped and the agent spun for minutes. Now a hung card feeds the same fast-fail streak and aborts in a couple of turns.
|
||||
import backend.apps.agents.browser.browser_skills as SK
|
||||
SK.clear()
|
||||
BH.BROWSER_HISTORY.clear()
|
||||
@@ -918,9 +848,7 @@ def test_hung_browser_card_aborts_fast_not_a_20_minute_loop(monkeypatch):
|
||||
|
||||
|
||||
def test_perception_is_frontloaded_into_first_turn(monkeypatch):
|
||||
# With a known start URL, the agent should prefetch the element list + page
|
||||
# text and put them in the FIRST user message, so the model can act on turn 1
|
||||
# instead of spending early turns orienting.
|
||||
# With a known start URL, the agent should prefetch the element list + page text and put them in the FIRST user message, so the model can act on turn 1 instead of spending early turns orienting.
|
||||
BH.BROWSER_HISTORY.clear(); BH.DOMAIN_NOTES.clear()
|
||||
primary = FakeLLM([Resp([Blk("text", "done")], stop_reason="end_turn")])
|
||||
aux = FakeAux()
|
||||
@@ -936,9 +864,7 @@ def test_perception_is_frontloaded_into_first_turn(monkeypatch):
|
||||
|
||||
|
||||
def test_prompt_caching_markers_present(monkeypatch):
|
||||
# The fixed system+tools prefix must carry cache_control so it's cached
|
||||
# across turns (the first-run speed/cost win). Without the marker the
|
||||
# ~4k-token prefix is reprocessed every turn.
|
||||
# The fixed system+tools prefix must carry cache_control so it's cached across turns (the first-run speed/cost win). Without the marker the ~4k-token prefix is reprocessed every turn.
|
||||
BH.BROWSER_HISTORY.clear(); BH.DOMAIN_NOTES.clear()
|
||||
primary = FakeLLM([Resp([Blk("text", "done")], stop_reason="end_turn")])
|
||||
aux = FakeAux()
|
||||
@@ -954,8 +880,7 @@ def test_prompt_caching_markers_present(monkeypatch):
|
||||
|
||||
|
||||
def test_agent_can_list_and_deprecate_its_own_skills(monkeypatch):
|
||||
# The agent calls BrowserListSkills + BrowserDeprecateSkill inline (backend-
|
||||
# handled, never sent to the webview), giving it agency over its own memory.
|
||||
# The agent calls BrowserListSkills + BrowserDeprecateSkill inline (backend- handled, never sent to the webview), giving it agency over its own memory.
|
||||
import backend.apps.agents.browser.browser_skills as SK
|
||||
SK.clear()
|
||||
BH.BROWSER_HISTORY.clear()
|
||||
@@ -984,10 +909,7 @@ def test_agent_can_list_and_deprecate_its_own_skills(monkeypatch):
|
||||
|
||||
|
||||
def test_playbook_distills_on_success_survives_restart_and_seeds_next_run(monkeypatch):
|
||||
# The tier-2 memory, end to end: a substantive judgment run distills a durable
|
||||
# strategy playbook (one aux call), it persists across a restart, and the NEXT
|
||||
# run on the same host gets it seeded into the system prompt, so the model
|
||||
# skips re-discovery. This is what makes LinkedIn-style tasks wiser over time.
|
||||
# The tier-2 memory, end to end: a substantive judgment run distills a durable strategy playbook (one aux call), it persists across a restart, and the NEXT run on the same host gets it seeded into the system prompt, so the model skips re-discovery. This is what makes LinkedIn-style tasks wiser over time.
|
||||
import backend.apps.agents.browser.browser_playbook as PB
|
||||
import backend.apps.agents.browser.browser_skills as SK
|
||||
import json as p_json
|
||||
@@ -1040,9 +962,7 @@ def test_playbook_distills_on_success_survives_restart_and_seeds_next_run(monkey
|
||||
|
||||
|
||||
def test_ambient_memory_signals_fire_calmly(monkeypatch):
|
||||
# Perceived value, zero clicks: the user should SEE the agent (a) pick up what
|
||||
# it learned when strategy is seeded, and (b) note new learning at the end,
|
||||
# both as calm one-liners in the existing stream, only when real.
|
||||
# Perceived value, zero clicks: the user should SEE the agent (a) pick up what it learned when strategy is seeded, and (b) note new learning at the end, both as calm one-liners in the existing stream, only when real.
|
||||
import backend.apps.agents.browser.browser_playbook as PB
|
||||
import backend.apps.agents.browser.browser_skills as SK
|
||||
import json as p_json
|
||||
@@ -1090,8 +1010,7 @@ def test_ambient_memory_signals_fire_calmly(monkeypatch):
|
||||
|
||||
|
||||
def test_playbook_not_learned_from_a_ghost_completion(monkeypatch):
|
||||
# Fail-safe: a dishonest 'completion' (all actions errored) must NOT distill a
|
||||
# playbook, garbage strategy from a failed run would mislead future runs.
|
||||
# Fail-safe: a dishonest 'completion' (all actions errored) must NOT distill a playbook, garbage strategy from a failed run would mislead future runs.
|
||||
import backend.apps.agents.browser.browser_playbook as PB
|
||||
import backend.apps.agents.browser.browser_skills as SK
|
||||
SK.clear(); PB.clear(wipe_disk=True)
|
||||
@@ -1118,14 +1037,12 @@ def test_playbook_not_learned_from_a_ghost_completion(monkeypatch):
|
||||
asyncio.run(BA.run_browser_agent(
|
||||
task="do the thing", browser_id="b1", model="sonnet", initial_url=DOC_URL,
|
||||
))
|
||||
# the only aux call allowed here is the stuck-adjudication; the playbook distill
|
||||
# must NOT have stored anything for a dishonest run
|
||||
# the only aux call allowed here is the stuck-adjudication; the playbook distill must NOT have stored anything for a dishonest run
|
||||
assert PB.get_playbook("docs.google.com") == []
|
||||
|
||||
|
||||
def test_batch_replay_runs_a_read_loop_for_all_values(monkeypatch):
|
||||
# The win: do one item the slow way, then BrowserRepeatFlow runs the same
|
||||
# read flow for the rest at machine speed, one tool turn, no screenshots.
|
||||
# The win: do one item the slow way, then BrowserRepeatFlow runs the same read flow for the rest at machine speed, one tool turn, no screenshots.
|
||||
BH.BROWSER_HISTORY.clear()
|
||||
steps = [{"action": "navigate", "url": "https://docs.google.com/in/{{value}}"},
|
||||
{"action": "evaluate", "expression": "read('{{value}}')"}]
|
||||
@@ -1156,9 +1073,7 @@ def test_batch_replay_runs_a_read_loop_for_all_values(monkeypatch):
|
||||
|
||||
|
||||
def test_batch_replay_is_ghost_proof_when_an_item_does_not_match(monkeypatch):
|
||||
# THE anti-ghost test: per-item pages vary. Value 'grace' errors mid-flow ->
|
||||
# it must be reported as needs-manual, the others still succeed, and the tally
|
||||
# is HONEST ('2 of 3'), never a silent 'did them all'.
|
||||
# THE anti-ghost test: per-item pages vary. Value 'grace' errors mid-flow -> it must be reported as needs-manual, the others still succeed, and the tally is HONEST ('2 of 3'), never a silent 'did them all'.
|
||||
BH.BROWSER_HISTORY.clear()
|
||||
steps = [{"action": "navigate", "url": "https://docs.google.com/in/{{value}}"},
|
||||
{"action": "evaluate", "expression": "read('{{value}}')"}]
|
||||
@@ -1189,8 +1104,7 @@ def test_batch_replay_is_ghost_proof_when_an_item_does_not_match(monkeypatch):
|
||||
|
||||
|
||||
def test_batch_replay_refuses_a_send_loop_and_executes_nothing(monkeypatch):
|
||||
# The send gate: a flow that clicks 'Send message' must be REFUSED outright,
|
||||
# nothing is clicked, so we can never auto-message N people.
|
||||
# The send gate: a flow that clicks 'Send message' must be REFUSED outright, nothing is clicked, so we can never auto-message N people.
|
||||
BH.BROWSER_HISTORY.clear()
|
||||
steps = [{"action": "navigate", "url": "https://docs.google.com/in/{{value}}"},
|
||||
{"action": "click", "role": "button", "name": "Message"},
|
||||
@@ -1210,8 +1124,7 @@ def test_batch_replay_refuses_a_send_loop_and_executes_nothing(monkeypatch):
|
||||
|
||||
|
||||
def test_batch_replay_uses_the_fast_network_route_per_value(monkeypatch):
|
||||
# Folds in the audit finding: a read-loop can hit a captured API endpoint
|
||||
# (replay_route) per value instead of clicking the UI, the fast tier.
|
||||
# Folds in the audit finding: a read-loop can hit a captured API endpoint (replay_route) per value instead of clicking the UI, the fast tier.
|
||||
BH.BROWSER_HISTORY.clear()
|
||||
steps = [{"action": "replay_route", "url": "https://docs.google.com/api/p?u={{value}}"}]
|
||||
primary = FakeLLM([
|
||||
@@ -1225,9 +1138,7 @@ def test_batch_replay_uses_the_fast_network_route_per_value(monkeypatch):
|
||||
|
||||
|
||||
def test_captured_routes_are_surfaced_once_per_host(monkeypatch):
|
||||
# Drives the dead network tier: when a READ shows safe GET routes were captured
|
||||
# (sampled on get_text, after the SPA's XHRs fired, not on navigate), the agent
|
||||
# gets a ONE-TIME nudge per host toward BrowserReplayRoute, not on every read.
|
||||
# Drives the dead network tier: when a READ shows safe GET routes were captured (sampled on get_text, after the SPA's XHRs fired, not on navigate), the agent gets a ONE-TIME nudge per host toward BrowserReplayRoute, not on every read.
|
||||
BH.BROWSER_HISTORY.clear()
|
||||
primary = FakeLLM([
|
||||
Resp([p_rp("read 1"), p_tu("BrowserEvaluate", expression="document.title")]),
|
||||
@@ -1244,15 +1155,13 @@ def test_captured_routes_are_surfaced_once_per_host(monkeypatch):
|
||||
monkeypatch.setattr(BA.ws_manager, "send_browser_command", p_with_routes, raising=False)
|
||||
|
||||
asyncio.run(BA.run_browser_agent(task="browse", browser_id="b1", model="sonnet", initial_url=DOC_URL))
|
||||
# messages are cumulative across calls, so count within ONE call's full
|
||||
# conversation: the nudge must appear exactly once for docs.google.com (not per read)
|
||||
# messages are cumulative across calls, so count within ONE call's full conversation: the nudge must appear exactly once for docs.google.com (not per read)
|
||||
final_convo = json.dumps(primary.calls[-1]["messages"])
|
||||
assert final_convo.count("API endpoint(s) were captured") == 1
|
||||
|
||||
|
||||
def test_browser_wait_routes_through_smart_wait_and_returns_early(monkeypatch):
|
||||
# BrowserWait must no longer be a blind sleep: it probes the page (evaluate)
|
||||
# and returns as soon as it's settled, well under the requested cap.
|
||||
# BrowserWait must no longer be a blind sleep: it probes the page (evaluate) and returns as soon as it's settled, well under the requested cap.
|
||||
BH.BROWSER_HISTORY.clear()
|
||||
primary = FakeLLM([
|
||||
Resp([p_rp("let it settle"), p_tu("BrowserWait", milliseconds=8000)]),
|
||||
@@ -1293,9 +1202,7 @@ def test_prior_domain_hint_is_seeded_into_system_prompt(monkeypatch):
|
||||
|
||||
|
||||
def test_find_reusable_card_reuses_own_then_orphan_never_user(monkeypatch):
|
||||
# Concurrent same-site webviews wedge each other, so a re-dispatch must
|
||||
# reuse the parent's own (or an orphaned) spawned card instead of stacking
|
||||
# another. User-created cards (no spawned_by) are never grabbed implicitly.
|
||||
# Concurrent same-site webviews wedge each other, so a re-dispatch must reuse the parent's own (or an orphaned) spawned card instead of stacking another. User-created cards (no spawned_by) are never grabbed implicitly.
|
||||
import backend.apps.dashboards.dashboards as dash_mod
|
||||
import backend.apps.agents.agent_manager as am_mod
|
||||
|
||||
@@ -1569,8 +1476,7 @@ def test_message_pairing_validator_catches_both_orphan_and_dangling():
|
||||
|
||||
|
||||
def test_composer_fill_detection():
|
||||
# detecting a composer fill is what arms the post-type wait for the Send button
|
||||
# to render before we re-list (so the model sees it instead of hunting)
|
||||
# detecting a composer fill is what arms the post-type wait for the Send button to render before we re-list (so the model sees it instead of hunting)
|
||||
from backend.apps.agents.browser.browser_agent import is_composer_fill
|
||||
assert is_composer_fill("BrowserClickIndex", {"index": 4, "text": "hello world"})
|
||||
assert is_composer_fill("BrowserType", {"selector": "#m", "text": "hi"})
|
||||
@@ -1582,8 +1488,7 @@ def test_composer_fill_detection():
|
||||
|
||||
|
||||
def test_send_index_handoff_points_only_at_a_real_send_button():
|
||||
# after a composer fill we hand the model the Send button's index so it clicks
|
||||
# it directly instead of hunting; must never mistake an upsell/profile link for it
|
||||
# after a composer fill we hand the model the Send button's index so it clicks it directly instead of hunting; must never mistake an upsell/profile link for it
|
||||
from backend.apps.agents.browser.browser_agent import send_index_in_state
|
||||
page = '[1]<link "Tyler Chen">\n[33]<textbox "Write a message">\n[44]<button "Send">'
|
||||
assert send_index_in_state(page) == (44, "Send")
|
||||
@@ -1594,8 +1499,7 @@ def test_send_index_handoff_points_only_at_a_real_send_button():
|
||||
|
||||
def test_strip_lone_surrogates():
|
||||
from backend.apps.agents.browser.browser_agent import strip_lone_surrogates, format_tool_result
|
||||
# an orphan UTF-16 surrogate (half an emoji from the webview) is what crashes
|
||||
# the turn at .encode('utf-8'); it must be swapped, not carried through
|
||||
# an orphan UTF-16 surrogate (half an emoji from the webview) is what crashes the turn at .encode('utf-8'); it must be swapped, not carried through
|
||||
out = strip_lone_surrogates("Twitch \ud83e live")
|
||||
assert "\ud83e" not in out and "�" in out
|
||||
out.encode("utf-8") # the operation that used to raise "surrogates not allowed"
|
||||
|
||||
@@ -96,8 +96,7 @@ def test_replay_route_maps_to_the_fast_network_tool():
|
||||
|
||||
|
||||
def test_fill_handles_a_value_with_url_characters():
|
||||
# a value with spaces/specials is substituted literally (caller is responsible
|
||||
# for encoding); we just don't mangle or drop it
|
||||
# a value with spaces/specials is substituted literally (caller is responsible for encoding); we just don't mangle or drop it
|
||||
tool, params = br.fill_step({"action": "navigate", "url": "https://x.com/s?q={{value}}"}, "a b&c")
|
||||
assert params["url"] == "https://x.com/s?q=a b&c"
|
||||
|
||||
|
||||
@@ -35,8 +35,7 @@ def test_timeout_map_reads_are_short_navigation_longer():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hung_command_returns_fast_at_the_bound(monkeypatch):
|
||||
# shrink the bounds so the test is quick, then never resolve the future:
|
||||
# the command must return a timeout error at ~the (default) bound, not hang.
|
||||
# shrink the bounds so the test is quick, then never resolve the future: the command must return a timeout error at ~the (default) bound, not hang.
|
||||
monkeypatch.setattr(wsm, "BROWSER_CMD_TIMEOUT_DEFAULT", 0.3)
|
||||
monkeypatch.setattr(wsm, "BROWSER_CMD_TIMEOUTS", {"navigate": 0.6})
|
||||
m = p_mgr()
|
||||
@@ -60,8 +59,7 @@ async def test_navigate_gets_the_longer_leash(monkeypatch):
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lost_first_delivery_heals_via_rebroadcast(monkeypatch):
|
||||
# a silently-dead socket eats the first broadcast; the re-send after the
|
||||
# rebroadcast interval must reach the (reconnected) client and succeed
|
||||
# a silently-dead socket eats the first broadcast; the re-send after the rebroadcast interval must reach the (reconnected) client and succeed
|
||||
monkeypatch.setattr(wsm, "BROWSER_CMD_TIMEOUT_DEFAULT", 5.0)
|
||||
monkeypatch.setattr(wsm, "BROWSER_CMD_REBROADCAST_S", 0.1)
|
||||
m = p_mgr()
|
||||
|
||||
@@ -62,8 +62,7 @@ def test_compose_task_keeps_user_words_first():
|
||||
|
||||
|
||||
def test_dispatch_failure_detection_is_fail_closed():
|
||||
# The result dict's structured `done` is the signal now (set true only when
|
||||
# the sub-agent called Done with success AND the honesty gate agreed).
|
||||
# The result dict's structured `done` is the signal now (set true only when the sub-agent called Done with success AND the honesty gate agreed).
|
||||
assert dispatch_failed({})
|
||||
assert dispatch_failed(None)
|
||||
assert dispatch_failed({"summary": "Error: browser card was deleted"})
|
||||
@@ -99,9 +98,7 @@ def test_dispatch_refused_when_no_dashboard_connected(monkeypatch):
|
||||
from backend.apps.agents.browser.browser_agent import run_browser_agents
|
||||
from backend.apps.agents.core import ws_manager as wsm
|
||||
|
||||
# Dispatch now waits briefly for a momentary WS drop to reconnect; with a
|
||||
# genuinely-closed window that wait just elapses and it still refuses without
|
||||
# dispatching an agent or burning a turn. Zero the wait so the test is instant.
|
||||
# Dispatch now waits briefly for a momentary WS drop to reconnect; with a genuinely-closed window that wait just elapses and it still refuses without dispatching an agent or burning a turn. Zero the wait so the test is instant.
|
||||
monkeypatch.setattr(wsm, "P_WS_RECONNECT_WAIT_S", 0.0)
|
||||
assert not wsm.ws_manager.global_connections
|
||||
results = asyncio.run(run_browser_agents(tasks=[{"task": "go to example.com"}], model="sonnet"))
|
||||
|
||||
@@ -34,17 +34,14 @@ def test_metrics_dir_is_cached_makedirs_runs_once(monkeypatch):
|
||||
|
||||
|
||||
def test_excluded_tools_never_register_a_loop():
|
||||
# The invariant the hash-skip relies on: for every excluded tool, even ten
|
||||
# identical calls in a row are NOT a loop, so computing/storing the hash for
|
||||
# them was dead work. Setting is_loop=False directly is therefore equivalent.
|
||||
# The invariant the hash-skip relies on: for every excluded tool, even ten identical calls in a row are NOT a loop, so computing/storing the hash for them was dead work. Setting is_loop=False directly is therefore equivalent.
|
||||
for tool in LOOP_DETECTION_EXCLUDED_TOOLS:
|
||||
key = (tool, "in", "out")
|
||||
assert detect_loop([key] * 10, key) is False, f"{tool} wrongly looped"
|
||||
|
||||
|
||||
def test_non_excluded_tool_still_loops_after_threshold():
|
||||
# Guard the other side: the fix must NOT disable loop detection for the tools
|
||||
# that need it (clicks/types/etc.).
|
||||
# Guard the other side: the fix must NOT disable loop detection for the tools that need it (clicks/types/etc.).
|
||||
key = ("BrowserClick", '{"selector":"#x"}', "clicked")
|
||||
# below threshold -> not a loop; at/over threshold within the window -> loop
|
||||
assert detect_loop([], key) is False # 1st occurrence: not yet a wall
|
||||
|
||||
@@ -12,8 +12,7 @@ def metrics(monkeypatch):
|
||||
d = tempfile.mkdtemp(prefix="bm_test_")
|
||||
monkeypatch.setenv("OPENSWARM_BROWSER_METRICS_DIR", d)
|
||||
from backend.apps.agents.browser import browser_metrics as bm
|
||||
# The dir is memoized once for the prod hot path; drop the cache so each test
|
||||
# re-resolves to its own temp dir instead of inheriting a prior test's.
|
||||
# The dir is memoized once for the prod hot path; drop the cache so each test re-resolves to its own temp dir instead of inheriting a prior test's.
|
||||
bm.p_metrics_dir_cache = None
|
||||
return bm, d
|
||||
|
||||
|
||||
@@ -13,8 +13,7 @@ from backend.apps.agents.manager.prompt import prompt_context as pc
|
||||
|
||||
|
||||
def p_fake_dashboard(monkeypatch):
|
||||
# build_browser_context loads the dashboard; give it a minimal one so it
|
||||
# gets past the load and emits the static delegation guidance.
|
||||
# build_browser_context loads the dashboard; give it a minimal one so it gets past the load and emits the static delegation guidance.
|
||||
import backend.apps.dashboards.dashboards as dash
|
||||
|
||||
class P_D:
|
||||
|
||||
@@ -59,8 +59,7 @@ async def test_first_success_creates_a_playbook():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_second_run_accumulates_not_overwrites():
|
||||
# THE BUG THIS FIXES: the old domain-note store overwrote. The reconcile must
|
||||
# ACCUMULATE: run 2's reply (which the aux builds from existing+new) grows it.
|
||||
# THE BUG THIS FIXES: the old domain-note store overwrote. The reconcile must ACCUMULATE: run 2's reply (which the aux builds from existing+new) grows it.
|
||||
pb.clear(wipe_disk=True)
|
||||
await p_distill("linkedin.com", "t1", "m1", "s1", FakeAux(p_pb("Vercel/Linear+React surfaces real design engineers")))
|
||||
# the aux on run 2 is handed the existing bullet (we assert that), and returns existing + a new one
|
||||
|
||||
@@ -61,8 +61,7 @@ def test_clean_history_proposes_nothing():
|
||||
|
||||
|
||||
def test_audit_fires_every_n_finished_tasks(monkeypatch, tmp_path):
|
||||
# the trigger refreshes the report once every N tasks, off the hot path. Make
|
||||
# threads synchronous so the test is deterministic, and use a small N.
|
||||
# the trigger refreshes the report once every N tasks, off the hot path. Make threads synchronous so the test is deterministic, and use a small N.
|
||||
from backend.apps.agents.browser import browser_metrics as m
|
||||
monkeypatch.setenv("OPENSWARM_BROWSER_METRICS_DIR", str(tmp_path))
|
||||
m.p_metrics_dir_cache = None
|
||||
|
||||
@@ -75,8 +75,7 @@ def test_distill_skips_failed_steps():
|
||||
|
||||
|
||||
def test_distill_flattens_browser_batch():
|
||||
# the agent's efficient path bundles type+press_key into one BrowserBatch;
|
||||
# the recorder must flatten those into discrete robust steps.
|
||||
# the agent's efficient path bundles type+press_key into one BrowserBatch; the recorder must flatten those into discrete robust steps.
|
||||
log = [
|
||||
{"tool": "BrowserNavigate", "input": {"url": "http://h/form"}, "ok": True},
|
||||
{"tool": "BrowserBatch", "ok": True, "input": {"actions": [
|
||||
@@ -120,8 +119,7 @@ def test_record_refuses_unrecordable_run():
|
||||
|
||||
# --- persistence + redaction ----------------------------------------------
|
||||
def test_skill_persists_across_restart(p_isolated_skills):
|
||||
# record, then simulate a process restart by wiping ONLY the in-memory cache;
|
||||
# find must re-load it from disk.
|
||||
# record, then simulate a process restart by wiping ONLY the in-memory cache; find must re-load it from disk.
|
||||
assert sk.record_skill("localhost:8901", "type hello and click Send", p_log()) is True
|
||||
sk.clear(wipe_disk=False) # in-memory gone, disk intact (== restart)
|
||||
assert not sk.SKILLS # cache truly empty
|
||||
@@ -275,10 +273,7 @@ def test_deprecate_unknown_is_false(p_isolated_skills):
|
||||
assert sk.deprecate_skill("shop.com", "never recorded this") is False
|
||||
|
||||
|
||||
# --- versioned safe-edit: the trust gate ----------------------------------
|
||||
# A skill is never trusted until a real replay proves it; an unproven skill that
|
||||
# fails is quarantined (never replayed again) so a lossy skill can't ghost-succeed
|
||||
# or run slower-than-baseline; re-deriving different steps is a re-versioned EDIT.
|
||||
# --- versioned safe-edit: the trust gate ---------------------------------- A skill is never trusted until a real replay proves it; an unproven skill that fails is quarantined (never replayed again) so a lossy skill can't ghost-succeed or run slower-than-baseline; re-deriving different steps is a re-versioned EDIT.
|
||||
|
||||
def test_new_skill_starts_on_probation(p_isolated_skills):
|
||||
sk.record_skill("shop.com", "do a thing now", p_log())
|
||||
@@ -317,8 +312,7 @@ def test_quarantined_skill_re_recorded_identical_stays_quarantined(p_isolated_sk
|
||||
def test_quarantined_skill_unquarantines_on_a_real_edit(p_isolated_skills):
|
||||
sk.record_skill("shop.com", "do a thing now", p_log())
|
||||
sk.mark_replay_failed("shop.com", "do a thing now") # quarantined
|
||||
# now the page changed and the LLM derives a DIFFERENT click -> a real edit,
|
||||
# which earns the skill another chance (back on probation, re-versioned)
|
||||
# now the page changed and the LLM derives a DIFFERENT click -> a real edit, which earns the skill another chance (back on probation, re-versioned)
|
||||
edited = p_log()[:-1] + [{"tool": "BrowserClickIndex", "input": {}, "ok": True,
|
||||
"clicked_role": "button", "clicked_name": "Submit"}]
|
||||
sk.record_skill("shop.com", "do a thing now", edited)
|
||||
@@ -427,8 +421,7 @@ def test_deprecating_a_foundation_demotes_everything_built_on_it(p_isolated_skil
|
||||
p_trust("shop.com", "search shoes and checkout now", p_log_plus()) # composed + trusted
|
||||
assert sk.find_skill("shop.com", "search shoes and checkout now")["state"] == sk.TRUSTED
|
||||
sk.deprecate_skill("shop.com", "search shoes now") # foundation pulled
|
||||
# the ghost guard for composition: the dependent must NOT stay trusted on a
|
||||
# foundation that no longer exists; it's knocked back to re-prove
|
||||
# the ghost guard for composition: the dependent must NOT stay trusted on a foundation that no longer exists; it's knocked back to re-prove
|
||||
assert sk.find_skill("shop.com", "search shoes and checkout now")["state"] == sk.PROBATION
|
||||
|
||||
|
||||
@@ -486,8 +479,7 @@ def test_extract_first_json_strips_fences_and_prose():
|
||||
|
||||
|
||||
def test_widened_redaction_catches_audit_bypasses():
|
||||
# the audit's three named bypasses: bare 2FA digits, credential-shaped
|
||||
# fields the old regex missed, and seed/recovery phrase boxes
|
||||
# the audit's three named bypasses: bare 2FA digits, credential-shaped fields the old regex missed, and seed/recovery phrase boxes
|
||||
assert sk.looks_sensitive("481922", "")
|
||||
assert sk.looks_sensitive("hunter2", "#user")
|
||||
assert sk.looks_sensitive("me@corp.com", "#login-email")
|
||||
@@ -538,16 +530,13 @@ def test_long_card_blob_click_names_are_not_send_steps():
|
||||
{"tool": "BrowserClickByName", "params": {"name": "Send"}},
|
||||
]
|
||||
i, why = first_unsafe_step(flow)
|
||||
# the 100ch blob at step 1 isn't flagged (len guard); the composer OPENER
|
||||
# "Message" at step 2 isn't either (reversible, it just opens the box); the
|
||||
# boundary is the real "Send" at step 3, so the prefix can open the composer.
|
||||
# the 100ch blob at step 1 isn't flagged (len guard); the composer OPENER "Message" at step 2 isn't either (reversible, it just opens the box); the boundary is the real "Send" at step 3, so the prefix can open the composer.
|
||||
assert i == 3, f"expected the Send click flagged, got {i}: {why}"
|
||||
|
||||
|
||||
def test_composer_opener_is_not_the_replay_boundary():
|
||||
from backend.apps.agents.browser.browser_skills import first_unsafe_step, replay_safety
|
||||
# clicking "Message"/"DM" just OPENS the composer (reversible); the boundary
|
||||
# is the real Send, so the open-the-composer steps can mechanically replay.
|
||||
# clicking "Message"/"DM" just OPENS the composer (reversible); the boundary is the real Send, so the open-the-composer steps can mechanically replay.
|
||||
flow = [
|
||||
{"tool": "BrowserClickByName", "params": {"role": "link", "name": "Message"}},
|
||||
{"tool": "BrowserClickByName", "params": {"role": "textbox", "name": "Write a message…"}},
|
||||
@@ -679,17 +668,14 @@ def test_route_hint_adoption_matching(p_isolated_skills):
|
||||
{"tool": "BrowserType", "input": {"selector": "div.msg-form", "text": "x"}, "ok": True},
|
||||
]
|
||||
adopted = [sk.hint_step_adopted(k, run_log) for k in keys]
|
||||
# navigate (query-stripped match), profile click (containment), type all adopt;
|
||||
# the Message and Send clicks did not run
|
||||
# navigate (query-stripped match), profile click (containment), type all adopt; the Message and Send clicks did not run
|
||||
assert adopted[0] and adopted[1] and adopted[3]
|
||||
assert not adopted[2] and not adopted[4]
|
||||
|
||||
|
||||
# --- conservative detour pruning ---------------------------------------------
|
||||
def test_distill_prunes_abandoned_navigate_detour():
|
||||
# wrong profile opened (navigate), abandoned for a search (navigate), then
|
||||
# the right profile + the real productive steps. The first navigate is a
|
||||
# detour: nothing acted on its page before the next navigate.
|
||||
# wrong profile opened (navigate), abandoned for a search (navigate), then the right profile + the real productive steps. The first navigate is a detour: nothing acted on its page before the next navigate.
|
||||
log = [
|
||||
{"tool": "BrowserNavigate", "input": {"url": "https://x.com/in/wrong"}, "ok": True},
|
||||
{"tool": "BrowserNavigate", "input": {"url": "https://x.com/search?q=tyler"}, "ok": True},
|
||||
|
||||
@@ -104,9 +104,7 @@ def test_advance_fires_again_at_max():
|
||||
assert stagnation_exhausted(streak)
|
||||
|
||||
|
||||
# --- completion honesty gate ----------------------------------------------
|
||||
# Catches the worst measured ghost: multi-minute runs, every tool errored, still
|
||||
# reported 'completed'. Must NOT cry wolf on real successes (it overrides status).
|
||||
# --- completion honesty gate ---------------------------------------------- Catches the worst measured ghost: multi-minute runs, every tool errored, still reported 'completed'. Must NOT cry wolf on real successes (it overrides status).
|
||||
|
||||
def p_ok(tool, summary="done"):
|
||||
return {"tool": tool, "ok": True, "result_summary": summary}
|
||||
@@ -159,8 +157,7 @@ def test_card_is_unavailable_only_for_unrecoverable_errors():
|
||||
# a gone card is unrecoverable (fail fast); a missing selector is not (route around)
|
||||
assert card_is_unavailable({"error": "Browser card 'b1' not found or not an Electron webview"})
|
||||
assert card_is_unavailable({"error": "No dashboard is connected. Open the dashboard to use browser tools."})
|
||||
# a HUNG card (the 20-min LinkedIn freeze) also counts: commands time out, the
|
||||
# page never responds, retrying is pointless -> same fast-fail streak as gone
|
||||
# a HUNG card (the 20-min LinkedIn freeze) also counts: commands time out, the page never responds, retrying is pointless -> same fast-fail streak as gone
|
||||
assert card_is_unavailable({"error": "Browser command timed out"})
|
||||
assert card_is_unavailable({"error": "page unresponsive"})
|
||||
# but normal, recoverable problems do NOT (the agent can route around these)
|
||||
@@ -168,8 +165,7 @@ def test_card_is_unavailable_only_for_unrecoverable_errors():
|
||||
assert not card_is_unavailable({"text": "ok", "url": "http://x"})
|
||||
|
||||
|
||||
# --- informational-deliverable gate (don't record a thin shortcut for a run
|
||||
# whose answer was gathered/judged content that replay can't reproduce) ---------
|
||||
# --- informational-deliverable gate (don't record a thin shortcut for a run whose answer was gathered/judged content that replay can't reproduce) ---------
|
||||
|
||||
def test_deliverable_informational_blocks_gathered_content_records_confirmations():
|
||||
# a short action confirmation (the PROVEN Wikipedia case) -> safe to record
|
||||
|
||||
@@ -18,8 +18,7 @@ from backend.apps.agents.browser import browser_wait as bw
|
||||
|
||||
# --- the pure decision (hammer it) ------------------------------------------
|
||||
def test_decide_stop_waits_until_past_the_floor():
|
||||
# even a fully-settled page must not return before the floor (a momentary gap
|
||||
# between two requests would otherwise look 'settled')
|
||||
# even a fully-settled page must not return before the floor (a momentary gap between two requests would otherwise look 'settled')
|
||||
assert bw.decide_stop(True, 9999, 0, False, 100, floor_ms=250) is False
|
||||
assert bw.decide_stop(True, 9999, 0, False, 300, floor_ms=250) is True
|
||||
|
||||
@@ -101,8 +100,7 @@ async def test_rides_to_cap_when_page_never_settles():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_settles_on_dom_stable_when_network_never_idles():
|
||||
# the LinkedIn case: network always busy (quiet tiny) but the DOM count is
|
||||
# constant -> DOM-settle fires instead of riding to the cap
|
||||
# the LinkedIn case: network always busy (quiet tiny) but the DOM count is constant -> DOM-settle fires instead of riding to the cap
|
||||
ex = FakeExec([p_probe(True, 5, elems=500)])
|
||||
out = await bw.smart_wait(ex, "b", "", 3000, poll_ms=20, floor_ms=20, quiet_window_ms=200)
|
||||
assert out["settled"] is True and out["waited_ms"] < 3000
|
||||
@@ -111,8 +109,7 @@ async def test_settles_on_dom_stable_when_network_never_idles():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_the_instant_target_is_found():
|
||||
# network busy AND DOM churning, but the agent's target appears on probe 2 ->
|
||||
# stop immediately, bypassing even the floor
|
||||
# network busy AND DOM churning, but the agent's target appears on probe 2 -> stop immediately, bypassing even the floor
|
||||
ex = FakeExec([p_probe(False, 5, elems=100, found=False),
|
||||
p_probe(False, 5, elems=200, found=True)])
|
||||
out = await bw.smart_wait(ex, "b", "", 5000, until="Send",
|
||||
@@ -157,9 +154,7 @@ async def test_garbage_probe_text_does_not_crash():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hung_tab_returns_fast_not_after_the_full_command_timeout():
|
||||
# THE bug from the 20-min loop: a wedged tab made each 'wait' block ~30s.
|
||||
# Now each probe is bounded, so after a couple of timeouts it returns hung,
|
||||
# in a few seconds, NOT 30s+, regardless of how long the command would block.
|
||||
# THE bug from the 20-min loop: a wedged tab made each 'wait' block ~30s. Now each probe is bounded, so after a couple of timeouts it returns hung, in a few seconds, NOT 30s+, regardless of how long the command would block.
|
||||
ex = HangingExec(block_s=30.0) # mimic the 30s command timeout
|
||||
t0 = time.monotonic()
|
||||
out = await bw.smart_wait(ex, "b", "", 8000, poll_ms=20, probe_timeout_s=0.3)
|
||||
|
||||
@@ -27,15 +27,13 @@ def test_prefers_bundled_extracted_tree(monkeypatch, tmp_path):
|
||||
|
||||
|
||||
def test_no_bundled_tree_returns_none(monkeypatch, tmp_path):
|
||||
# No extracted tree shipped (Mac / older builds): must not select it, so the
|
||||
# caller falls through to the .tar.gz extract or live npm.
|
||||
# No extracted tree shipped (Mac / older builds): must not select it, so the caller falls through to the .tar.gz extract or live npm.
|
||||
monkeypatch.setattr(vt, "P_BUNDLED_ARCHIVE_DIR", str(tmp_path / "empty"))
|
||||
assert vt.bundled_extracted_modules() is None
|
||||
|
||||
|
||||
def test_warm_cache_is_complete_requires_launch_bin(tmp_path):
|
||||
# A package tree on disk is NOT a finished install; the .bin/vite launch
|
||||
# shim is what proves npm finished its bin-linking phase.
|
||||
# A package tree on disk is NOT a finished install; the .bin/vite launch shim is what proves npm finished its bin-linking phase.
|
||||
nm = tmp_path / "node_modules"
|
||||
(nm / "vite" / "bin").mkdir(parents=True)
|
||||
(nm / "vite" / "bin" / "vite.js").write_text("// vite")
|
||||
@@ -47,9 +45,7 @@ def test_warm_cache_is_complete_requires_launch_bin(tmp_path):
|
||||
|
||||
|
||||
def test_ensure_warm_cache_wipes_partial_and_never_returns_incomplete(monkeypatch, tmp_path):
|
||||
# A half-finished cache (package tree present, .bin/vite missing) must be
|
||||
# WIPED and never handed back, so no workspace symlinks to an unlaunchable
|
||||
# tree and run.sh is never pushed into installing through the shared cache.
|
||||
# A half-finished cache (package tree present, .bin/vite missing) must be WIPED and never handed back, so no workspace symlinks to an unlaunchable tree and run.sh is never pushed into installing through the shared cache.
|
||||
digest = vt.warm_cache_digest()
|
||||
home = tmp_path / "home"
|
||||
monkeypatch.setenv("OPENSWARM_WEBAPP_CACHE_DIR", str(home))
|
||||
@@ -58,8 +54,7 @@ def test_ensure_warm_cache_wipes_partial_and_never_returns_incomplete(monkeypatc
|
||||
(cache_modules / "vite" / "bin" / "vite.js").write_text("// vite")
|
||||
assert vt.warm_cache_is_complete(str(cache_modules)) is False
|
||||
|
||||
# No bundled tree, no archive, no npm: the only honest answer is "not ready"
|
||||
# (None), and the broken tree must be gone, not cached for the next caller.
|
||||
# No bundled tree, no archive, no npm: the only honest answer is "not ready" (None), and the broken tree must be gone, not cached for the next caller.
|
||||
monkeypatch.setattr(vt, "P_BUNDLED_ARCHIVE_DIR", str(tmp_path / "noresources"))
|
||||
monkeypatch.setattr(vt, "p_try_extract_bundled_archive", lambda *a, **k: False)
|
||||
monkeypatch.setattr(vt, "p_resolve_npm", lambda: None)
|
||||
|
||||
@@ -40,10 +40,7 @@ import pytest
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Boot env: route data to a tempdir BEFORE importing backend modules so
|
||||
# the persistence dir for terminal events lives under our control.
|
||||
# ---------------------------------------------------------------------------
|
||||
# --------------------------------------------------------------------------- Boot env: route data to a tempdir BEFORE importing backend modules so the persistence dir for terminal events lives under our control. ---------------------------------------------------------------------------
|
||||
|
||||
P_TMPROOT = tempfile.mkdtemp(prefix="openswarm-disconnect-test-")
|
||||
os.environ.setdefault("OPENSWARM_DATA_DIR", P_TMPROOT)
|
||||
@@ -71,12 +68,7 @@ def p_patch_persist_dir():
|
||||
wm_monkey.stop()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Minimal FastAPI app with the real WS endpoint logic. We import
|
||||
# ws_manager directly and replicate the handler from backend/main.py
|
||||
# without any of its auth middleware so the TestClient can connect
|
||||
# without a token.
|
||||
# ---------------------------------------------------------------------------
|
||||
# --------------------------------------------------------------------------- Minimal FastAPI app with the real WS endpoint logic. We import ws_manager directly and replicate the handler from backend/main.py without any of its auth middleware so the TestClient can connect without a token. ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def p_build_app(seq_log):
|
||||
@@ -138,9 +130,7 @@ def p_emit(client, session_id: str, n: int, terminate: str | None = None, concur
|
||||
return r.json()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
# --------------------------------------------------------------------------- Helpers ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def p_emit_run(session_id: str, n_events: int, terminate: str | None = "completed", concurrent_tasks: int = 1):
|
||||
@@ -159,8 +149,7 @@ async def p_emit_run(session_id: str, n_events: int, terminate: str | None = "co
|
||||
"message_id": "m1",
|
||||
"delta": f"chunk-{start + i}",
|
||||
})
|
||||
# Yield to the scheduler so other coroutines interleave;
|
||||
# this is what surfaces the seq race if locking is wrong.
|
||||
# Yield to the scheduler so other coroutines interleave; this is what surfaces the seq race if locking is wrong.
|
||||
await asyncio.sleep(0)
|
||||
|
||||
if concurrent_tasks <= 1:
|
||||
@@ -184,9 +173,7 @@ async def p_emit_run(session_id: str, n_events: int, terminate: str | None = "co
|
||||
})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit-level: seq log fundamentals
|
||||
# ---------------------------------------------------------------------------
|
||||
# --------------------------------------------------------------------------- Unit-level: seq log fundamentals ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_seq_monotonic_under_concurrency(p_patch_persist_dir):
|
||||
@@ -226,9 +213,7 @@ def test_replay_after_eviction_reports_gap(p_patch_persist_dir):
|
||||
assert all(json.loads(s)["seq"] > 10 for s in events)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration: full WS connect / disconnect / resume cycle
|
||||
# ---------------------------------------------------------------------------
|
||||
# --------------------------------------------------------------------------- Integration: full WS connect / disconnect / resume cycle ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_resume_after_disconnect_recovers_all_events(p_patch_persist_dir):
|
||||
@@ -251,12 +236,10 @@ def test_resume_after_disconnect_recovers_all_events(p_patch_persist_dir):
|
||||
assert len(received) == 10
|
||||
assert received[-1]["seq"] == 10
|
||||
|
||||
# Phase 2: between connections, the server keeps emitting. The
|
||||
# agent task is alive; only the WS is gone.
|
||||
# Phase 2: between connections, the server keeps emitting. The agent task is alive; only the WS is gone.
|
||||
p_emit(client, sid, n=10, terminate="completed")
|
||||
|
||||
# Phase 3: reconnect with last_seq=10, expect replay of seq 11..21
|
||||
# (10 deltas + 1 status), then the server:hello ack.
|
||||
# Phase 3: reconnect with last_seq=10, expect replay of seq 11..21 (10 deltas + 1 status), then the server:hello ack.
|
||||
with client.websocket_connect(f"/ws/agents/{sid}") as ws:
|
||||
ws.send_text(json.dumps({"event": "client:hello", "data": {"last_seq": 10, "connection_uuid": "c2"}}))
|
||||
replay: list[dict] = []
|
||||
@@ -284,8 +267,7 @@ def test_terminal_event_visible_after_full_eviction(p_patch_persist_dir):
|
||||
with TestClient(app) as client:
|
||||
p_emit(client, sid, n=5, terminate="completed")
|
||||
|
||||
# Simulate a process restart: clear the in-memory ring buffer
|
||||
# but keep the persisted terminal file.
|
||||
# Simulate a process restart: clear the in-memory ring buffer but keep the persisted terminal file.
|
||||
seq_log.per_session.pop(sid, None)
|
||||
|
||||
with client.websocket_connect(f"/ws/agents/{sid}") as ws:
|
||||
@@ -341,9 +323,7 @@ def test_ping_pong_round_trip(p_patch_persist_dir):
|
||||
assert pong["data"]["nonce"] == "abc"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The big one: hundreds of randomized disconnect scenarios.
|
||||
# ---------------------------------------------------------------------------
|
||||
# --------------------------------------------------------------------------- The big one: hundreds of randomized disconnect scenarios. ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
N_STRESS_ITERATIONS = int(os.environ.get("DISCONNECT_STRESS_N", "500"))
|
||||
@@ -364,10 +344,7 @@ def test_stress_random_disconnect(iteration, p_patch_persist_dir):
|
||||
n_disconnects = rng.randint(1, min(5, total_events // 2 or 1))
|
||||
will_terminate = rng.random() < 0.7 # 70% of runs reach a terminal
|
||||
|
||||
# Disconnect points: each is a count of events emitted *before*
|
||||
# the WS drops. We deliberately exclude `total_events` itself
|
||||
# so the breakpoint list never collides with the appended final
|
||||
# iteration (which is when the optional terminal status fires).
|
||||
# Disconnect points: each is a count of events emitted *before* the WS drops. We deliberately exclude `total_events` itself so the breakpoint list never collides with the appended final iteration (which is when the optional terminal status fires).
|
||||
if total_events > 1:
|
||||
breakpoints = sorted(rng.sample(range(1, total_events), min(n_disconnects, total_events - 1)))
|
||||
else:
|
||||
@@ -395,12 +372,7 @@ def test_stress_random_disconnect(iteration, p_patch_persist_dir):
|
||||
emitted_so_far = bp
|
||||
terminate = "completed" if (bp == total_events and will_terminate) else None
|
||||
|
||||
# Drive the emit through the test app's HTTP endpoint so
|
||||
# the broadcast happens on the same event loop as the WS
|
||||
# handler. Using asyncio.run() here would create an
|
||||
# isolated loop and re-bind the per-session asyncio.Lock
|
||||
# to a different loop, which is hostile to anyio's
|
||||
# blocking-portal pattern.
|
||||
# Drive the emit through the test app's HTTP endpoint so the broadcast happens on the same event loop as the WS handler. Using asyncio.run() here would create an isolated loop and re-bind the per-session asyncio.Lock to a different loop, which is hostile to anyio's blocking-portal pattern.
|
||||
p_emit(client, sid, n=to_emit, terminate=terminate)
|
||||
|
||||
expected = to_emit + (1 if terminate else 0)
|
||||
@@ -409,8 +381,7 @@ def test_stress_random_disconnect(iteration, p_patch_persist_dir):
|
||||
seen[msg["seq"]] = msg
|
||||
last_seq = max(last_seq, msg["seq"])
|
||||
|
||||
# Closing the with-block disconnects the WS. The loop
|
||||
# opens a fresh socket on the next iteration.
|
||||
# Closing the with-block disconnects the WS. The loop opens a fresh socket on the next iteration.
|
||||
|
||||
# ----- Assertions: completeness, ordering, no dups, terminal -----
|
||||
expected_total = total_events + (1 if will_terminate else 0)
|
||||
@@ -423,9 +394,7 @@ def test_stress_random_disconnect(iteration, p_patch_persist_dir):
|
||||
assert last["data"]["status"] == "completed"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Concurrent broadcast: many fan-out coroutines must preserve seq order
|
||||
# ---------------------------------------------------------------------------
|
||||
# --------------------------------------------------------------------------- Concurrent broadcast: many fan-out coroutines must preserve seq order ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("trial", range(30))
|
||||
@@ -447,19 +416,10 @@ def test_concurrent_broadcast_preserves_order(trial, p_patch_persist_dir):
|
||||
assert len(seqs) == len(set(seqs))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth/security smoke: the WS endpoint here is unauth'd by design (test
|
||||
# scaffolding), but main.py's p_ws_auth_ok must remain in place. This
|
||||
# test pins that contract so a future refactor can't accidentally
|
||||
# strip it.
|
||||
# ---------------------------------------------------------------------------
|
||||
# --------------------------------------------------------------------------- Auth/security smoke: the WS endpoint here is unauth'd by design (test scaffolding), but main.py's p_ws_auth_ok must remain in place. This test pins that contract so a future refactor can't accidentally strip it. ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Extra stress: terminate happens INSIDE a disconnect window. The
|
||||
# client must see the terminal event on its next reconnect (whether
|
||||
# from ring buffer or persisted disk record).
|
||||
# ---------------------------------------------------------------------------
|
||||
# --------------------------------------------------------------------------- Extra stress: terminate happens INSIDE a disconnect window. The client must see the terminal event on its next reconnect (whether from ring buffer or persisted disk record). ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("trial", range(50))
|
||||
@@ -484,9 +444,7 @@ def test_terminate_during_disconnect_is_observable(trial, p_patch_persist_dir):
|
||||
last_seq = max(last_seq, msg["seq"])
|
||||
# Disconnected. Emit the rest + terminate while WS is gone.
|
||||
p_emit(client, sid, n=n_post, terminate="completed")
|
||||
# Reconnect. We expect to receive everything from last_seq+1
|
||||
# through to the terminal, possibly via disk if the buffer
|
||||
# rolled (it won't here; numbers are small).
|
||||
# Reconnect. We expect to receive everything from last_seq+1 through to the terminal, possibly via disk if the buffer rolled (it won't here; numbers are small).
|
||||
with client.websocket_connect(f"/ws/agents/{sid}") as ws:
|
||||
ws.send_text(json.dumps({"event": "client:hello", "data": {"last_seq": last_seq, "connection_uuid": "c2"}}))
|
||||
while True:
|
||||
@@ -505,12 +463,7 @@ def test_terminate_during_disconnect_is_observable(trial, p_patch_persist_dir):
|
||||
assert last["data"]["status"] == "completed"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sanity: an explicit `WebSocketDisconnect` MUST NOT cancel the
|
||||
# underlying agent task. We don't have a real agent here, but we can
|
||||
# at least assert that the ws_manager's disconnect path doesn't touch
|
||||
# any task registry.
|
||||
# ---------------------------------------------------------------------------
|
||||
# --------------------------------------------------------------------------- Sanity: an explicit `WebSocketDisconnect` MUST NOT cancel the underlying agent task. We don't have a real agent here, but we can at least assert that the ws_manager's disconnect path doesn't touch any task registry. ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_disconnect_does_not_touch_agent_task(p_patch_persist_dir):
|
||||
@@ -519,10 +472,7 @@ def test_disconnect_does_not_touch_agent_task(p_patch_persist_dir):
|
||||
`tasks` dict starts empty; we register a sentinel task and confirm
|
||||
disconnect_session doesn't poke it."""
|
||||
from backend.apps.agents.core.ws_manager import ws_manager
|
||||
# Insert a real Future into a parallel registry to mimic
|
||||
# `agent_manager.tasks[session_id]` and confirm ws_manager
|
||||
# never reaches into it. We don't import agent_manager (heavy);
|
||||
# we just inspect the source.
|
||||
# Insert a real Future into a parallel registry to mimic `agent_manager.tasks[session_id]` and confirm ws_manager never reaches into it. We don't import agent_manager (heavy); we just inspect the source.
|
||||
import inspect
|
||||
src = inspect.getsource(ws_manager.disconnect_session)
|
||||
assert "cancel" not in src.lower()
|
||||
@@ -536,8 +486,7 @@ def test_main_ws_endpoints_still_gated_by_auth(p_patch_persist_dir):
|
||||
"main.py WS endpoints must still call p_ws_auth_ok before accepting "
|
||||
"the connection, otherwise any local web page can read agent traffic."
|
||||
)
|
||||
# And the disconnect handler must NOT call any task-cancel helper
|
||||
#, that's the regression we're guarding against.
|
||||
# And the disconnect handler must NOT call any task-cancel helper, that's the regression we're guarding against.
|
||||
assert "stop_agent" not in src.split("WebSocketDisconnect")[1].split("def ")[0], (
|
||||
"WebSocketDisconnect handler must not cancel the agent task."
|
||||
)
|
||||
|
||||
@@ -96,8 +96,7 @@ def test_dashboards_load_all_skips_corrupt_and_invalid(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(dmod, "DATA_DIR", str(tmp_path))
|
||||
dmod.save(Dashboard(name="good", layout=DashboardLayout()))
|
||||
(tmp_path / "garbled.json").write_text("{ not json")
|
||||
# Valid JSON the model can't accept (a list can't be **-unpacked into the model).
|
||||
# Models are lenient about missing/extra fields, so this is what an unloadable file actually looks like.
|
||||
# Valid JSON the model can't accept (a list can't be **-unpacked into the model). Models are lenient about missing/extra fields, so this is what an unloadable file actually looks like.
|
||||
(tmp_path / "wrongshape.json").write_text(json.dumps([1, 2, 3]))
|
||||
loaded = dmod.load_all()
|
||||
assert [d.name for d in loaded] == ["good"]
|
||||
|
||||
@@ -27,8 +27,7 @@ def test_web_mcp_suppresses_native_web_tools():
|
||||
session = p_session(["Read", "WebSearch", "WebFetch"])
|
||||
perms = {"Read": "always_allow", "WebSearch": "always_allow", "WebFetch": "always_allow"}
|
||||
allowed, disallowed = build_effective_tool_lists(session, {}, perms, True, [], [])
|
||||
# native WebSearch/WebFetch are stripped (they'd fail) and force-disallowed so the model
|
||||
# uses the openswarm-web MCP variants instead
|
||||
# native WebSearch/WebFetch are stripped (they'd fail) and force-disallowed so the model uses the openswarm-web MCP variants instead
|
||||
assert "WebSearch" not in allowed and "WebFetch" not in allowed
|
||||
assert "WebSearch" in disallowed and "WebFetch" in disallowed
|
||||
assert "Read" in allowed
|
||||
|
||||
@@ -37,8 +37,7 @@ def test_proxy_auth_for_each_mode():
|
||||
def test_free_trial_resolves_to_a_bare_anthropic_id():
|
||||
s = AppSettings(connection_mode="free-trial", free_trial_token="ftk")
|
||||
mid = resolve_model_id_for_sdk("sonnet", s)
|
||||
# The bug this fixes: without the free-trial branch this returns a cc/-prefixed
|
||||
# id that 401s when no Claude subscription is connected.
|
||||
# The bug this fixes: without the free-trial branch this returns a cc/-prefixed id that 401s when no Claude subscription is connected.
|
||||
assert "cc/" not in mid
|
||||
assert mid.startswith("claude-")
|
||||
|
||||
@@ -141,8 +140,7 @@ async def test_arm_with_no_sub_is_bounded_and_falls_through_to_arm(monkeypatch):
|
||||
import backend.apps.nine_router as nr
|
||||
monkeypatch.setattr(nr, "ensure_running", fake_ensure_running)
|
||||
monkeypatch.setattr(ft, "p_has_connected_subscription", never_sub)
|
||||
# Short-circuit before the cloud mint so the test stays offline + deterministic;
|
||||
# reaching this branch proves arm did NOT falsely conclude has_model.
|
||||
# Short-circuit before the cloud mint so the test stays offline + deterministic; reaching this branch proves arm did NOT falsely conclude has_model.
|
||||
monkeypatch.setattr(ft, "p_fingerprint", lambda _s: None)
|
||||
|
||||
s = AppSettings()
|
||||
|
||||
@@ -25,8 +25,7 @@ def p_settings(dismissed=None):
|
||||
|
||||
|
||||
def test_offer_resolves_both_display_name_and_hotpath_slug(monkeypatch):
|
||||
# The hot-path passes a sanitized slug ("google-workspace"); the curated id is a display
|
||||
# name ("Google Workspace"). Both must resolve, so the wiring isn't a load-bearing string.
|
||||
# The hot-path passes a sanitized slug ("google-workspace"); the curated id is a display name ("Google Workspace"). Both must resolve, so the wiring isn't a load-bearing string.
|
||||
monkeypatch.setattr(pf, "load_all_tools", lambda: []) # nothing enabled
|
||||
s = p_settings()
|
||||
for name in ("Google Workspace", "google-workspace"):
|
||||
@@ -86,8 +85,7 @@ def test_preflight_default_suppresses_suggestions_on_concrete_prompt(monkeypatch
|
||||
|
||||
|
||||
def test_preflight_require_vague_false_keeps_suggestions(monkeypatch):
|
||||
# MCPSearch path: the agent already proved it needs an integration, so keep the suggestion
|
||||
# even though the prompt is concrete (is_vague False).
|
||||
# MCPSearch path: the agent already proved it needs an integration, so keep the suggestion even though the prompt is concrete (is_vague False).
|
||||
monkeypatch.setattr(pf, "load_all_tools", lambda: [])
|
||||
monkeypatch.setattr(pf, "p_call_classifier", p_stub_classifier(False, ["Google Workspace"]))
|
||||
out = asyncio.run(run_preflight("check my unread emails", timeout_s=5, require_vague=False))
|
||||
|
||||
@@ -18,18 +18,13 @@ from unittest.mock import patch, AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Boot env: route data dirs into a tmp scratch root before importing
|
||||
# backend modules.
|
||||
# ---------------------------------------------------------------------------
|
||||
# --------------------------------------------------------------------------- Boot env: route data dirs into a tmp scratch root before importing backend modules. ---------------------------------------------------------------------------
|
||||
|
||||
P_TMPROOT = tempfile.mkdtemp(prefix="openswarm-phase1-stress-")
|
||||
os.environ.setdefault("OPENSWARM_DATA_DIR", P_TMPROOT)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Group 1, Message.client_message_id
|
||||
# ---------------------------------------------------------------------------
|
||||
# --------------------------------------------------------------------------- Group 1, Message.client_message_id ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_message_round_trips_client_id():
|
||||
@@ -81,9 +76,7 @@ def test_client_message_id_collision_resistance():
|
||||
assert len(seen) >= 495 # collisions are statistically negligible
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Group 2, Mode migration: chat → ask
|
||||
# ---------------------------------------------------------------------------
|
||||
# --------------------------------------------------------------------------- Group 2, Mode migration: chat → ask ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_builtin_modes_no_chat():
|
||||
@@ -143,8 +136,7 @@ def test_session_reconcile_migrates_chat_to_ask():
|
||||
from backend.apps.agents import agent_manager as am_mod
|
||||
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
# Seed 50 sessions: 30 with mode='chat', 20 with mode='agent'.
|
||||
# Some marked running so we also exercise the stale-status path.
|
||||
# Seed 50 sessions: 30 with mode='chat', 20 with mode='agent'. Some marked running so we also exercise the stale-status path.
|
||||
for i in range(50):
|
||||
sid = f"sess-{i}"
|
||||
mode = "chat" if i < 30 else "agent"
|
||||
@@ -194,10 +186,7 @@ def test_reconcile_idempotent():
|
||||
assert mtime_after_first == mtime_after_second, "reconcile must be idempotent"
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Group 6, Notes layout serialization
|
||||
# ---------------------------------------------------------------------------
|
||||
# --------------------------------------------------------------------------- Group 6, Notes layout serialization ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_dashboard_layout_notes_round_trip():
|
||||
@@ -253,14 +242,7 @@ def test_notes_stress_many_round_trips():
|
||||
assert rehydrated.notes[nid].color == orig.color
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Group 7, Concurrent send_message dedupe stress
|
||||
#
|
||||
# Real-world scenario: user mashes Enter quickly. 50 concurrent sends
|
||||
# each with a unique client_message_id must produce 50 echoed messages
|
||||
# carrying the right ids. Pure pydantic / asyncio test, no real
|
||||
# agent loop.
|
||||
# ---------------------------------------------------------------------------
|
||||
# --------------------------------------------------------------------------- Group 7, Concurrent send_message dedupe stress Real-world scenario: user mashes Enter quickly. 50 concurrent sends each with a unique client_message_id must produce 50 echoed messages carrying the right ids. Pure pydantic / asyncio test, no real agent loop. ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -281,9 +263,7 @@ async def test_concurrent_send_message_unique_client_ids():
|
||||
assert len(set(actual)) == 100, "all unique"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pytest config: register asyncio mode so we don't need the plugin.
|
||||
# ---------------------------------------------------------------------------
|
||||
# --------------------------------------------------------------------------- Pytest config: register asyncio mode so we don't need the plugin. ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def pytest_collection_modifyitems(config, items):
|
||||
|
||||
@@ -24,8 +24,7 @@ def test_registers_always_on_and_delegation_servers():
|
||||
assert "openswarm-invoke-agent" in mcp_servers
|
||||
assert browser_tools == ["CreateBrowserAgent", "BrowserAgent", "BrowserAgents"]
|
||||
assert invoke_tools == ["InvokeAgent"]
|
||||
# Every registered server's script path must resolve to a file that ACTUALLY EXISTS.
|
||||
# This is the assertion that catches a moved-caller resolving the wrong agents dir.
|
||||
# Every registered server's script path must resolve to a file that ACTUALLY EXISTS. This is the assertion that catches a moved-caller resolving the wrong agents dir.
|
||||
for name in ("openswarm-mcp-meta", "openswarm-settings-meta",
|
||||
"openswarm-browser-agent", "openswarm-invoke-agent"):
|
||||
script = mcp_servers[name]["args"][0]
|
||||
|
||||
@@ -38,8 +38,7 @@ def isolated_data_dir(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(_storage, "_cache_loaded", False)
|
||||
monkeypatch.setattr(_storage, "_paused", False)
|
||||
monkeypatch.setattr(_audit, "AUDIT_DIR", str(tmp_path / "workflows" / "audit"))
|
||||
# Module-level scheduler state survives across tests; reset it so
|
||||
# each test gets a fresh _wake Event bound to its own event loop.
|
||||
# Module-level scheduler state survives across tests; reset it so each test gets a fresh _wake Event bound to its own event loop.
|
||||
_scheduler._loop_task = None
|
||||
_scheduler._wake = asyncio.Event()
|
||||
_escalation._tasks.clear()
|
||||
@@ -161,8 +160,7 @@ async def test_reconcile_captures_missed_fires(monkeypatch):
|
||||
startup captures the missed fires as pending MissedRuns and rolls
|
||||
next_run_at forward (no auto-firing)."""
|
||||
from backend.apps.workflows import storage, scheduler
|
||||
# created_at must predate the missed window; occurrences_between never
|
||||
# enumerates fires from before the workflow existed.
|
||||
# created_at must predate the missed window; occurrences_between never enumerates fires from before the workflow existed.
|
||||
wf = _make_wf(created_at=datetime.now(timezone.utc) - timedelta(days=10))
|
||||
wf.next_run_at = datetime.now(timezone.utc) - timedelta(days=3)
|
||||
storage.save_workflow(wf)
|
||||
@@ -340,8 +338,7 @@ async def test_kick_wakes_loop_before_timeout(monkeypatch):
|
||||
wf.next_run_at = datetime.now(timezone.utc) - timedelta(seconds=1)
|
||||
storage.save_workflow(wf)
|
||||
scheduler.kick()
|
||||
# Without kick(), the loop would sleep up to 60s before checking
|
||||
# the freshly-saved workflow. With kick, it should fire fast.
|
||||
# Without kick(), the loop would sleep up to 60s before checking the freshly-saved workflow. With kick, it should fire fast.
|
||||
await asyncio.wait_for(fired.wait(), timeout=3.0)
|
||||
finally:
|
||||
await scheduler.stop()
|
||||
|
||||
@@ -45,8 +45,7 @@ def install_sync_sink():
|
||||
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.
|
||||
# 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"
|
||||
@@ -146,9 +145,7 @@ def manager():
|
||||
return AgentManager()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. record(), legacy shim correctness
|
||||
# ---------------------------------------------------------------------------
|
||||
# --------------------------------------------------------------------------- 1. record(), legacy shim correctness ---------------------------------------------------------------------------
|
||||
|
||||
class TestRecordBasics:
|
||||
def test_record_sends_payload(self):
|
||||
@@ -174,9 +171,7 @@ class TestRecordBasics:
|
||||
assert s["properties"]["dashboard_id"] == "dash456"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Multi-message session, close fires exactly once
|
||||
# ---------------------------------------------------------------------------
|
||||
# --------------------------------------------------------------------------- 2. Multi-message session, close fires exactly once ---------------------------------------------------------------------------
|
||||
|
||||
class TestMultiMessageSession:
|
||||
@pytest.mark.asyncio
|
||||
@@ -200,9 +195,7 @@ class TestMultiMessageSession:
|
||||
assert len(completed) == 1, f"expected 1 completed sync, got {len(completed)}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Token + cost capture on close
|
||||
# ---------------------------------------------------------------------------
|
||||
# --------------------------------------------------------------------------- 3. Token + cost capture on close ---------------------------------------------------------------------------
|
||||
|
||||
class TestTokenTracking:
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -34,8 +34,7 @@ def test_purge_session_memory_clears_every_structure():
|
||||
|
||||
|
||||
def test_purge_is_safe_on_an_untracked_id():
|
||||
# Purging an id that was never tracked must be a quiet no-op, not a KeyError,
|
||||
# so the delete/close paths can call it unconditionally.
|
||||
# Purging an id that was never tracked must be a quiet no-op, not a KeyError, so the delete/close paths can call it unconditionally.
|
||||
mgr = am.AgentManager()
|
||||
mgr.purge_session_memory("never-existed")
|
||||
assert mgr.sessions == {}
|
||||
|
||||
@@ -30,10 +30,7 @@ from backend.apps.settings.redaction import is_secret_field, redact_settings
|
||||
|
||||
CONNECTION_MODES = ["own_key", "openswarm-pro", "free-trial"]
|
||||
|
||||
# Every credential field the settings PUT path already treats as secret. Kept
|
||||
# here as the contract the redactor must honor; if PUT's notion of "secret"
|
||||
# grows, this list should too, and the drift-seal test fails until the redactor
|
||||
# also covers it.
|
||||
# Every credential field the settings PUT path already treats as secret. Kept here as the contract the redactor must honor; if PUT's notion of "secret" grows, this list should too, and the drift-seal test fails until the redactor also covers it.
|
||||
KNOWN_SECRET_FIELDS = [
|
||||
"anthropic_api_key", "openai_api_key", "google_api_key", "openrouter_api_key",
|
||||
"claude_subscription_token", "openai_subscription_token", "gemini_subscription_token",
|
||||
@@ -67,9 +64,7 @@ def p_settings_with(mode: str, keys: set[str], custom: bool = False) -> AppSetti
|
||||
return s
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The invariant: the live credential can never be blanked; others always can.
|
||||
# ---------------------------------------------------------------------------
|
||||
# --------------------------------------------------------------------------- The invariant: the live credential can never be blanked; others always can. ---------------------------------------------------------------------------
|
||||
|
||||
def test_live_api_key_can_never_be_blanked_but_others_can():
|
||||
key_subsets = [set(c) for r in range(5)
|
||||
@@ -98,8 +93,7 @@ def test_live_api_key_can_never_be_blanked_but_others_can():
|
||||
)
|
||||
|
||||
elif p.kind == "subscription":
|
||||
# The live credential isn't a settings field, so clearing
|
||||
# ANY api key is safe (it can't be the powering one).
|
||||
# The live credential isn't a settings field, so clearing ANY api key is safe (it can't be the powering one).
|
||||
for field in ALL_API_KEY_FIELDS:
|
||||
assert not write_would_suicide(field, "", p), (
|
||||
f"subscription run wrongly protected {field}: model={model} mode={mode}"
|
||||
@@ -145,9 +139,7 @@ def test_disconnect_all_models_spec_scenario():
|
||||
assert not write_would_suicide("anthropic_api_key", "", p2)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Drift seals.
|
||||
# ---------------------------------------------------------------------------
|
||||
# --------------------------------------------------------------------------- Drift seals. ---------------------------------------------------------------------------
|
||||
|
||||
def test_every_shipped_model_lane_classifies():
|
||||
"""A new model row that the resolver can't place would silently fall to the
|
||||
@@ -174,9 +166,7 @@ def test_redactor_catches_every_known_secret():
|
||||
|
||||
|
||||
def test_redaction_fail_safe_catches_misnamed_secret_by_value():
|
||||
# The name rule (_key/_token/_secret) would MISS a field named off-convention.
|
||||
# The value-shape backstop must still redact it, so a leak needs BOTH a bad
|
||||
# name AND a non-credential-shaped value, not just one.
|
||||
# The name rule (_key/_token/_secret) would MISS a field named off-convention. The value-shape backstop must still redact it, so a leak needs BOTH a bad name AND a non-credential-shaped value, not just one.
|
||||
import json
|
||||
raw = {"theme": "dark", "weird_field": "sk-ant-api03-AAAABBBBCCCCDDDDEEEEFFFF"}
|
||||
red = redact_settings(raw)
|
||||
|
||||
@@ -88,8 +88,7 @@ def test_live_stdio_settingswrite_refuses_live_key_clears_other(live_backend, re
|
||||
from backend.apps.agents.agent_manager import agent_manager
|
||||
from backend.apps.agents.core.models import AgentSession
|
||||
|
||||
# A real run on opus-4-8 in own_key mode: the Anthropic key powers it; an
|
||||
# OpenAI key is also connected (the "other provider").
|
||||
# A real run on opus-4-8 in own_key mode: the Anthropic key powers it; an OpenAI key is also connected (the "other provider").
|
||||
s = load_settings()
|
||||
s.connection_mode = "own_key"
|
||||
s.anthropic_api_key = "sk-ant-LIVE-do-not-clear"
|
||||
|
||||
@@ -97,8 +97,7 @@ def test_unknown_removed_fields_are_ignored(settings_file):
|
||||
|
||||
|
||||
def test_type_drifted_field_reverts_to_default_keeps_rest(settings_file):
|
||||
# dismissed_mcp_suggestions is dict[str,str] now; an old build stored a list.
|
||||
# The bad field must revert to its default, every valid field must survive.
|
||||
# dismissed_mcp_suggestions is dict[str,str] now; an old build stored a list. The bad field must revert to its default, every valid field must survive.
|
||||
p_write(settings_file, {"theme": "light", "dismissed_mcp_suggestions": ["legacy", "list"]})
|
||||
s = store.load_settings()
|
||||
assert s.theme == "light"
|
||||
|
||||
@@ -73,8 +73,7 @@ def test_analyzer_measures_replay_speedup_when_the_layer_helps(p_metrics_dir, ca
|
||||
|
||||
def test_analyzer_flags_silent_non_help_thrash(p_metrics_dir, capsys):
|
||||
sk.clear(wipe_disk=True)
|
||||
# A task that keeps getting re-learned/edited and quarantined, never promoted,
|
||||
# and whose runs always go via the LLM (never the fast path) = the ghost.
|
||||
# A task that keeps getting re-learned/edited and quarantined, never promoted, and whose runs always go via the LLM (never the fast path) = the ghost.
|
||||
sk.record_skill("bad.com", "do thing now", p_log()) # learn
|
||||
sk.mark_replay_failed("bad.com", "do thing now") # quarantine
|
||||
edited = p_log()[:-1] + [{"tool": "BrowserClickIndex", "input": {}, "ok": True,
|
||||
@@ -115,8 +114,7 @@ def test_analyzer_reports_composition(p_metrics_dir, capsys):
|
||||
|
||||
|
||||
def test_analyzer_reports_playbook_cutting_exploration_turns(p_metrics_dir, capsys):
|
||||
# tier-2 win: a cold run on a host takes many turns; once strategy is seeded,
|
||||
# the same kind of task takes fewer. The analyzer must report HELPS.
|
||||
# tier-2 win: a cold run on a host takes many turns; once strategy is seeded, the same kind of task takes fewer. The analyzer must report HELPS.
|
||||
sig = sk.compute_sig("find people")
|
||||
p_task_row(sig, "llm", 60.0, turns=14, playbook_seeded=False) # cold
|
||||
p_task_row(sig, "llm", 40.0, turns=8, playbook_seeded=True) # seeded -> fewer turns
|
||||
|
||||
@@ -75,8 +75,7 @@ def test_github_headers_adds_token_when_set(monkeypatch):
|
||||
|
||||
|
||||
def test_install_disclosure_flags_secret_shaped_files():
|
||||
# The scan we wire into the install disclosure (reused from the .swarm importer)
|
||||
# must flag a community skill shipping credentials, and leave clean files alone.
|
||||
# The scan we wire into the install disclosure (reused from the .swarm importer) must flag a community skill shipping credentials, and leave clean files alone.
|
||||
from backend.apps.swarm.redact import find_secrets_in_files
|
||||
files = {
|
||||
"SKILL.md": b"Renders PDFs. No secrets.",
|
||||
@@ -97,9 +96,7 @@ def test_script_classification():
|
||||
assert not is_script_path("data.json")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Safe install (write_folder_skill).
|
||||
# ---------------------------------------------------------------------------
|
||||
# --------------------------------------------------------------------------- Safe install (write_folder_skill). ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def skills_dir(tmp_path, monkeypatch):
|
||||
|
||||
@@ -14,8 +14,7 @@ from backend.apps.skill_registry import skill_registry as sr
|
||||
|
||||
|
||||
def test_bundled_snapshot_exists_and_includes_pdf():
|
||||
# The onboarding step targets the "pdf" skill via /pdf/i; it must be present
|
||||
# in the shipped snapshot or the tour times out even with a populated list.
|
||||
# The onboarding step targets the "pdf" skill via /pdf/i; it must be present in the shipped snapshot or the tour times out even with a populated list.
|
||||
assert os.path.exists(sr.BUNDLED_SNAPSHOT)
|
||||
data = json.load(open(sr.BUNDLED_SNAPSHOT, encoding="utf-8"))
|
||||
assert isinstance(data, dict) and len(data) >= 10
|
||||
@@ -24,8 +23,7 @@ def test_bundled_snapshot_exists_and_includes_pdf():
|
||||
|
||||
|
||||
def test_seed_makes_catalog_non_empty_offline(monkeypatch, tmp_path):
|
||||
# Point the disk cache at an empty tmp dir so only the bundled snapshot can
|
||||
# seed; this is the brand-new-install, no-network case.
|
||||
# Point the disk cache at an empty tmp dir so only the bundled snapshot can seed; this is the brand-new-install, no-network case.
|
||||
monkeypatch.setenv("OPENSWARM_SKILL_CACHE_DIR", str(tmp_path))
|
||||
seeded = sr.load_seed_cache()
|
||||
assert len(seeded) >= 10
|
||||
|
||||
@@ -154,9 +154,7 @@ def test_injection_no_folder_note_for_flat_skill(skills_dir):
|
||||
assert "supporting files" not in block.lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# .swarm round-trip for folder skills (export carries files, import rebuilds them).
|
||||
# ---------------------------------------------------------------------------
|
||||
# --------------------------------------------------------------------------- .swarm round-trip for folder skills (export carries files, import rebuilds them). ---------------------------------------------------------------------------
|
||||
|
||||
def test_swarm_export_folder_skill_carries_supporting_files(skills_dir):
|
||||
from backend.apps.swarm.entities.skills import SkillExportable
|
||||
@@ -182,8 +180,7 @@ def test_swarm_import_writes_folder_when_files_present(skills_dir):
|
||||
|
||||
|
||||
def test_swarm_import_always_writes_folder(skills_dir):
|
||||
# Unified storage: even a one-file skill imports as a folder, so a skill's
|
||||
# on-disk shape never depends on whether it had supporting files.
|
||||
# Unified storage: even a one-file skill imports as a folder, so a skill's on-disk shape never depends on whether it had supporting files.
|
||||
from backend.apps.swarm.entities.skills import SkillExportable
|
||||
payload = {"slug": "note", "name": "Note", "content": "just text"}
|
||||
new_id = SkillExportable.import_(payload, {}, None)
|
||||
@@ -196,8 +193,7 @@ async def test_create_writes_folder_and_supersedes_legacy_flat(skills_dir):
|
||||
from backend.apps.skills.models import SkillCreate
|
||||
# A pre-existing legacy flat skill of the same id...
|
||||
p_write(str(skills_dir / "notes.md"), "old flat")
|
||||
# ...is superseded (not shadowed) when the user (re)creates it; folder wins,
|
||||
# and the phantom flat file is removed so there's exactly one shape on disk.
|
||||
# ...is superseded (not shadowed) when the user (re)creates it; folder wins, and the phantom flat file is removed so there's exactly one shape on disk.
|
||||
res = await skills_mod.create_skill(SkillCreate(name="Notes", content="new body", description="d"))
|
||||
sid = res["skill"]["id"]
|
||||
assert sid == "notes"
|
||||
|
||||
@@ -105,8 +105,7 @@ def p_capture_env(monkeypatch, settings, api_type, resolved_model, model_entry):
|
||||
|
||||
|
||||
def test_loop_builds_pro_proxy_env(monkeypatch):
|
||||
# OpenSwarm Pro: the run authenticates against the cloud proxy with the server bearer, never
|
||||
# the user's own key. Pin that the proxy bearer + base url land in the env.
|
||||
# OpenSwarm Pro: the run authenticates against the cloud proxy with the server bearer, never the user's own key. Pin that the proxy bearer + base url land in the env.
|
||||
from backend.apps.settings.models import AppSettings
|
||||
import backend.apps.settings.credentials as creds
|
||||
monkeypatch.setattr(creds, "proxy_auth", lambda s: ("pro-bearer-xyz", "https://api.openswarm.com/proxy"), raising=True)
|
||||
@@ -118,8 +117,7 @@ def test_loop_builds_pro_proxy_env(monkeypatch):
|
||||
|
||||
|
||||
def test_loop_builds_direct_openai_key_env(monkeypatch):
|
||||
# Direct OpenAI api-route key: routes through the local openai-passthrough that fixes the
|
||||
# max_tokens->max_completion_tokens rename GPT-5 requires. Pin the key + passthrough base url.
|
||||
# Direct OpenAI api-route key: routes through the local openai-passthrough that fixes the max_tokens->max_completion_tokens rename GPT-5 requires. Pin the key + passthrough base url.
|
||||
from backend.apps.settings.models import AppSettings
|
||||
settings = AppSettings(openai_api_key="sk-openai-test")
|
||||
env = p_capture_env(monkeypatch, settings, "openai", "cp-openai/gpt-5",
|
||||
@@ -129,8 +127,7 @@ def test_loop_builds_direct_openai_key_env(monkeypatch):
|
||||
|
||||
|
||||
def test_loop_builds_pinned_anthropic_api_route_env(monkeypatch):
|
||||
# A *-api route Claude model with a direct Anthropic key bypasses 9Router straight to
|
||||
# api.anthropic.com, and pins the subagent + small-fast models so they don't drift to the proxy.
|
||||
# A *-api route Claude model with a direct Anthropic key bypasses 9Router straight to api.anthropic.com, and pins the subagent + small-fast models so they don't drift to the proxy.
|
||||
from backend.apps.settings.models import AppSettings
|
||||
settings = AppSettings(anthropic_api_key="sk-ant-pinned")
|
||||
env = p_capture_env(monkeypatch, settings, "anthropic", "claude-3-5-api",
|
||||
@@ -141,8 +138,7 @@ def test_loop_builds_pinned_anthropic_api_route_env(monkeypatch):
|
||||
|
||||
|
||||
def test_loop_builds_9router_default_env(monkeypatch):
|
||||
# A subscription-route Claude model (cc/ -> 9Router) with no direct key and no Pro falls to
|
||||
# the 9Router default lane. Pin that it routes through 9Router on localhost:20128.
|
||||
# A subscription-route Claude model (cc/ -> 9Router) with no direct key and no Pro falls to the 9Router default lane. Pin that it routes through 9Router on localhost:20128.
|
||||
from backend.apps.settings.models import AppSettings
|
||||
import backend.apps.nine_router as nr
|
||||
monkeypatch.setattr(nr, "is_running", lambda: True, raising=True)
|
||||
@@ -153,8 +149,7 @@ def test_loop_builds_9router_default_env(monkeypatch):
|
||||
|
||||
|
||||
def test_loop_builds_direct_gemini_key_env(monkeypatch):
|
||||
# Direct Google AI Studio key: routed through the local anthropic-proxy that scrubs the
|
||||
# JSON-Schema fields Gemini rejects. Pin the Gemini keys + the proxy base url.
|
||||
# Direct Google AI Studio key: routed through the local anthropic-proxy that scrubs the JSON-Schema fields Gemini rejects. Pin the Gemini keys + the proxy base url.
|
||||
from backend.apps.settings.models import AppSettings
|
||||
settings = AppSettings(google_api_key="g-key-test")
|
||||
env = p_capture_env(monkeypatch, settings, "gemini", "cp-gemini/gemini-2.5-pro",
|
||||
@@ -165,8 +160,7 @@ def test_loop_builds_direct_gemini_key_env(monkeypatch):
|
||||
|
||||
|
||||
def test_loop_builds_openrouter_env(monkeypatch):
|
||||
# OpenRouter: routes through 9Router (must be up). Pin the 9Router base + that subagent ids
|
||||
# fall back to OR's resold Claude when the user has no Anthropic key.
|
||||
# OpenRouter: routes through 9Router (must be up). Pin the 9Router base + that subagent ids fall back to OR's resold Claude when the user has no Anthropic key.
|
||||
from backend.apps.settings.models import AppSettings
|
||||
import backend.apps.nine_router as nr
|
||||
monkeypatch.setattr(nr, "is_running", lambda: True, raising=True)
|
||||
@@ -178,10 +172,7 @@ def test_loop_builds_openrouter_env(monkeypatch):
|
||||
|
||||
|
||||
def test_loop_builds_direct_anthropic_key_env(monkeypatch):
|
||||
# Pin the provider env/route config the loop builds, the part the hook flagged as untested.
|
||||
# Drive the REAL loop with a direct-Anthropic-key config (own_key, a non-9router model, no
|
||||
# pinned api-route) and capture the ClaudeAgentOptions; the env must carry exactly the user's
|
||||
# Anthropic key so the SDK authenticates against api.anthropic.com directly.
|
||||
# Pin the provider env/route config the loop builds, the part the hook flagged as untested. Drive the REAL loop with a direct-Anthropic-key config (own_key, a non-9router model, no pinned api-route) and capture the ClaudeAgentOptions; the env must carry exactly the user's Anthropic key so the SDK authenticates against api.anthropic.com directly.
|
||||
from backend.apps.settings.models import AppSettings
|
||||
import backend.apps.agents.providers.registry as reg
|
||||
import backend.apps.agents.agent_manager as am
|
||||
@@ -218,10 +209,7 @@ def test_loop_builds_direct_anthropic_key_env(monkeypatch):
|
||||
|
||||
|
||||
def test_loop_with_session_cwd_runs_workspace_git_init(monkeypatch):
|
||||
# Regression: a session WITH a cwd hits the workspace git-init call in the loop. Harness
|
||||
# sessions normally have no cwd, which masked a NameError (the call said ensure_cwd_git_repo
|
||||
# while only _ensure_cwd_git_repo was imported). raising=True here would fail if the name were
|
||||
# missing again; the assertions confirm the cwd path actually runs and the turn completes.
|
||||
# Regression: a session WITH a cwd hits the workspace git-init call in the loop. Harness sessions normally have no cwd, which masked a NameError (the call said ensure_cwd_git_repo while only _ensure_cwd_git_repo was imported). raising=True here would fail if the name were missing again; the assertions confirm the cwd path actually runs and the turn completes.
|
||||
import backend.apps.agents.manager.run.RunOptions as run_opts
|
||||
called = {}
|
||||
|
||||
@@ -250,12 +238,7 @@ def test_loop_with_session_cwd_runs_workspace_git_init(monkeypatch):
|
||||
|
||||
|
||||
def test_full_streaming_turn_drives_the_complete_ws_contract(monkeypatch):
|
||||
# The closest in-repo proxy for a live streaming run: drive the REAL loop with the exact
|
||||
# SDK sequence the live provider emits, partial StreamEvents (block start -> text deltas ->
|
||||
# stop -> message_stop), THEN the AssistantMessage envelope, THEN the ResultMessage. Asserts
|
||||
# the FULL observable contract the live UI consumes end to end (stream_start, the streamed
|
||||
# deltas, the committed assistant message, the token/context meter, the per-turn token math).
|
||||
# This exercises stream_event + assistant_message + result_message together, through the loop.
|
||||
# The closest in-repo proxy for a live streaming run: drive the REAL loop with the exact SDK sequence the live provider emits, partial StreamEvents (block start -> text deltas -> stop -> message_stop), THEN the AssistantMessage envelope, THEN the ResultMessage. Asserts the FULL observable contract the live UI consumes end to end (stream_start, the streamed deltas, the committed assistant message, the token/context meter, the per-turn token math). This exercises stream_event + assistant_message + result_message together, through the loop.
|
||||
msgs = [
|
||||
p_stream({"type": "content_block_start", "index": 0, "content_block": {"type": "text"}}),
|
||||
p_stream({"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "Hel"}}),
|
||||
@@ -280,11 +263,7 @@ def test_full_streaming_turn_drives_the_complete_ws_contract(monkeypatch):
|
||||
|
||||
|
||||
def test_loop_wires_all_four_hooks_to_a_live_hook_context(monkeypatch):
|
||||
# Integration coverage the unit tests can't give: capture the ClaudeAgentOptions the real
|
||||
# loop hands to query(), then invoke the WIRED hooks. This proves run_agent_loop builds a
|
||||
# HookContext (all required fields, incl. the live `sessions` registry) and the four thin
|
||||
# wrappers delegate to the extracted hook modules. The SDK never fires these under a mocked
|
||||
# query, so without this the wiring (not just the functions) would be untested.
|
||||
# Integration coverage the unit tests can't give: capture the ClaudeAgentOptions the real loop hands to query(), then invoke the WIRED hooks. This proves run_agent_loop builds a HookContext (all required fields, incl. the live `sessions` registry) and the four thin wrappers delegate to the extracted hook modules. The SDK never fires these under a mocked query, so without this the wiring (not just the functions) would be untested.
|
||||
captured = {}
|
||||
|
||||
async def capturing_query(*args, **kwargs):
|
||||
@@ -312,8 +291,7 @@ def test_loop_wires_all_four_hooks_to_a_live_hook_context(monkeypatch):
|
||||
stop = options.hooks["Stop"][0].hooks
|
||||
assert pre and post and stop
|
||||
|
||||
# Invoke the wired Stop hook: a non-view-builder session short-circuits to {} by reading
|
||||
# ctx.session.mode, so this drives the full wrapper -> hook_ctx -> stop_hook module path.
|
||||
# Invoke the wired Stop hook: a non-view-builder session short-circuits to {} by reading ctx.session.mode, so this drives the full wrapper -> hook_ctx -> stop_hook module path.
|
||||
assert asyncio.run(stop[0]({}, None, None)) == {}
|
||||
|
||||
|
||||
@@ -357,8 +335,7 @@ def test_completes_even_with_no_content(monkeypatch):
|
||||
|
||||
|
||||
def test_thinking_block_before_text_is_handled(monkeypatch):
|
||||
# a ThinkingBlock mutates the separate thinking-state cluster; the turn must still
|
||||
# surface the final answer and complete (pins the thinking path for the restructuring)
|
||||
# a ThinkingBlock mutates the separate thinking-state cluster; the turn must still surface the final answer and complete (pins the thinking path for the restructuring)
|
||||
session, events = p_drive(monkeypatch, [
|
||||
p_assistant([ThinkingBlock(thinking="let me reason about this", signature="sig-1"),
|
||||
TextBlock(text="the answer is 42")]),
|
||||
@@ -369,9 +346,7 @@ def test_thinking_block_before_text_is_handled(monkeypatch):
|
||||
|
||||
|
||||
def test_transient_capacity_error_is_retried_then_succeeds(monkeypatch):
|
||||
# the capacity-retry while-loop: first query() raises a transient error, the loop
|
||||
# backs off (sleep mocked to no-op) and re-queries, which succeeds. This is the exact
|
||||
# behavior the streaming restructuring must preserve.
|
||||
# the capacity-retry while-loop: first query() raises a transient error, the loop backs off (sleep mocked to no-op) and re-queries, which succeeds. This is the exact behavior the streaming restructuring must preserve.
|
||||
real_sleep = asyncio.sleep # capture before patching to avoid self-recursion
|
||||
|
||||
async def p_fast_sleep(*a, **k):
|
||||
@@ -408,11 +383,7 @@ def test_transient_capacity_error_is_retried_then_succeeds(monkeypatch):
|
||||
|
||||
|
||||
def test_thinking_pill_shows_per_turn_delta_not_cumulative(monkeypatch):
|
||||
# The pill's token total must reflect THIS turn's new tokens, not the whole session's
|
||||
# running cumulative (the baseline-delta fix: capture-at-turn-start, subtract-at-emit,
|
||||
# unified through TurnState). Prior turns left 1500 tokens on the session; this turn adds
|
||||
# 100 in + 50 out = 150. Before the fix the baseline writes leaked into a closure-local
|
||||
# and the pill showed the cumulative 1650; now it shows 150.
|
||||
# The pill's token total must reflect THIS turn's new tokens, not the whole session's running cumulative (the baseline-delta fix: capture-at-turn-start, subtract-at-emit, unified through TurnState). Prior turns left 1500 tokens on the session; this turn adds 100 in + 50 out = 150. Before the fix the baseline writes leaked into a closure-local and the pill showed the cumulative 1650; now it shows 150.
|
||||
pills = []
|
||||
|
||||
async def fake_send(sid, event, data):
|
||||
|
||||
@@ -70,8 +70,7 @@ def test_content_secret_redacted_in_bundle(skill_store):
|
||||
secret = "sk-ant-api03-AAAAAAAAAAAAAAAAAAAAAAAA"
|
||||
p_make_skill(skill_store, "leaky", "Leaky", f"use this key: {secret}")
|
||||
raw, p_name = closure.build_bundle(EntityType.skill, "leaky")
|
||||
# Inspect the actual packed payload (zip entries are compressed, so grepping
|
||||
# the raw bytes proves nothing).
|
||||
# Inspect the actual packed payload (zip entries are compressed, so grepping the raw bytes proves nothing).
|
||||
with zipfile.ZipFile(io.BytesIO(raw)) as zf:
|
||||
payload_name = next(n for n in zf.namelist() if n.endswith("payload.json"))
|
||||
payload = json.loads(zf.read(payload_name))
|
||||
@@ -100,8 +99,7 @@ def test_pack_refuses_denied_key():
|
||||
|
||||
|
||||
def test_pack_refuses_secret_in_workspace_file():
|
||||
# A key hardcoded in app source (not .env) must not ride along; pack scans
|
||||
# file bytes, not just payload keys.
|
||||
# A key hardcoded in app source (not .env) must not ride along; pack scans file bytes, not just payload keys.
|
||||
leak = b"const KEY = 'sk-ant-api03-AAAAAAAAAAAAAAAAAAAAAAAA';\n"
|
||||
with pytest.raises(BundleError):
|
||||
pack({"format_version": 1}, {"bid1": {"name": "ok"}}, {"entities/bid1/files/config.js": leak})
|
||||
@@ -113,8 +111,7 @@ def test_pack_allows_clean_workspace_file():
|
||||
|
||||
|
||||
def test_app_export_drops_machine_env(tmp_path, monkeypatch):
|
||||
# The live .env holds the source machine's absolute paths + pinned port; it
|
||||
# must never ride along. .env.example (portable) does.
|
||||
# The live .env holds the source machine's absolute paths + pinned port; it must never ride along. .env.example (portable) does.
|
||||
from backend.apps.swarm.entities import apps as appmod
|
||||
from backend.apps.outputs.models import Output
|
||||
|
||||
@@ -159,11 +156,7 @@ def test_workflow_sanitize_disables_schedule_and_strips_pii():
|
||||
|
||||
|
||||
def test_workflow_round_trips_through_the_store(isolated_workflows_data):
|
||||
# The workflow store landed on this branch, so a workflow bundle imports into an
|
||||
# (isolated) store: an unknown id loads as None, import_ creates a fresh row with its
|
||||
# schedule forced OFF (so an imported workflow never auto-runs on someone else's machine),
|
||||
# and load reads it back. Supersedes test_workflow_unavailable_on_this_branch, which dated
|
||||
# from before the workflow store was on eric/dev.
|
||||
# The workflow store landed on this branch, so a workflow bundle imports into an (isolated) store: an unknown id loads as None, import_ creates a fresh row with its schedule forced OFF (so an imported workflow never auto-runs on someone else's machine), and load reads it back. Supersedes test_workflow_unavailable_on_this_branch, which dated from before the workflow store was on eric/dev.
|
||||
from backend.apps.swarm.entities.workflows import WorkflowExportable
|
||||
from backend.apps.swarm.exportable import RemapTable
|
||||
from backend.apps.workflows import storage
|
||||
@@ -175,8 +168,7 @@ def test_workflow_round_trips_through_the_store(isolated_workflows_data):
|
||||
loaded = WorkflowExportable.load(new_id)
|
||||
assert loaded is not None
|
||||
assert loaded.name == "Shared WF"
|
||||
# Read the persisted row back through the store's public API (not the entity's
|
||||
# private data) to confirm the schedule was forced off on import.
|
||||
# Read the persisted row back through the store's public API (not the entity's private data) to confirm the schedule was forced off on import.
|
||||
saved = storage.get_workflow(new_id)
|
||||
assert saved is not None and saved.schedule.enabled is False
|
||||
|
||||
@@ -205,8 +197,7 @@ def test_session_export_carries_transcript_drops_runtime_and_secrets():
|
||||
# Runtime, identity, and gate state still never leave.
|
||||
for gone in ("cwd", "active_mcps", "cost_usd", "sdk_session_id"):
|
||||
assert gone not in out
|
||||
# The closure runs scrub_payload on every payload, so a secret-shaped
|
||||
# string sitting in the transcript is redacted before it ships.
|
||||
# The closure runs scrub_payload on every payload, so a secret-shaped string sitting in the transcript is redacted before it ships.
|
||||
assert "sk-ant-" not in json.dumps(scrub_payload(out))
|
||||
reqs = ex.requirements()
|
||||
assert any(r.kind.value == "mcp_action" and r.key == "Gmail" for r in reqs)
|
||||
@@ -237,8 +228,7 @@ def test_session_import_restores_transcript_without_granting_mcp(monkeypatch):
|
||||
|
||||
|
||||
def test_session_import_old_bundle_without_transcript(monkeypatch):
|
||||
# A bundle made before transcripts were carried has no messages; it must
|
||||
# still import as a valid empty-history agent (single main branch), not crash.
|
||||
# A bundle made before transcripts were carried has no messages; it must still import as a valid empty-history agent (single main branch), not crash.
|
||||
from backend.apps.swarm.entities.SessionExportable import SessionExportable
|
||||
from backend.apps.swarm.exportable import RemapTable
|
||||
from backend.apps.agents.manager.session import session_store
|
||||
@@ -251,8 +241,7 @@ def test_session_import_old_bundle_without_transcript(monkeypatch):
|
||||
|
||||
|
||||
def test_session_load_prefers_live_memory_over_stale_disk(tmp_path, monkeypatch):
|
||||
# The freshest transcript lives in memory; a disk-only load would ship a
|
||||
# stale one. load() must read the live session first, disk only as fallback.
|
||||
# The freshest transcript lives in memory; a disk-only load would ship a stale one. load() must read the live session first, disk only as fallback.
|
||||
from backend.apps.agents import agent_manager as am
|
||||
from backend.apps.swarm.entities.SessionExportable import SessionExportable
|
||||
sdir = tmp_path / "sessions"
|
||||
@@ -275,10 +264,7 @@ def test_session_load_prefers_live_memory_over_stale_disk(tmp_path, monkeypatch)
|
||||
|
||||
|
||||
def test_dashboard_export_import_carries_agent_cards_and_transcript(tmp_path, monkeypatch):
|
||||
# The path the single-session tests missed: a whole dashboard with agent
|
||||
# cards + a browser card. Both agents (with their transcripts) and the
|
||||
# browser must survive export -> import. An empty-history import is the bug
|
||||
# the user hit ("the chats didn't even show up, let alone the history").
|
||||
# The path the single-session tests missed: a whole dashboard with agent cards + a browser card. Both agents (with their transcripts) and the browser must survive export -> import. An empty-history import is the bug the user hit ("the chats didn't even show up, let alone the history").
|
||||
import shutil
|
||||
from backend.apps.agents import agent_manager as am
|
||||
import backend.config.paths as paths
|
||||
@@ -327,19 +313,14 @@ def test_dashboard_export_import_carries_agent_cards_and_transcript(tmp_path, mo
|
||||
assert doc["active_mcps"] == [], "import must not grant MCP access"
|
||||
assert total_msgs == 2, "each agent's transcript must carry through"
|
||||
|
||||
# The bug behind "the chats didn't even show up": after import the sessions
|
||||
# are on disk but not in memory, and the dashboard-open fetch
|
||||
# (get_all_sessions) was memory-only, so the cards rendered blank. The fetch
|
||||
# must now see the freshly-imported sessions straight off disk.
|
||||
# The bug behind "the chats didn't even show up": after import the sessions are on disk but not in memory, and the dashboard-open fetch (get_all_sessions) was memory-only, so the cards rendered blank. The fetch must now see the freshly-imported sessions straight off disk.
|
||||
found = am.agent_manager.get_all_sessions(dashboard_id=root_id)
|
||||
assert len(found) == 2, f"dashboard-open fetch must see imported agent sessions, got {len(found)}"
|
||||
assert sum(len(s.messages) for s in found) == 2, "and with their transcripts"
|
||||
|
||||
|
||||
def test_get_all_sessions_does_not_resurrect_deleted_cards(tmp_path, monkeypatch):
|
||||
# Deleting a card removes it from the layout but the session keeps its
|
||||
# dashboard_id on disk. get_all_sessions must surface only sessions the
|
||||
# layout still has a card for, or deleted chats come back on every reopen.
|
||||
# Deleting a card removes it from the layout but the session keeps its dashboard_id on disk. get_all_sessions must surface only sessions the layout still has a card for, or deleted chats come back on every reopen.
|
||||
from backend.apps.agents import agent_manager as am
|
||||
import backend.config.paths as paths
|
||||
sdir = tmp_path / "sessions"
|
||||
@@ -422,12 +403,7 @@ def test_dashboard_import_remaps_to_fresh_local_ids(monkeypatch):
|
||||
|
||||
|
||||
def test_dashboard_remap_invariant_generative(monkeypatch):
|
||||
# The hand-written remap tests only check the id-bearing fields I remembered.
|
||||
# Generate random dashboards and assert the real invariant on a serialize ->
|
||||
# import round-trip: no source-local id and no bundle id survives into the
|
||||
# imported layout, and every card id is a freshly-minted local id. This is
|
||||
# what catches "someone adds a new layout field holding a session id and
|
||||
# forgets to remap it."
|
||||
# The hand-written remap tests only check the id-bearing fields I remembered. Generate random dashboards and assert the real invariant on a serialize -> import round-trip: no source-local id and no bundle id survives into the imported layout, and every card id is a freshly-minted local id. This is what catches "someone adds a new layout field holding a session id and forgets to remap it."
|
||||
import random
|
||||
|
||||
from backend.apps.swarm.entities import dashboards as dmod
|
||||
@@ -523,10 +499,7 @@ def test_skill_rollback_removes_it(skill_store):
|
||||
|
||||
|
||||
def test_commit_rolls_back_created_on_failure(skill_store, tmp_path, monkeypatch):
|
||||
# A bundle of [skill, workflow]: the skill imports first and lands, then the workflow
|
||||
# import fails, so the skill must be rolled back (all-or-nothing, no half-write). The
|
||||
# failure used to come for free (no workflow store on this branch); now the store exists,
|
||||
# so force it deterministically by making the workflow import raise.
|
||||
# A bundle of [skill, workflow]: the skill imports first and lands, then the workflow import fails, so the skill must be rolled back (all-or-nothing, no half-write). The failure used to come for free (no workflow store on this branch); now the store exists, so force it deterministically by making the workflow import raise.
|
||||
from backend.apps.swarm.models import BundlePreview, EntityRef, Manifest
|
||||
from backend.apps.swarm.entities.workflows import WorkflowExportable
|
||||
|
||||
@@ -552,8 +525,7 @@ def test_commit_rolls_back_created_on_failure(skill_store, tmp_path, monkeypatch
|
||||
|
||||
|
||||
def test_manifest_duplicate_ids_rejected():
|
||||
# Two entities sharing a bundle_id silently collapse in the topo/summary
|
||||
# dicts, dropping one; reject up front. (The manifest is outside the checksum.)
|
||||
# Two entities sharing a bundle_id silently collapse in the topo/summary dicts, dropping one; reject up front. (The manifest is outside the checksum.)
|
||||
from backend.apps.swarm.closure import validate_manifest
|
||||
from backend.apps.swarm.models import BundlePreview, EntityRef, Manifest
|
||||
ref = EntityRef(type=EntityType.skill, bundle_id="dup", name="A", path="entities/dup")
|
||||
@@ -603,8 +575,7 @@ def test_absolute_path_rejected():
|
||||
|
||||
|
||||
def test_symlink_entry_rejected():
|
||||
# A symlink entry could point outside the sandbox once followed; unpack must
|
||||
# refuse it before writing anything.
|
||||
# A symlink entry could point outside the sandbox once followed; unpack must refuse it before writing anything.
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
zi = zipfile.ZipInfo("link")
|
||||
|
||||
@@ -44,8 +44,7 @@ def test_slot_for_unknown_mcp_has_no_write_target():
|
||||
PolicySlot("mcp", None, "do-thing")
|
||||
|
||||
|
||||
# read/write mirror the dispatch-gate branches in agent_manager
|
||||
# (effective_policy / set_tool_policy): both key through resolve_policy_slot.
|
||||
# read/write mirror the dispatch-gate branches in agent_manager (effective_policy / set_tool_policy): both key through resolve_policy_slot.
|
||||
def p_read(tool_name, builtin_perms, tools):
|
||||
slot = resolve_policy_slot(tool_name, tools)
|
||||
if slot.store == "builtin":
|
||||
@@ -102,9 +101,7 @@ def test_two_actions_on_the_same_mcp_server_are_independent():
|
||||
assert p_read(f"mcp__{slug}__notion-create-pages", bp, tools) == "ask"
|
||||
|
||||
|
||||
# ---- Integration: the same round-trip through the REAL file persistence the gate
|
||||
# uses (load_builtin_permissions / _save / _load_all), so 'write then re-read'
|
||||
# survives a save+reload, not just an in-memory dict. ----
|
||||
# ---- Integration: the same round-trip through the REAL file persistence the gate uses (load_builtin_permissions / _save / _load_all), so 'write then re-read' survives a save+reload, not just an in-memory dict. ----
|
||||
import backend.apps.tools_lib.tools_lib as tl
|
||||
|
||||
|
||||
|
||||
@@ -36,9 +36,7 @@ P_TMPROOT = tempfile.mkdtemp(prefix="openswarm-v2-invariants-")
|
||||
os.environ.setdefault("OPENSWARM_DATA_DIR", P_TMPROOT)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixture: build a fake ToolDefinition without touching disk.
|
||||
# ---------------------------------------------------------------------------
|
||||
# --------------------------------------------------------------------------- Fixture: build a fake ToolDefinition without touching disk. ---------------------------------------------------------------------------
|
||||
|
||||
def p_fake_tool(
|
||||
name: str,
|
||||
@@ -60,14 +58,7 @@ def p_fake_tool(
|
||||
)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Group A, MCP activation gate (the non-bypassable ToolSearch invariant)
|
||||
# ===========================================================================
|
||||
# The product invariant: NO MCP tool is callable until the model has
|
||||
# explicitly searched + activated the server, and the user has approved
|
||||
# the activation. The gate lives at the dispatch layer in
|
||||
# `_build_mcp_servers`, even if the prompt rules are ignored, the SDK
|
||||
# never sees the unactivated server.
|
||||
# =========================================================================== Group A, MCP activation gate (the non-bypassable ToolSearch invariant) =========================================================================== The product invariant: NO MCP tool is callable until the model has explicitly searched + activated the server, and the user has approved the activation. The gate lives at the dispatch layer in `_build_mcp_servers`, even if the prompt rules are ignored, the SDK never sees the unactivated server.
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -210,14 +201,7 @@ async def test_gate_stress_random_activations():
|
||||
)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Group A2, ToolSearch loop-breaker
|
||||
# ===========================================================================
|
||||
# Gated MCP servers are withheld from the SDK, so the CLI's native ToolSearch
|
||||
# can never see them; small models loop (empty ToolSearch -> retry) until the
|
||||
# user pauses. The break must (a) not fire on the first call or two (a power
|
||||
# user may legitimately ToolSearch a deferred tool), (b) fire once it's clearly
|
||||
# stuck, steering to MCPActivate, and (c) reset when any real tool runs.
|
||||
# =========================================================================== Group A2, ToolSearch loop-breaker =========================================================================== Gated MCP servers are withheld from the SDK, so the CLI's native ToolSearch can never see them; small models loop (empty ToolSearch -> retry) until the user pauses. The break must (a) not fire on the first call or two (a power user may legitimately ToolSearch a deferred tool), (b) fire once it's clearly stuck, steering to MCPActivate, and (c) reset when any real tool runs.
|
||||
|
||||
|
||||
def test_toolsearch_redirect_holds_below_threshold():
|
||||
@@ -242,8 +226,7 @@ def test_toolsearch_redirect_fires_at_threshold_and_names_gated_servers():
|
||||
|
||||
|
||||
def test_toolsearch_redirect_works_with_no_gated_servers():
|
||||
# Even with nothing to activate, the steer must still tell the model its
|
||||
# tools are already loaded so it stops searching (no crash on empty list).
|
||||
# Even with nothing to activate, the steer must still tell the model its tools are already loaded so it stops searching (no crash on empty list).
|
||||
from backend.apps.agents.manager.prompt.prompt_context import (
|
||||
toolsearch_loop_redirect,
|
||||
TOOLSEARCH_LOOP_THRESHOLD,
|
||||
@@ -277,12 +260,7 @@ async def test_gated_server_names_empty_when_all_active():
|
||||
assert gated_mcp_server_names(["mcp:Gmail"], ["gmail"]) == []
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Group B, needs_fresh_session soft-restart
|
||||
# ===========================================================================
|
||||
# When MCPActivate fires mid-session, the bundled CLI doesn't re-read
|
||||
# mcp_servers from a fork. We force a fresh sdk_session_id so the new
|
||||
# server's tools actually reach the model.
|
||||
# =========================================================================== Group B, needs_fresh_session soft-restart =========================================================================== When MCPActivate fires mid-session, the bundled CLI doesn't re-read mcp_servers from a fork. We force a fresh sdk_session_id so the new server's tools actually reach the model.
|
||||
|
||||
|
||||
def test_needs_fresh_session_field_default_false():
|
||||
@@ -352,9 +330,7 @@ def test_active_mcps_append_idempotent():
|
||||
assert s.active_mcps.count("gmail") == 1
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Group C, Pydantic Message backward compat (no ghost fields, legacy loads)
|
||||
# ===========================================================================
|
||||
# =========================================================================== Group C, Pydantic Message backward compat (no ghost fields, legacy loads) ===========================================================================
|
||||
|
||||
|
||||
def test_message_no_ghost_fields():
|
||||
@@ -425,9 +401,7 @@ def test_message_round_trip_50_iterations():
|
||||
assert m2.elapsed_ms == m.elapsed_ms
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Group D, resolve_aux_model Gemini route (the gemini-3.1-flash-lite-preview fix)
|
||||
# ===========================================================================
|
||||
# =========================================================================== Group D, resolve_aux_model Gemini route (the gemini-3.1-flash-lite-preview fix) ===========================================================================
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -465,9 +439,7 @@ async def test_resolve_aux_model_anthropic_pro_returns_proxy():
|
||||
settings = AppSettings()
|
||||
settings.connection_mode = "openswarm-pro"
|
||||
settings.openswarm_proxy_url = "https://api.openswarm.test"
|
||||
# A real Pro-connected user carries a bearer token; proxy_auth reads it.
|
||||
# Without it the resolver can't see Pro and falls through to the raise,
|
||||
# which is what made this test depend on live machine state.
|
||||
# A real Pro-connected user carries a bearer token; proxy_auth reads it. Without it the resolver can't see Pro and falls through to the raise, which is what made this test depend on live machine state.
|
||||
settings.openswarm_bearer_token = "test-pro-token"
|
||||
with patch("backend.apps.nine_router.is_running", return_value=False):
|
||||
model_id, base = await registry.resolve_aux_model(settings)
|
||||
@@ -640,8 +612,7 @@ async def test_mcp_gate_only_forwards_activated_servers():
|
||||
return [SimpleNamespace(name=n, mcp_config={"x": 1}, enabled=True,
|
||||
auth_status="configured", auth_type="apikey") for n in names]
|
||||
|
||||
# allowed_tools == get_all_tool_names() bypasses the (separate) permission
|
||||
# gate so we isolate the ACTIVATION gate. sanitize_server_name -> identity.
|
||||
# allowed_tools == get_all_tool_names() bypasses the (separate) permission gate so we isolate the ACTIVATION gate. sanitize_server_name -> identity.
|
||||
with patch("backend.apps.agents.manager.RunSupport.load_all_tools", side_effect=installed), \
|
||||
patch("backend.apps.agents.manager.RunSupport.get_all_tool_names", return_value=["__ALL__"]), \
|
||||
patch("backend.apps.agents.manager.RunSupport.sanitize_server_name", side_effect=lambda n: n), \
|
||||
@@ -652,8 +623,7 @@ async def test_mcp_gate_only_forwards_activated_servers():
|
||||
assert await mgr.build_mcp_servers(allowed, active_mcps=[]) == {}
|
||||
# Boundary 2: None (legacy) -> permission gate only, all forwarded.
|
||||
assert set((await mgr.build_mcp_servers(allowed, active_mcps=None)).keys()) == set(names)
|
||||
# Property: forwarded set is ALWAYS a subset of the activated set, and
|
||||
# equals exactly the activated-and-installed intersection.
|
||||
# Property: forwarded set is ALWAYS a subset of the activated set, and equals exactly the activated-and-installed intersection.
|
||||
rng = random.Random(1234)
|
||||
for _ in range(400):
|
||||
active = rng.sample(names, rng.randint(0, len(names)))
|
||||
@@ -706,12 +676,7 @@ def test_banned_models_not_offered():
|
||||
assert "3.1 pro" not in all_labels
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Group E, 9Router-streamed 401 detection
|
||||
# ===========================================================================
|
||||
# 9Router sometimes returns upstream auth failures AS the assistant's
|
||||
# reply text, not as an exception. We detect the pattern in the stream
|
||||
# handler to substitute a friendly bubble.
|
||||
# =========================================================================== Group E, 9Router-streamed 401 detection =========================================================================== 9Router sometimes returns upstream auth failures AS the assistant's reply text, not as an exception. We detect the pattern in the stream handler to substitute a friendly bubble.
|
||||
|
||||
|
||||
def test_router_auth_pattern_codex():
|
||||
@@ -796,12 +761,7 @@ def test_is_auth_error_with_stderr_tail():
|
||||
assert is_auth_error(e, extra_text=stderr)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Group F, MCP_SERVER_BRAND coverage
|
||||
# ===========================================================================
|
||||
# Every server slug we surface to the user via MCPSearch / connected_servers
|
||||
# should have a brand entry, otherwise the UI falls back to the kebab-case
|
||||
# id ("microsoft-365" instead of "Microsoft 365").
|
||||
# =========================================================================== Group F, MCP_SERVER_BRAND coverage =========================================================================== Every server slug we surface to the user via MCPSearch / connected_servers should have a brand entry, otherwise the UI falls back to the kebab-case id ("microsoft-365" instead of "Microsoft 365").
|
||||
|
||||
|
||||
def test_mcp_brand_covers_curated_servers():
|
||||
@@ -854,16 +814,12 @@ def test_sanitize_server_name_strips_special_chars():
|
||||
assert sanitize_server_name("a__b") == "a-b"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Group G, mcp_meta_server activation backend handler
|
||||
# ===========================================================================
|
||||
# =========================================================================== Group G, mcp_meta_server activation backend handler ===========================================================================
|
||||
|
||||
|
||||
def test_mcp_activate_handler_unknown_server():
|
||||
"""Unknown server name → status='unknown_server' with the valid list."""
|
||||
# We test the response shape independently of the FastAPI plumbing.
|
||||
# The handler is a closure inside main.py:mcp_meta_handler, so we
|
||||
# instead exercise the contract: invalid name surfaces alternatives.
|
||||
# We test the response shape independently of the FastAPI plumbing. The handler is a closure inside main.py:mcp_meta_handler, so we instead exercise the contract: invalid name surfaces alternatives.
|
||||
from backend.apps.tools_lib.tools_lib import sanitize_server_name
|
||||
valid = {"gmail", "slack", "google-workspace"}
|
||||
requested = "Gmail" # raw, needs sanitize
|
||||
@@ -885,9 +841,7 @@ def test_active_mcps_persistence_on_session():
|
||||
assert rehydrated.active_mcps == ["gmail", "slack"]
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Group H, long-context error classifier
|
||||
# ===========================================================================
|
||||
# =========================================================================== Group H, long-context error classifier ===========================================================================
|
||||
|
||||
|
||||
def test_long_context_pattern_caught():
|
||||
@@ -917,8 +871,7 @@ def test_transient_capacity_patterns():
|
||||
]
|
||||
for t in transients:
|
||||
assert TRANSIENT_CAPACITY_PATTERNS.search(t), f"transient missed: {t!r}"
|
||||
# Importantly: must NOT also match non-transient (no double-classification)
|
||||
# except for the fuzzy edge cases. Spot-check a couple:
|
||||
# Importantly: must NOT also match non-transient (no double-classification) except for the fuzzy edge cases. Spot-check a couple:
|
||||
if "429" in t and "rate_limit" in t.lower():
|
||||
# rate_limit_error is transient; non-transient should not match this exact text
|
||||
assert not NON_TRANSIENT_PATTERNS.search(t)
|
||||
@@ -930,9 +883,7 @@ def test_long_context_does_not_match_normal_429():
|
||||
assert not NON_TRANSIENT_PATTERNS.search("Error 429: rate_limit_error")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Group I, Mode reconciliation (regression guard)
|
||||
# ===========================================================================
|
||||
# =========================================================================== Group I, Mode reconciliation (regression guard) ===========================================================================
|
||||
|
||||
|
||||
def test_chat_mode_not_in_builtins():
|
||||
@@ -953,9 +904,7 @@ def test_active_mcps_default_factory_creates_new_list():
|
||||
assert s2.active_mcps == [], "active_mcps must not share state across sessions"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Group J, Concurrent gate stress (real production risk: simultaneous turns)
|
||||
# ===========================================================================
|
||||
# =========================================================================== Group J, Concurrent gate stress (real production risk: simultaneous turns) ===========================================================================
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -979,9 +928,7 @@ async def test_concurrent_gate_calls_isolated():
|
||||
assert set(empty.keys()) == set()
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Group K, pending_continuation auto-restart
|
||||
# ===========================================================================
|
||||
# =========================================================================== Group K, pending_continuation auto-restart ===========================================================================
|
||||
|
||||
|
||||
def test_pending_continuation_default_false():
|
||||
@@ -1071,11 +1018,7 @@ async def test_context_update_emitter_refreshes_session_tokens(monkeypatch):
|
||||
)]
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Group L, Sentence-case display (the parseMcpToolName fix)
|
||||
# ===========================================================================
|
||||
# This is technically a frontend behavior, but we mirror the rule in
|
||||
# Python so the backend's MCPSearch results don't leak Title Case either.
|
||||
# =========================================================================== Group L, Sentence-case display (the parseMcpToolName fix) =========================================================================== This is technically a frontend behavior, but we mirror the rule in Python so the backend's MCPSearch results don't leak Title Case either.
|
||||
|
||||
|
||||
def test_sentence_case_rule():
|
||||
@@ -1094,9 +1037,7 @@ def test_sentence_case_rule():
|
||||
assert sentence_case(raw) == expected
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Group M, Bash command verb extraction (frontend logic, mirrored)
|
||||
# ===========================================================================
|
||||
# =========================================================================== Group M, Bash command verb extraction (frontend logic, mirrored) ===========================================================================
|
||||
|
||||
|
||||
def test_bash_verb_extraction_strips_env_prefix():
|
||||
@@ -1135,9 +1076,7 @@ def test_bash_command_detail_path_basename():
|
||||
assert basename(raw) == expected
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Group N, Pydantic AppSettings invariants
|
||||
# ===========================================================================
|
||||
# =========================================================================== Group N, Pydantic AppSettings invariants ===========================================================================
|
||||
|
||||
|
||||
def test_app_settings_defaults():
|
||||
@@ -1161,9 +1100,7 @@ def test_custom_provider_round_trip():
|
||||
assert s2.custom_providers[0].name == "MyCorp"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Group O, Tool gate stress with denied permissions
|
||||
# ===========================================================================
|
||||
# =========================================================================== Group O, Tool gate stress with denied permissions ===========================================================================
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -1194,9 +1131,7 @@ async def test_gate_handles_missing_refresh_token_gracefully():
|
||||
assert "myapitool" in result
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Group P, resolve_aux_model failover logic
|
||||
# ===========================================================================
|
||||
# =========================================================================== Group P, resolve_aux_model failover logic ===========================================================================
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -1241,9 +1176,7 @@ async def test_aux_returns_sonnet_when_preferred_tier_set():
|
||||
assert "sonnet" in model_id
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Group Q, get_api_type / model id resolution
|
||||
# ===========================================================================
|
||||
# =========================================================================== Group Q, get_api_type / model id resolution ===========================================================================
|
||||
|
||||
|
||||
def test_get_api_type_openai():
|
||||
@@ -1265,9 +1198,7 @@ def test_find_builtin_model_returns_dict_for_known():
|
||||
assert sonnet.get("api") == "anthropic"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Group R, context window
|
||||
# ===========================================================================
|
||||
# =========================================================================== Group R, context window ===========================================================================
|
||||
|
||||
|
||||
def test_get_context_window_known_model():
|
||||
@@ -1421,8 +1352,7 @@ def test_resolve_attachments_mixed_kinds_total_size_guard():
|
||||
mgr = AgentManager()
|
||||
paths = []
|
||||
try:
|
||||
# 10MB PDF + 10MB image + small text → 20MB raw = ~27MB base64,
|
||||
# under Anthropic's 28MB cap so all should land natively.
|
||||
# 10MB PDF + 10MB image + small text → 20MB raw = ~27MB base64, under Anthropic's 28MB cap so all should land natively.
|
||||
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as fh:
|
||||
fh.write(b"%PDF-1.4\n"); fh.write(b"X" * (10 * 1024 * 1024))
|
||||
paths.append(fh.name)
|
||||
@@ -1511,15 +1441,10 @@ def test_resolve_attachments_uses_os_path_basename_for_windows_paths():
|
||||
no crash on Windows-shaped strings. Real Windows behavior is exercised
|
||||
in CI on Windows hosts via .github/workflows/."""
|
||||
import os, ntpath
|
||||
# ntpath.basename simulates what Windows os.path.basename does on
|
||||
# actual Windows hosts. Our backend uses os.path which == ntpath on
|
||||
# Windows and posixpath on macOS/Linux, so paths go through correctly
|
||||
# at runtime per host. This test asserts the parsing is correct WHEN
|
||||
# routed through ntpath (the Windows code path).
|
||||
# ntpath.basename simulates what Windows os.path.basename does on actual Windows hosts. Our backend uses os.path which == ntpath on Windows and posixpath on macOS/Linux, so paths go through correctly at runtime per host. This test asserts the parsing is correct WHEN routed through ntpath (the Windows code path).
|
||||
win_path = r"C:\Users\rrios\AppData\Local\Temp\self-swarm-uploads\palm.pdf"
|
||||
assert ntpath.basename(win_path) == "palm.pdf"
|
||||
# And that os.path.join with mixed separators on Windows would still
|
||||
# produce a valid path (ntpath is forgiving).
|
||||
# And that os.path.join with mixed separators on Windows would still produce a valid path (ntpath is forgiving).
|
||||
assert ntpath.basename(r"D:/Downloads\test.pdf") == "test.pdf"
|
||||
|
||||
|
||||
@@ -2290,9 +2215,7 @@ def test_apply_context_window_respects_custom_provider_value():
|
||||
assert s.context_window == 32_000
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Custom OpenAI-compatible providers (Ollama Cloud, Together, etc.)
|
||||
# ---------------------------------------------------------------------------
|
||||
# --------------------------------------------------------------------------- Custom OpenAI-compatible providers (Ollama Cloud, Together, etc.) ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_custom_provider_value_synthesises_route_api_entry():
|
||||
@@ -2643,9 +2566,7 @@ def test_custom_provider_with_very_long_name_still_works():
|
||||
assert entry["model_id"] == f"cp-{slug}/some-model"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 9Router sync stress tests, async, mocked HTTP layer
|
||||
# ===========================================================================
|
||||
# =========================================================================== 9Router sync stress tests, async, mocked HTTP layer ===========================================================================
|
||||
|
||||
|
||||
def p_make_mock_9router(initial_nodes=None, initial_conns=None, fail_endpoints=None):
|
||||
@@ -2984,9 +2905,7 @@ def p_async_return(value):
|
||||
return p_f()
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Group T, Mode definitions
|
||||
# ===========================================================================
|
||||
# =========================================================================== Group T, Mode definitions ===========================================================================
|
||||
|
||||
|
||||
def test_agent_mode_no_explicit_tools():
|
||||
@@ -3017,9 +2936,7 @@ def test_view_builder_mode_has_default_folder():
|
||||
assert vb.default_folder is not None
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Group U, Stress: gate handles 100 sequential calls without state leak
|
||||
# ===========================================================================
|
||||
# =========================================================================== Group U, Stress: gate handles 100 sequential calls without state leak ===========================================================================
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -3038,9 +2955,7 @@ async def test_gate_100_sequential_calls_no_leak():
|
||||
f"iteration {i}: expected {set(active)}, got {set(result.keys())}"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Group V, Discord shim entrypoint sanity
|
||||
# ===========================================================================
|
||||
# =========================================================================== Group V, Discord shim entrypoint sanity ===========================================================================
|
||||
|
||||
|
||||
def test_discord_shim_main_callable():
|
||||
@@ -3055,9 +2970,7 @@ def test_discord_shim_package_importable():
|
||||
assert backend.apps.discord_mcp_shim is not None
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Group W, Tools/web.py (live MCP for DDG search)
|
||||
# ===========================================================================
|
||||
# =========================================================================== Group W, Tools/web.py (live MCP for DDG search) ===========================================================================
|
||||
|
||||
|
||||
def test_web_tools_classes_inherit_basetool():
|
||||
@@ -3081,9 +2994,7 @@ def test_web_fetch_tool_has_name_and_schema():
|
||||
assert isinstance(tool.get_schema(), dict)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Group X, ToolGroupMeta + caching
|
||||
# ===========================================================================
|
||||
# =========================================================================== Group X, ToolGroupMeta + caching ===========================================================================
|
||||
|
||||
|
||||
def test_tool_group_meta_round_trip():
|
||||
@@ -3102,9 +3013,7 @@ def test_tool_group_meta_default_is_refined_false():
|
||||
assert m.is_refined is False
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Group Y, MessageBranch invariants
|
||||
# ===========================================================================
|
||||
# =========================================================================== Group Y, MessageBranch invariants ===========================================================================
|
||||
|
||||
|
||||
def test_session_has_main_branch_by_default():
|
||||
@@ -3124,9 +3033,7 @@ def test_branch_serialization():
|
||||
assert s2.branches["alt"].parent_branch_id == "main"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Group Z, End-to-end: realistic session lifecycle
|
||||
# ===========================================================================
|
||||
# =========================================================================== Group Z, End-to-end: realistic session lifecycle ===========================================================================
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -19,9 +19,7 @@ import re
|
||||
import pytest
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Mirror: parseMcpToolName.displayName (sentence-case rule)
|
||||
# ===========================================================================
|
||||
# =========================================================================== Mirror: parseMcpToolName.displayName (sentence-case rule) ===========================================================================
|
||||
|
||||
def parse_mcp_tool_name_display(raw_name: str) -> str | None:
|
||||
"""Mirror of frontend parseMcpToolName().displayName."""
|
||||
@@ -63,9 +61,7 @@ def test_parse_mcp_tool_name_no_title_case():
|
||||
assert "A New Page" not in bad
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Mirror: getResultSummary (glyph-free regression test)
|
||||
# ===========================================================================
|
||||
# =========================================================================== 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."""
|
||||
@@ -103,9 +99,7 @@ def test_no_check_glyph_in_summaries():
|
||||
assert "✓" not in s and "✔" not in s and "✗" not in s and "✘" not in s
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Mirror: bashCommandDetail extraction
|
||||
# ===========================================================================
|
||||
# =========================================================================== Mirror: bashCommandDetail extraction ===========================================================================
|
||||
|
||||
def bash_command_detail(raw_cmd: str) -> str:
|
||||
"""Mirror of frontend bashCommandDetail."""
|
||||
@@ -181,9 +175,7 @@ def test_bash_detail_handles_empty():
|
||||
assert bash_command_detail(" ") == ""
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Mirror: prettyPath (basename a path)
|
||||
# ===========================================================================
|
||||
# =========================================================================== Mirror: prettyPath (basename a path) ===========================================================================
|
||||
|
||||
def pretty_path(p: str) -> str:
|
||||
if not p:
|
||||
@@ -209,9 +201,7 @@ def test_pretty_path_empty():
|
||||
assert pretty_path("") == ""
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Mirror: prettyUrl (host-only)
|
||||
# ===========================================================================
|
||||
# =========================================================================== Mirror: prettyUrl (host-only) ===========================================================================
|
||||
|
||||
def pretty_url(u: str) -> str:
|
||||
if not u:
|
||||
@@ -241,9 +231,7 @@ def test_pretty_url_empty():
|
||||
assert pretty_url("") == ""
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Mirror: quoteQuery
|
||||
# ===========================================================================
|
||||
# =========================================================================== Mirror: quoteQuery ===========================================================================
|
||||
|
||||
def quote_query(q: str, max_len: int = 60) -> str:
|
||||
if not q:
|
||||
@@ -268,9 +256,7 @@ def test_quote_query_empty():
|
||||
assert quote_query("") == ""
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Mirror: stable-seeded variant pick (djb2 hash → mod n)
|
||||
# ===========================================================================
|
||||
# =========================================================================== Mirror: stable-seeded variant pick (djb2 hash → mod n) ===========================================================================
|
||||
|
||||
def stable_index(seed: str | None, n: int) -> int:
|
||||
"""Mirror of frontend _stableIndex."""
|
||||
@@ -324,9 +310,7 @@ def test_stable_index_n_one():
|
||||
assert stable_index("anything", 1) == 0
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Mirror: bash verb extraction (the leading-binary lookup)
|
||||
# ===========================================================================
|
||||
# =========================================================================== Mirror: bash verb extraction (the leading-binary lookup) ===========================================================================
|
||||
|
||||
BIN_VERB_MAP = {
|
||||
"rm": ("Deleting", "Deleted"),
|
||||
|
||||
@@ -32,8 +32,7 @@ def test_claude_pro_uses_native_path():
|
||||
|
||||
|
||||
def test_subscription_route_claude_non_pro_registers():
|
||||
# opus-4-8 on a non-Pro own-key account: the aux haiku call 401s through 9Router, so a bare
|
||||
# key isn't enough -> fall back to openswarm-web.
|
||||
# opus-4-8 on a non-Pro own-key account: the aux haiku call 401s through 9Router, so a bare key isn't enough -> fall back to openswarm-web.
|
||||
assert p_call(router_model_id="cc/opus", api_type="anthropic",
|
||||
connection_mode="own_key", anthropic_api_key="sk-ant-xxx") is True
|
||||
|
||||
|
||||
@@ -128,10 +128,7 @@ async def test_everything_fails_is_honest_not_empty(monkeypatch):
|
||||
assert "Settings" in res["results"] or "API key" in res["results"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# /fetch mirrors /search: local httpx + trafilatura is the fast path, grounded
|
||||
# fetchers are the fallback for JS/paywalled pages, every attempt is bounded.
|
||||
# --------------------------------------------------------------------------
|
||||
# -------------------------------------------------------------------------- /fetch mirrors /search: local httpx + trafilatura is the fast path, grounded fetchers are the fallback for JS/paywalled pages, every attempt is bounded. --------------------------------------------------------------------------
|
||||
|
||||
from backend.apps.web.web import fetch, FetchBody
|
||||
from backend.apps.agents.tools.web import WebFetchTool
|
||||
|
||||
@@ -29,6 +29,5 @@ def test_both_is_reliable():
|
||||
|
||||
|
||||
def test_subscription_route_claude_is_NOT_reliable():
|
||||
# The exact bug: opus-4-8 (subscription route) + key in settings. The haiku
|
||||
# call still 401s via the managed pool, so this must stay unreliable -> DDG.
|
||||
# The exact bug: opus-4-8 (subscription route) + key in settings. The haiku call still 401s via the managed pool, so this must stay unreliable -> DDG.
|
||||
assert ok(uses_direct_anthropic_api=False, is_pro=False) is False
|
||||
|
||||
@@ -52,8 +52,7 @@ def isolated_data_dir(monkeypatch, tmp_path):
|
||||
_escalation._state.clear()
|
||||
_executor._run_control.clear()
|
||||
_executor._run_pause_override.clear()
|
||||
# Also clear audit dir reference; audit.py reads DATA_DIR at import via
|
||||
# module-level expression, so reach in and override the AUDIT_DIR too.
|
||||
# Also clear audit dir reference; audit.py reads DATA_DIR at import via module-level expression, so reach in and override the AUDIT_DIR too.
|
||||
from backend.apps.workflows import audit as _audit
|
||||
monkeypatch.setattr(_audit, "AUDIT_DIR", str(tmp_path / "workflows" / "audit"))
|
||||
yield
|
||||
@@ -89,9 +88,7 @@ def test_dst_spring_forward_weekly():
|
||||
nxt = _next_fire_after(sched, ref_local.astimezone(timezone.utc))
|
||||
assert nxt is not None
|
||||
nxt_local = nxt.astimezone(tz)
|
||||
# 02:30 wall-clock on the spring-forward day doesn't exist; zoneinfo
|
||||
# resolves it forward to 03:30. The point is the *date* lands on the
|
||||
# 9th, not the 8th and not the 16th.
|
||||
# 02:30 wall-clock on the spring-forward day doesn't exist; zoneinfo resolves it forward to 03:30. The point is the *date* lands on the 9th, not the 8th and not the 16th.
|
||||
assert nxt_local.date() == datetime(2025, 3, 9).date()
|
||||
assert nxt_local.hour in (2, 3)
|
||||
|
||||
@@ -106,8 +103,7 @@ def test_dst_fall_back_no_double_fire():
|
||||
ref_local = datetime(2025, 11, 1, 23, 0, tzinfo=tz)
|
||||
nxt = _next_fire_after(sched, ref_local.astimezone(timezone.utc))
|
||||
assert nxt.astimezone(tz).date() == datetime(2025, 11, 2).date()
|
||||
# After firing on the 2nd, the next fire should be the 3rd, not a
|
||||
# second 2nd from the duplicated hour.
|
||||
# After firing on the 2nd, the next fire should be the 3rd, not a second 2nd from the duplicated hour.
|
||||
after = _next_fire_after(sched, nxt)
|
||||
assert after.astimezone(tz).date() == datetime(2025, 11, 3).date()
|
||||
|
||||
@@ -294,8 +290,7 @@ def test_weekly_every_n_weeks_phase_is_stable_across_recompute():
|
||||
a tick/kick in an off week slides the whole cadence by weeks."""
|
||||
from backend.apps.workflows.scheduler import compute_next_fire
|
||||
from backend.apps.workflows.models import ScheduleConfig
|
||||
# Created on Mon 2026-06-08. Every 2 weeks on Monday => fires 06-08,
|
||||
# 06-22, 07-06, 07-20 (UTC). 06-15 and 06-29 are 'off' weeks.
|
||||
# Created on Mon 2026-06-08. Every 2 weeks on Monday => fires 06-08, 06-22, 07-06, 07-20 (UTC). 06-15 and 06-29 are 'off' weeks.
|
||||
wf = _make_wf(
|
||||
created_at=datetime(2026, 6, 8, tzinfo=timezone.utc),
|
||||
schedule=ScheduleConfig(
|
||||
@@ -324,8 +319,7 @@ def test_ran_late_is_measured_from_start_not_finish():
|
||||
assert p_ran_late(slot + timedelta(minutes=4), slot) is False
|
||||
# Started well after the slot -> late.
|
||||
assert p_ran_late(slot + timedelta(minutes=6), slot) is True
|
||||
# Naive started_at (host-local, as datetime.now() produces) is normalized
|
||||
# to UTC rather than subtracted across the offset.
|
||||
# Naive started_at (host-local, as datetime.now() produces) is normalized to UTC rather than subtracted across the offset.
|
||||
naive_on_time = slot.astimezone().replace(tzinfo=None)
|
||||
assert p_ran_late(naive_on_time, slot) is False
|
||||
|
||||
@@ -467,8 +461,7 @@ def test_cost_cap_skips_with_clear_error(monkeypatch):
|
||||
async def fake_launch(*a, **k):
|
||||
raise AssertionError("agent_manager should not be reached when cost-capped")
|
||||
|
||||
# Patch agent_manager.launch_agent so we'd fail loudly if the cap
|
||||
# didn't short-circuit before launch.
|
||||
# Patch agent_manager.launch_agent so we'd fail loudly if the cap didn't short-circuit before launch.
|
||||
from backend.apps.agents import agent_manager
|
||||
monkeypatch.setattr(agent_manager.agent_manager, "launch_agent", fake_launch)
|
||||
|
||||
@@ -648,8 +641,7 @@ def test_legacy_timezone_coerced_on_load(monkeypatch):
|
||||
assert loaded is not None
|
||||
# In-memory should be the host zone, not "local".
|
||||
assert loaded.schedule.timezone == "America/Los_Angeles"
|
||||
# On-disk file should be unchanged (still "local") so we don't churn
|
||||
# mtime on every restart.
|
||||
# On-disk file should be unchanged (still "local") so we don't churn mtime on every restart.
|
||||
with open(os.path.join(storage.DATA_DIR, f"{wf_id}.json")) as f:
|
||||
on_disk = json.load(f)
|
||||
assert on_disk["schedule"]["timezone"] == "local"
|
||||
@@ -790,8 +782,7 @@ def test_executor_merge_does_not_clobber_concurrent_patch():
|
||||
storage._workflow_cache[wf.id].title = "t-patched"
|
||||
storage._workflow_cache[wf.id].description = "patched while running"
|
||||
storage.save_workflow(storage._workflow_cache[wf.id])
|
||||
# Executor uses the stale `wf` it captured before the patch. With
|
||||
# the merge helper, the patched fields must remain.
|
||||
# Executor uses the stale `wf` it captured before the patch. With the merge helper, the patched fields must remain.
|
||||
executor._persist_run_fields(wf, {
|
||||
"last_run_at": datetime.now(),
|
||||
"last_run_status": "success",
|
||||
|
||||
Reference in New Issue
Block a user