Files
openswarm/backend/tests/test_v2_invariants.py
T

3151 lines
142 KiB
Python

"""Invariant tests for the eric/v2 branch behaviors.
Each test simulates a real production scenario as closely as possible
without spinning up the bundled CLI. We mock at the boundary
(`load_all_tools`, the streaming SDK, the aux LLM client) so the
production code path runs end-to-end against in-memory fixtures.
Covers:
- MCP activation gate (the ToolSearch-only invariant) at the dispatch layer
- needs_fresh_session soft-restart on MCP activation mid-session
- Pydantic Message + AgentSession backward compat
- resolve_aux_model Gemini route correctness
- 9Router-streamed 401 detection
- MCP_SERVER_BRAND coverage vs the connected-server registry
- Auth-error / long-context / transient-capacity classifiers
Each group runs many randomized iterations to catch ordering, edge
case and concurrency regressions.
"""
from __future__ import annotations
import asyncio
import json
import os
import random
import string
import tempfile
from typing import Any
from unittest.mock import patch, AsyncMock, MagicMock
import pytest
P_TMPROOT = tempfile.mkdtemp(prefix="openswarm-v2-invariants-")
os.environ.setdefault("OPENSWARM_DATA_DIR", P_TMPROOT)
# --------------------------------------------------------------------------- Fixture: build a fake ToolDefinition without touching disk. ---------------------------------------------------------------------------
def p_fake_tool(
name: str,
*,
enabled: bool = True,
auth_status: str = "connected",
has_mcp: bool = True,
permissions: dict | None = None,
):
from backend.apps.tools_lib.models import ToolDefinition
return ToolDefinition(
name=name,
description=f"{name} integration",
mcp_config={"type": "stdio", "command": "echo", "args": ["x"]} if has_mcp else {},
auth_status=auth_status,
tool_permissions=permissions or {},
enabled=enabled,
)
# =========================================================================== 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
async def test_gate_blocks_when_active_mcps_empty():
"""Connected MCPs + active_mcps=[] → SDK gets empty mcp_servers dict."""
from backend.apps.agents.agent_manager import AgentManager
fake_tools = [
p_fake_tool("Gmail"),
p_fake_tool("Slack"),
p_fake_tool("Notion"),
]
with patch("backend.apps.agents.manager.RunSupport.load_all_tools", return_value=fake_tools), \
patch("backend.apps.agents.manager.RunSupport.refresh_google_token", new=AsyncMock(return_value=True)):
mgr = AgentManager()
# allowed_tools includes mcp:Gmail, but active_mcps is empty
result = await mgr.build_mcp_servers(
allowed_tools=["mcp:Gmail", "mcp:Slack", "mcp:Notion"],
active_mcps=[],
)
assert result == {}, f"gate must block all MCPs when active_mcps=[]; got {list(result.keys())}"
@pytest.mark.asyncio
async def test_gate_allows_only_activated_servers():
"""active_mcps=['gmail'] → only gmail server in dispatch dict, others blocked."""
from backend.apps.agents.agent_manager import AgentManager
fake_tools = [
p_fake_tool("Gmail"),
p_fake_tool("Slack"),
p_fake_tool("Notion"),
]
with patch("backend.apps.agents.manager.RunSupport.load_all_tools", return_value=fake_tools), \
patch("backend.apps.agents.manager.RunSupport.refresh_google_token", new=AsyncMock(return_value=True)):
mgr = AgentManager()
result = await mgr.build_mcp_servers(
allowed_tools=["mcp:Gmail", "mcp:Slack", "mcp:Notion"],
active_mcps=["gmail"], # sanitized name of "Gmail"
)
keys = set(result.keys())
assert "gmail" in keys, f"activated server must be present; got {keys}"
assert "slack" not in keys, f"unactivated server leaked through gate: {keys}"
assert "notion" not in keys, f"unactivated server leaked through gate: {keys}"
@pytest.mark.asyncio
async def test_gate_unset_active_mcps_legacy_allows_all():
"""Pre-gate sessions use active_mcps=None → everything allowed (back-compat)."""
from backend.apps.agents.agent_manager import AgentManager
fake_tools = [p_fake_tool("Gmail"), p_fake_tool("Slack")]
with patch("backend.apps.agents.manager.RunSupport.load_all_tools", return_value=fake_tools), \
patch("backend.apps.agents.manager.RunSupport.refresh_google_token", new=AsyncMock(return_value=True)):
mgr = AgentManager()
result = await mgr.build_mcp_servers(
allowed_tools=["mcp:Gmail", "mcp:Slack"],
active_mcps=None, # legacy / unset
)
assert "gmail" in result
assert "slack" in result
@pytest.mark.asyncio
async def test_gate_disabled_tool_blocked_even_when_activated():
"""Tool with enabled=False stays blocked even if in active_mcps."""
from backend.apps.agents.agent_manager import AgentManager
fake_tools = [p_fake_tool("Gmail", enabled=False)]
with patch("backend.apps.agents.manager.RunSupport.load_all_tools", return_value=fake_tools):
mgr = AgentManager()
result = await mgr.build_mcp_servers(
allowed_tools=["mcp:Gmail"],
active_mcps=["gmail"],
)
assert "gmail" not in result, "disabled tool must not reach the SDK"
@pytest.mark.asyncio
async def test_gate_unauthed_tool_blocked():
"""Tool with auth_status='disconnected' stays blocked."""
from backend.apps.agents.agent_manager import AgentManager
fake_tools = [p_fake_tool("Gmail", auth_status="disconnected")]
with patch("backend.apps.agents.manager.RunSupport.load_all_tools", return_value=fake_tools):
mgr = AgentManager()
result = await mgr.build_mcp_servers(
allowed_tools=["mcp:Gmail"],
active_mcps=["gmail"],
)
assert "gmail" not in result, "unauthed tool must not reach the SDK"
@pytest.mark.asyncio
async def test_gate_allowed_tools_filter_intersects_active_mcps():
"""Activate gmail+slack but allowed_tools only has gmail → only gmail passes."""
from backend.apps.agents.agent_manager import AgentManager
fake_tools = [p_fake_tool("Gmail"), p_fake_tool("Slack")]
with patch("backend.apps.agents.manager.RunSupport.load_all_tools", return_value=fake_tools), \
patch("backend.apps.agents.manager.RunSupport.refresh_google_token", new=AsyncMock(return_value=True)):
mgr = AgentManager()
result = await mgr.build_mcp_servers(
allowed_tools=["mcp:Gmail"], # mode-restricted
active_mcps=["gmail", "slack"], # both activated
)
assert "gmail" in result
assert "slack" not in result, "mode allowed_tools restriction must intersect with activation"
@pytest.mark.asyncio
async def test_gate_stress_random_activations():
"""Randomized: activated set ⊆ allowed set ⊆ connected set, gate must always intersect correctly."""
from backend.apps.agents.agent_manager import AgentManager
server_pool = ["gmail", "slack", "notion", "discord", "github", "linear", "airtable", "hubspot"]
raw_names = ["Gmail", "Slack", "Notion", "Discord", "GitHub", "Linear", "Airtable", "HubSpot"]
for _ in range(40):
connected_count = random.randint(2, 8)
connected_idx = random.sample(range(len(server_pool)), connected_count)
fake_tools = [p_fake_tool(raw_names[i]) for i in connected_idx]
connected_sanitized = [server_pool[i] for i in connected_idx]
# active set is a random subset of connected
active_n = random.randint(0, len(connected_sanitized))
active = random.sample(connected_sanitized, active_n)
# allowed_tools mirrors raw names of connected
allowed = [f"mcp:{raw_names[i]}" for i in connected_idx]
with patch("backend.apps.agents.manager.RunSupport.load_all_tools", return_value=fake_tools), \
patch("backend.apps.agents.manager.RunSupport.refresh_google_token", new=AsyncMock(return_value=True)), \
patch("backend.apps.agents.manager.RunSupport.refresh_airtable_token", new=AsyncMock(return_value=True)), \
patch("backend.apps.agents.manager.RunSupport.refresh_hubspot_token", new=AsyncMock(return_value=True)):
mgr = AgentManager()
result = await mgr.build_mcp_servers(
allowed_tools=allowed,
active_mcps=active,
)
keys = set(result.keys())
# MUST: keys ⊆ active ∩ connected
allowed_set = set(active) & set(connected_sanitized)
assert keys.issubset(allowed_set), (
f"GATE BREACH: {keys - allowed_set} leaked through "
f"(active={active}, connected={connected_sanitized})"
)
# =========================================================================== 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():
from backend.apps.agents.manager.prompt.prompt_context import (
toolsearch_loop_redirect,
TOOLSEARCH_LOOP_THRESHOLD,
)
for n in range(1, TOOLSEARCH_LOOP_THRESHOLD):
assert toolsearch_loop_redirect(n, ["gmail"]) is None, f"must not redirect at n={n}"
def test_toolsearch_redirect_fires_at_threshold_and_names_gated_servers():
from backend.apps.agents.manager.prompt.prompt_context import (
toolsearch_loop_redirect,
TOOLSEARCH_LOOP_THRESHOLD,
)
reason = toolsearch_loop_redirect(TOOLSEARCH_LOOP_THRESHOLD, ["google-workspace", "slack"])
assert reason is not None
assert "MCPActivate" in reason
assert "google-workspace" in reason and "slack" in reason
assert "Stop calling ToolSearch" in reason
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).
from backend.apps.agents.manager.prompt.prompt_context import (
toolsearch_loop_redirect,
TOOLSEARCH_LOOP_THRESHOLD,
)
reason = toolsearch_loop_redirect(TOOLSEARCH_LOOP_THRESHOLD, [])
assert reason is not None
assert "MCPActivate" not in reason # nothing to point at
assert "Stop calling ToolSearch" in reason
@pytest.mark.asyncio
async def test_gated_server_names_surface_only_inactive_servers():
"""The steer list must mirror the gate: connected-but-not-active servers
only, never one that's already activated (callable) or denied."""
from backend.apps.agents.manager.prompt.tool_catalog import gated_mcp_server_names
fake_tools = [p_fake_tool("Gmail"), p_fake_tool("Slack"), p_fake_tool("Notion")]
with patch("backend.apps.agents.manager.prompt.tool_catalog.load_all_tools", return_value=fake_tools):
names = gated_mcp_server_names(
allowed_tools=["mcp:Gmail", "mcp:Slack", "mcp:Notion"],
active_mcps=["gmail"], # already activated -> not "gated"
)
assert "gmail" not in names, "activated server must not appear as gated"
assert "slack" in names and "notion" in names
@pytest.mark.asyncio
async def test_gated_server_names_empty_when_all_active():
from backend.apps.agents.manager.prompt.tool_catalog import gated_mcp_server_names
fake_tools = [p_fake_tool("Gmail")]
with patch("backend.apps.agents.manager.prompt.tool_catalog.load_all_tools", return_value=fake_tools):
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.
def test_needs_fresh_session_field_default_false():
"""Brand-new sessions must default needs_fresh_session=False."""
from backend.apps.agents.core.models import AgentSession
s = AgentSession(id="x", name="t", model="sonnet", mode="agent")
assert s.needs_fresh_session is False
def test_needs_fresh_session_serializes_round_trip():
"""Pydantic round-trip must preserve the flag for session.json persistence."""
from backend.apps.agents.core.models import AgentSession
s = AgentSession(id="x", name="t", model="sonnet", mode="agent")
s.needs_fresh_session = True
s.sdk_session_id = "claude-session-abc-123"
dumped = s.model_dump(mode="json")
assert dumped["needs_fresh_session"] is True
assert dumped["sdk_session_id"] == "claude-session-abc-123"
rehydrated = AgentSession.model_validate(dumped)
assert rehydrated.needs_fresh_session is True
def test_legacy_session_json_loads_without_field():
"""Old session JSONs predate the field, Pydantic must fill in default."""
from backend.apps.agents.core.models import AgentSession
legacy = {
"id": "old", "name": "legacy", "model": "sonnet", "mode": "agent",
"status": "completed", "messages": [],
}
s = AgentSession.model_validate(legacy)
assert s.needs_fresh_session is False
# extras silently absorbed → can't be a regression hazard
legacy_with_ghost = {**legacy, "answer_tokens": 999, "thought_signature": "abc=="}
s2 = AgentSession.model_validate(legacy_with_ghost)
assert s2.id == "old"
def test_mcp_activate_sets_fresh_session_when_history_exists():
"""The gate logic at main.py: if sdk_session_id exists, set needs_fresh_session=True."""
from backend.apps.agents.core.models import AgentSession
# Mid-session: sdk already locked in
s = AgentSession(id="mid", name="t", model="sonnet", mode="agent")
s.sdk_session_id = "claude-session-existing"
# Simulate the gate handler logic
if s.sdk_session_id:
s.needs_fresh_session = True
assert s.needs_fresh_session is True
def test_mcp_activate_skips_fresh_session_on_first_turn():
"""First-turn activation: no sdk_session_id yet, so needs_fresh_session stays False."""
from backend.apps.agents.core.models import AgentSession
s = AgentSession(id="fresh", name="t", model="sonnet", mode="agent")
# No sdk_session_id yet
if s.sdk_session_id:
s.needs_fresh_session = True
assert s.needs_fresh_session is False
def test_active_mcps_append_idempotent():
"""Activating the same server twice doesn't dupe."""
from backend.apps.agents.core.models import AgentSession
s = AgentSession(id="x", name="t", model="sonnet", mode="agent")
s.active_mcps.append("gmail")
if "gmail" not in s.active_mcps:
s.active_mcps.append("gmail")
assert s.active_mcps.count("gmail") == 1
# =========================================================================== Group C, Pydantic Message backward compat (no ghost fields, legacy loads) ===========================================================================
def test_message_no_ghost_fields():
"""answer_tokens + thought_signature must NOT be Message attributes anymore."""
from backend.apps.agents.core.models import Message
m = Message(role="thinking", content="x")
dumped = m.model_dump(mode="json")
assert "answer_tokens" not in dumped
assert "thought_signature" not in dumped
def test_message_legacy_payload_with_ghost_fields_still_loads():
"""Old session JSONs may carry the deleted fields, Pydantic must ignore them."""
from backend.apps.agents.core.models import Message
legacy = {
"id": "m1",
"role": "thinking",
"content": "old",
"answer_tokens": 42,
"thought_signature": "deadbeef==",
"tool_count": 3,
"input_tokens": 1234,
}
m = Message.model_validate(legacy)
# Fields that survived are preserved
assert m.tool_count == 3
assert m.input_tokens == 1234
# Ghost fields don't blow up + don't leak into re-dump
redumped = m.model_dump(mode="json")
assert "answer_tokens" not in redumped
assert "thought_signature" not in redumped
def test_message_kept_fields():
"""Verify the live fields remain on the model."""
from backend.apps.agents.core.models import Message
m = Message(
role="thinking",
content="x",
client_message_id="opt-123",
elapsed_ms=1500,
tokens=42,
tool_count=2,
input_tokens=5000,
)
d = m.model_dump(mode="json")
for f in ("client_message_id", "elapsed_ms", "tokens", "tool_count", "input_tokens"):
assert f in d, f"live field {f} disappeared"
def test_message_round_trip_50_iterations():
"""Stress: 50 randomized message round-trips."""
from backend.apps.agents.core.models import Message
for _ in range(50):
roles = ["user", "assistant", "tool_call", "tool_result", "system", "thinking"]
m = Message(
role=random.choice(roles),
content=("x" * random.randint(0, 5000)),
elapsed_ms=random.randint(0, 60000),
tokens=random.randint(0, 100000),
tool_count=random.randint(0, 50),
input_tokens=random.randint(0, 200000),
)
d = m.model_dump(mode="json")
m2 = Message.model_validate(d)
assert m2.role == m.role
assert m2.content == m.content
assert m2.elapsed_ms == m.elapsed_ms
# =========================================================================== Group D, resolve_aux_model Gemini route (the gemini-3.1-flash-lite-preview fix) ===========================================================================
@pytest.mark.asyncio
async def test_resolve_aux_model_gemini_subscription_returns_preview_suffix():
"""The bug: gc/gemini-3.1-flash-lite (no -preview) 404s on 9Router."""
from backend.apps.agents.providers import registry
from backend.apps.settings.models import AppSettings
settings = AppSettings()
with patch("backend.apps.nine_router.is_running", return_value=True), \
patch("backend.apps.nine_router.get_providers",
new=AsyncMock(return_value=[{"provider": "gemini-cli", "isActive": True}])):
model_id, base = await registry.resolve_aux_model(settings, primary_api="gemini-cli")
assert model_id == "gc/gemini-3.1-flash-lite-preview", \
f"Gemini aux must use the -preview suffix; got {model_id}"
@pytest.mark.asyncio
async def test_resolve_aux_model_gemini_api_key_returns_preview_suffix():
"""Direct API key path also needs -preview."""
from backend.apps.agents.providers import registry
from backend.apps.settings.models import AppSettings
settings = AppSettings()
settings.google_api_key = "fake-key-123"
with patch("backend.apps.nine_router.is_running", return_value=False):
model_id, base = await registry.resolve_aux_model(settings, primary_api="gemini-cli")
assert model_id == "gemini-3.1-flash-lite-preview", \
f"Gemini API-key aux must use the -preview suffix; got {model_id}"
@pytest.mark.asyncio
async def test_resolve_aux_model_anthropic_pro_returns_proxy():
"""OpenSwarm Pro mode → bare haiku via proxy."""
from backend.apps.agents.providers import registry
from backend.apps.settings.models import AppSettings
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.
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)
assert "haiku" in model_id
assert base == "https://api.openswarm.test"
@pytest.mark.asyncio
async def test_resolve_aux_model_codex_subscription():
"""Codex primary with codex connected → cx/gpt-5.4-mini."""
from backend.apps.agents.providers import registry
from backend.apps.settings.models import AppSettings
settings = AppSettings()
with patch("backend.apps.nine_router.is_running", return_value=True), \
patch("backend.apps.nine_router.get_providers",
new=AsyncMock(return_value=[{"provider": "codex", "isActive": True}])):
model_id, base = await registry.resolve_aux_model(settings, primary_api="codex")
assert model_id == "cx/gpt-5.4-mini", f"got {model_id}"
@pytest.mark.asyncio
async def test_resolve_aux_model_raises_when_nothing_available():
"""No 9Router, no API keys, no Pro → ValueError."""
from backend.apps.agents.providers import registry
from backend.apps.settings.models import AppSettings
settings = AppSettings()
with patch("backend.apps.nine_router.is_running", return_value=False):
with pytest.raises(ValueError, match="No AI provider"):
await registry.resolve_aux_model(settings)
@pytest.mark.asyncio
async def test_resolve_aux_model_openrouter_only_fallback():
"""OR-only user (no Pro, no Anthropic key, no claude/codex/gemini sub)
falls back to OR-resold Haiku instead of raising. Covers the gap that
used to leave OR-only users at 'Untitled session'."""
from backend.apps.agents.providers import registry
from backend.apps.settings.models import AppSettings
settings = AppSettings() # no anthropic_api_key, no Pro
with patch("backend.apps.nine_router.is_running", return_value=True), \
patch("backend.apps.nine_router.get_providers",
new=AsyncMock(return_value=[{"provider": "openrouter", "isActive": True}])):
model_id, base = await registry.resolve_aux_model(settings, preferred_tier="haiku")
assert model_id == "openrouter/anthropic/claude-haiku-4.5", f"got {model_id}"
assert base == "http://localhost:20128", f"got {base}"
@pytest.mark.asyncio
async def test_resolve_aux_model_openrouter_primary_prefers_or():
"""OR-primary chat keeps aux on OR (single-bill predictability) even
when other free lanes happen to be connected. Stay-on-family rule."""
from backend.apps.agents.providers import registry
from backend.apps.settings.models import AppSettings
settings = AppSettings()
with patch("backend.apps.nine_router.is_running", return_value=True), \
patch("backend.apps.nine_router.get_providers",
new=AsyncMock(return_value=[
{"provider": "openrouter", "isActive": True},
{"provider": "claude", "isActive": True},
])):
model_id, base = await registry.resolve_aux_model(settings, primary_api="openrouter")
assert model_id == "openrouter/anthropic/claude-haiku-4.5", f"got {model_id}"
@pytest.mark.asyncio
async def test_resolve_aux_model_openrouter_priority_after_subs():
"""In the default cascade (no primary_api), Claude/Codex/Gemini subs
win over OR, OR is metered while subs are sub-covered free."""
from backend.apps.agents.providers import registry
from backend.apps.settings.models import AppSettings
settings = AppSettings()
# Both Codex and OR connected, Codex (free via sub) should win.
with patch("backend.apps.nine_router.is_running", return_value=True), \
patch("backend.apps.nine_router.get_providers",
new=AsyncMock(return_value=[
{"provider": "codex", "isActive": True},
{"provider": "openrouter", "isActive": True},
])):
model_id, _ = await registry.resolve_aux_model(settings)
assert model_id == "cx/gpt-5.4-mini", f"got {model_id}"
def test_resolve_sdk_openai_own_key_keeps_cp_openai_prefix():
"""OpenAI own-key dispatch points the SDK at 9Router, which routes by
prefix to our cp-openai passthrough node; handing it the bare `gpt-5.5`
matched no node and silently dropped every request before it reached
OpenAI (0 requests on the dashboard). The resolver must keep the
cp-openai/ prefix for the openai route while Anthropic/Gemini own-key
stay on the bare id (they go direct / via the local proxy)."""
from backend.apps.agents.providers.registry import resolve_model_id_for_sdk
from backend.apps.settings.models import AppSettings
s = AppSettings()
s.openai_api_key = "sk-test"
for v, expected in (
("gpt-5.5-api", "cp-openai/gpt-5.5"),
("gpt-5.4-api", "cp-openai/gpt-5.4"),
("gpt-5.4-mini-api", "cp-openai/gpt-5.4-mini"),
):
assert resolve_model_id_for_sdk(v, s) == expected, f"{v} -> {resolve_model_id_for_sdk(v, s)}"
# Non-OpenAI own-key lanes must NOT gain a 9Router prefix.
assert resolve_model_id_for_sdk("gemini-3.5-flash-api", s) == "gemini-3.5-flash"
assert resolve_model_id_for_sdk("opus-4-8-api", s) == "claude-opus-4-8"
def test_resolve_sdk_gemini_prefers_antigravity_over_api_key():
"""A connected Antigravity sub must win over the AI Studio key for the
models AG serves (flash) since AG bypasses the thoughtSignature validator;
pro variants aren't AG-serveable so they fall back to the key. Before this,
the key was checked first and silently shadowed a connected AG sub (user had
AG connected but 100% of Gemini traffic still went through the key)."""
from backend.apps.agents.providers import registry
from backend.apps.settings.models import AppSettings
s = AppSettings()
s.google_api_key = "ai-studio-key"
with patch.object(registry, "p_antigravity_connected", return_value=True):
# flash IS AG-serveable -> AG wins over the key
assert registry.resolve_model_id_for_sdk("gemini-3-flash", s) == "ag/gemini-3-flash"
with patch.object(registry, "p_antigravity_connected", return_value=False):
# AG not connected -> key
assert registry.resolve_model_id_for_sdk("gemini-3-flash", s) == "gemini/gemini-3-flash-preview"
# No key, no AG -> gc/ subscription lane untouched
s2 = AppSettings()
with patch.object(registry, "p_antigravity_connected", return_value=False):
assert registry.resolve_model_id_for_sdk("gemini-3-flash", s2) == "gc/gemini-3-flash-preview"
def test_error_classify_schema_translation_400_is_not_auth():
"""A 9Router tool-schema translation 400 can carry provider/connection
wording that trips the auth regex, so it used to surface a misleading
'reconnect your subscription' card for what is really a schema bug. The
translation guard must win: schema 400 -> not auth; a real auth failure
with no translation signature still reads as auth."""
from backend.apps.agents.core.error_classify import is_auth_error, is_translation_error
both = Exception("provider not connected: 400 INVALID_ARGUMENT at "
"tools[0].function_declarations[0].parameters")
assert is_translation_error(both)
assert not is_auth_error(both), "schema-400 must not be classified as auth"
# Pure auth failures (no translation signature) still classify as auth.
assert is_auth_error(Exception("provider not connected: gemini"))
assert is_auth_error(Exception("401 invalid authentication credentials"))
assert not is_translation_error(Exception("401 invalid authentication credentials"))
def test_error_classify_gemini_resource_exhausted_is_transient():
"""gemini-cli's free-tier 429 surfaces as RESOURCE_EXHAUSTED; it must count
as transient so the existing backoff/retry catches it instead of dying as a
hard first-message error. A 403 (hard auth/quota) must still NOT retry."""
from backend.apps.agents.core.error_classify import is_transient_capacity_error
assert is_transient_capacity_error(Exception("429 RESOURCE_EXHAUSTED: Quota exceeded"))
assert is_transient_capacity_error(Exception("RESOURCE_EXHAUSTED"))
assert not is_transient_capacity_error(Exception("403 permission denied"))
@pytest.mark.asyncio
async def test_mcp_gate_only_forwards_activated_servers():
"""Dispatch-layer security invariant (the non-bypassable enforcement of
'MCP tools only via MCPActivate'): for a GATED session (active_mcps is a
list), _build_mcp_servers forwards ONLY servers whose sanitized name is in
active_mcps; an empty list forwards ZERO; None is the legacy all-allowed
path. The model cannot reach an unactivated server no matter what it asks
for. Property-checked over random installed sets and random activation
subsets, plus the two boundary cases."""
import random
from types import SimpleNamespace
from backend.apps.agents.agent_manager import AgentManager
mgr = AgentManager()
names = ["gmail", "drive", "slack", "reddit", "notion", "airtable"]
def installed():
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.
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), \
patch("backend.apps.agents.manager.RunSupport.is_fully_denied", return_value=False), \
patch("backend.apps.agents.manager.RunSupport.derive_mcp_config", side_effect=lambda t: {"command": "x"}):
allowed = ["__ALL__"]
# Boundary 1: empty activation list -> zero servers, always.
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.
rng = random.Random(1234)
for _ in range(400):
active = rng.sample(names, rng.randint(0, len(names)))
# throw in a bogus name the gate must never invent a server for
if rng.random() < 0.3:
active = active + ["ghost-not-installed"]
forwarded = set((await mgr.build_mcp_servers(allowed, active_mcps=active)).keys())
assert forwarded <= set(active), f"leaked {forwarded - set(active)} for active={active}"
assert forwarded == (set(active) & set(names)), f"mismatch for active={active}"
def test_dashboard_get_strips_only_orphan_session_cards():
"""A layout card whose session vanished (gone from memory AND disk) makes the
frontend GET /sessions/{id} 404 on every load and flash a dead card. The
dashboard GET filters those orphan cards out of the response, but must keep
live (in-memory) cards, on-disk cards, and drafts. Non-destructive: only the
response is filtered, never the stored layout."""
from types import SimpleNamespace
from backend.apps.dashboards import dashboards as D
data = {"layout": {
"cards": {
"live": {"session_id": "live"}, # in memory
"ondisk": {"session_id": "ondisk"}, # closed but on disk
"draft-1": {"session_id": "draft-1"}, # unsent draft, no backend session yet
"ghost": {"session_id": "ghost"}, # gone from memory AND disk -> would 404
},
"expanded_session_ids": ["live", "ghost"],
}}
fake_mgr = SimpleNamespace(sessions={"live": object()})
on_disk = {"ondisk": {"id": "ondisk"}}
with patch("backend.apps.agents.agent_manager.agent_manager", fake_mgr), \
patch("backend.apps.agents.manager.session.session_store.load_session_data",
side_effect=lambda sid: on_disk.get(sid)):
D.strip_orphan_session_cards(data)
assert set(data["layout"]["cards"].keys()) == {"live", "ondisk", "draft-1"}, "only the ghost should be dropped"
assert data["layout"]["expanded_session_ids"] == ["live"], "ghost dropped from expanded too"
def test_banned_models_not_offered():
"""Claude Fable (banned) and Gemini 3.1 Pro (no working lane: AG can't serve
it, AI Studio key 429s pro-preview) were pulled from the picker. Guard so a
refactor can't silently re-list a model that can't run."""
from backend.apps.agents.providers.registry import BUILTIN_MODELS
all_values = {m["value"] for models in BUILTIN_MODELS.values() for m in models}
for dead in ("fable-5-cc", "fable-5-api", "gemini-3.1-pro", "gemini-3.1-pro-api"):
assert dead not in all_values, f"{dead} is back in the picker"
# No 'fable' or '3.1 pro' label survives in any provider group either.
all_labels = " | ".join(m["label"].lower() for models in BUILTIN_MODELS.values() for m in models)
assert "fable" not in all_labels
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.
def test_router_auth_pattern_codex():
"""The pattern detector at agent_manager.py:2841-2846."""
text = (
"Failed to authenticate. API Error: 401 {\"error\":{\"message\":"
"\"[codex/gpt-5.5] [401]: Provided authentication token is expired. "
"Please try signing in again. (reset after 1m 59s)\"}}"
)
lower = text.lower()
looks_auth = (
("failed to authenticate" in lower and "401" in lower)
or ("authentication token is expired" in lower)
or ("authentication token has expired" in lower)
or ("provided authentication token" in lower and ("401" in lower or "expired" in lower))
)
assert looks_auth, "codex 401 pattern must match"
assert "codex/" in lower, "codex provider tag should be detectable"
def test_router_auth_pattern_gemini():
text = "[gemini-cli/gemini-2.5-flash] [401]: Invalid API key provided (reset after 2m)"
lower = text.lower()
is_gemini = "gemini-cli/" in lower or "[gemini" in lower
has_401 = "401" in lower
assert is_gemini
assert has_401
def test_router_auth_pattern_does_not_falsely_match_normal_text():
"""Don't friendly-bubble normal assistant replies."""
benign_replies = [
"Here are your recent emails: ...",
"I found 3 results for your search.",
"Sorry, I don't have access to that file.",
"401 Unauthorized, wait this is a code example I'm explaining", # tricky
]
for text in benign_replies:
lower = text.lower()
looks_auth = (
("failed to authenticate" in lower and "401" in lower)
or "authentication token is expired" in lower
or "authentication token has expired" in lower
or ("provided authentication token" in lower and ("401" in lower or "expired" in lower))
)
assert not looks_auth, f"falsely matched benign text: {text!r}"
def test_is_auth_error_classifier():
"""The classifier at agent_manager.py:is_auth_error covers many shapes."""
from backend.apps.agents.core.error_classify import is_auth_error
# Real shapes that must be caught
matches = [
Exception("Error 401: invalid_api_key"),
Exception("Got 403 from upstream"),
Exception("invalid authentication credentials"),
Exception("missing bearer token"),
Exception("Unauthorized"),
Exception("No credentials for provider: claude"),
Exception("Provider not configured: gemini"),
]
for e in matches:
assert is_auth_error(e), f"should match: {e}"
# Non-auth errors must not match
non_matches = [
Exception("Connection timeout"),
Exception("Rate limit exceeded"),
Exception("Internal server error"),
Exception("File not found"),
]
for e in non_matches:
assert not is_auth_error(e), f"should NOT match: {e}"
def test_is_auth_error_with_stderr_tail():
"""The classifier also reads stderr buffer text."""
from backend.apps.agents.core.error_classify import is_auth_error
e = Exception("Command failed with exit code 1")
stderr = "...\n[codex/gpt-5.5] [401]: Provided authentication token is expired"
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").
def test_mcp_brand_covers_curated_servers():
"""Every curated server slug must already be in canonical sanitized form."""
curated = {
"google-workspace", "microsoft-365", "slack", "discord",
"notion", "airtable", "hubspot", "reddit", "youtube",
}
from backend.apps.tools_lib.tools_lib import sanitize_server_name
for slug in curated:
assert sanitize_server_name(slug) == slug, (
f"curated slug {slug!r} is not in sanitized form"
)
def test_curated_server_aliases_in_main():
"""Read main.py's source to confirm the alias map covers curated servers."""
import inspect
import backend.main as main_module
src = inspect.getsource(main_module)
assert "_SERVER_SEARCH_ALIASES" in src, "alias map removed?"
for slug in ("google-workspace", "microsoft-365", "slack", "discord", "notion"):
assert f'"{slug}"' in src, f"{slug} alias entry missing in main.py"
def test_sanitize_server_name_idempotent():
"""sanitize_server_name must be idempotent (sanitize twice = sanitize once)."""
from backend.apps.tools_lib.tools_lib import sanitize_server_name
test_inputs = [
"Google Workspace", "Microsoft 365", "Slack", "Discord",
"Notion", "Airtable", "HubSpot", "Reddit", "YouTube",
"GitHub", "GitLab", "Jira",
]
for raw in test_inputs:
once = sanitize_server_name(raw)
twice = sanitize_server_name(once)
assert once == twice, f"{raw}: sanitize not idempotent ({once} != {twice})"
def test_sanitize_server_name_lowercase():
from backend.apps.tools_lib.tools_lib import sanitize_server_name
assert sanitize_server_name("Gmail") == "gmail"
assert sanitize_server_name("UPPERCASE") == "uppercase"
def test_sanitize_server_name_strips_special_chars():
from backend.apps.tools_lib.tools_lib import sanitize_server_name
assert sanitize_server_name("Foo Bar!") == "foo-bar"
assert sanitize_server_name("@x/y") == "x-y"
assert sanitize_server_name("a__b") == "a-b"
# =========================================================================== 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.
from backend.apps.tools_lib.tools_lib import sanitize_server_name
valid = {"gmail", "slack", "google-workspace"}
requested = "Gmail" # raw, needs sanitize
sanitized = sanitize_server_name(requested)
if sanitized in valid:
status = "would_activate"
else:
status = "unknown_server"
assert status in ("would_activate", "unknown_server")
def test_active_mcps_persistence_on_session():
"""active_mcps survives session.model_dump() round-trip, critical for resume."""
from backend.apps.agents.core.models import AgentSession
s = AgentSession(id="x", name="t", model="sonnet", mode="agent")
s.active_mcps = ["gmail", "slack"]
dumped = json.dumps(s.model_dump(mode="json"))
rehydrated = AgentSession.model_validate(json.loads(dumped))
assert rehydrated.active_mcps == ["gmail", "slack"]
# =========================================================================== Group H, long-context error classifier ===========================================================================
def test_long_context_pattern_caught():
"""The 'extra usage required' 429 must NOT silently retry."""
from backend.apps.agents.core.error_classify import NON_TRANSIENT_PATTERNS
cases = [
"Extra usage is required for long context requests",
"extra usage is required for long context",
"EXTRA USAGE IS REQUIRED FOR LONG CONTEXT",
]
for case in cases:
assert NON_TRANSIENT_PATTERNS.search(case), f"missed: {case!r}"
def test_transient_capacity_patterns():
"""Real transient errors that SHOULD retry."""
from backend.apps.agents.core.error_classify import TRANSIENT_CAPACITY_PATTERNS, NON_TRANSIENT_PATTERNS
transients = [
"Error 429: rate_limit_error",
"503 Service Unavailable",
"Service is at capacity",
"Try again shortly",
"Internal server error",
"ECONNRESET on upstream",
"fetch failed",
"overloaded",
]
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:
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)
def test_long_context_does_not_match_normal_429():
"""Generic 429 is transient, only the long-context variant is non-transient."""
from backend.apps.agents.core.error_classify import NON_TRANSIENT_PATTERNS
assert not NON_TRANSIENT_PATTERNS.search("Error 429: rate_limit_error")
# =========================================================================== Group I, Mode reconciliation (regression guard) ===========================================================================
def test_chat_mode_not_in_builtins():
"""chat mode was deleted; only ask/agent/plan/view-builder/skill-builder remain."""
from backend.apps.modes.models import BUILTIN_MODES
ids = {m.id for m in BUILTIN_MODES}
assert "chat" not in ids
for required in ("agent", "ask", "plan", "view-builder", "skill-builder"):
assert required in ids, f"{required} mode missing"
def test_active_mcps_default_factory_creates_new_list():
"""Defaults must use Field(default_factory=list), not [], to avoid shared mutation."""
from backend.apps.agents.core.models import AgentSession
s1 = AgentSession(id="a", name="a", model="sonnet", mode="agent")
s2 = AgentSession(id="b", name="b", model="sonnet", mode="agent")
s1.active_mcps.append("gmail")
assert s2.active_mcps == [], "active_mcps must not share state across sessions"
# =========================================================================== Group J, Concurrent gate stress (real production risk: simultaneous turns) ===========================================================================
@pytest.mark.asyncio
async def test_concurrent_gate_calls_isolated():
"""Two concurrent _build_mcp_servers calls with different active_mcps must not cross-contaminate."""
from backend.apps.agents.agent_manager import AgentManager
fake_tools = [p_fake_tool("Gmail"), p_fake_tool("Slack"), p_fake_tool("Notion")]
with patch("backend.apps.agents.manager.RunSupport.load_all_tools", return_value=fake_tools), \
patch("backend.apps.agents.manager.RunSupport.refresh_google_token", new=AsyncMock(return_value=True)):
mgr = AgentManager()
results = await asyncio.gather(
mgr.build_mcp_servers(allowed_tools=["mcp:Gmail", "mcp:Slack", "mcp:Notion"], active_mcps=["gmail"]),
mgr.build_mcp_servers(allowed_tools=["mcp:Gmail", "mcp:Slack", "mcp:Notion"], active_mcps=["slack"]),
mgr.build_mcp_servers(allowed_tools=["mcp:Gmail", "mcp:Slack", "mcp:Notion"], active_mcps=["notion"]),
mgr.build_mcp_servers(allowed_tools=["mcp:Gmail", "mcp:Slack", "mcp:Notion"], active_mcps=[]),
)
gmail_only, slack_only, notion_only, empty = results
assert set(gmail_only.keys()) == {"gmail"}
assert set(slack_only.keys()) == {"slack"}
assert set(notion_only.keys()) == {"notion"}
assert set(empty.keys()) == set()
# =========================================================================== Group K, pending_continuation auto-restart ===========================================================================
def test_pending_continuation_default_false():
from backend.apps.agents.core.models import AgentSession
s = AgentSession(id="x", name="t", model="sonnet", mode="agent")
assert s.pending_continuation is False
assert s.pending_continuation_prompt is None
def test_pending_continuation_serializes():
from backend.apps.agents.core.models import AgentSession
s = AgentSession(id="x", name="t", model="sonnet", mode="agent")
s.pending_continuation = True
s.pending_continuation_prompt = "[mcp:auto-continue] retry now"
d = s.model_dump(mode="json")
s2 = AgentSession.model_validate(d)
assert s2.pending_continuation is True
assert s2.pending_continuation_prompt.startswith("[mcp:auto-continue]")
def test_compact_threshold_default():
"""compact_threshold_pct default of 0.65, drift here breaks Phase 2 compaction."""
from backend.apps.agents.core.models import AgentSession
s = AgentSession(id="x", name="t", model="sonnet", mode="agent")
assert s.compact_threshold_pct == 0.65
assert s.context_soft_cap_pct == 0.90
assert s.context_window == 200_000
def test_post_compact_estimate_excludes_compacted_messages():
from backend.apps.agents.core.models import AgentSession, Message
from backend.apps.agents.manager.session.history_compaction import (
estimate_post_compact_input,
)
messages = [
Message(id=f"m{i}", role="user", content=("old" * 1000 if i < 6 else "keep"))
for i in range(8)
]
s = AgentSession(id="x", name="t", model="sonnet", mode="agent")
s.messages = messages
s.compacted_through_msg_id = "m5"
s.framework_overhead_tokens = 100
assert estimate_post_compact_input(s) == 100 + 200 + (len("keepkeep") // 4)
@pytest.mark.asyncio
async def test_context_update_emitter_refreshes_session_tokens(monkeypatch):
import backend.apps.agents.agent_manager as agent_manager_module
from backend.apps.agents.agent_manager import AgentManager
from backend.apps.agents.core.models import AgentSession
sent = []
async def fake_send_to_session(session_id, event, payload):
sent.append((session_id, event, payload))
monkeypatch.setattr(
agent_manager_module.ws_manager,
"send_to_session",
fake_send_to_session,
)
s = AgentSession(id="x", name="t", model="sonnet", mode="agent")
s.context_window = 1_000
s.tokens = {"input": 900, "output": 7}
s.framework_overhead_tokens = 42
s.active_mcps = ["github"]
await AgentManager().emit_context_update("x", s, input_tokens=250)
assert s.tokens == {"input": 250, "output": 7}
assert sent == [(
"x",
"agent:context_update",
{
"session_id": "x",
"input_tokens": 250,
"output_tokens": 7,
"cache_read_tokens": 0,
"cache_read_pct": 0.0,
"ctx_used_pct": 0.25,
"context_window": 1_000,
"framework_overhead_tokens": 42,
"active_mcps": ["github"],
},
)]
# =========================================================================== 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():
"""Mirror of the JS _humanizeName: first word capitalized, rest lower."""
def sentence_case(name: str) -> str:
spaced = name.replace("_", " ").replace("-", " ").lower()
return spaced[0].upper() + spaced[1:] if spaced else ""
cases = [
("get_message_details", "Get message details"),
("send_gmail_message", "Send gmail message"),
("Create_PR", "Create pr"),
("foo_bar_baz", "Foo bar baz"),
]
for raw, expected in cases:
assert sentence_case(raw) == expected
# =========================================================================== Group M, Bash command verb extraction (frontend logic, mirrored) ===========================================================================
def test_bash_verb_extraction_strips_env_prefix():
"""`FOO=bar git commit -m x` should treat `git commit` as the verb."""
import re
cmd = "FOO=bar BAZ=qux git commit -m hi"
stripped = re.sub(r"^(?:[A-Z_][A-Z0-9_]*=\S+\s+)+", "", cmd)
assert stripped.startswith("git commit")
def test_bash_verb_extraction_strips_sudo():
"""`sudo rm foo` → verb is `rm`, target is `foo`."""
cmd = "sudo rm /tmp/foo"
tokens = cmd.split()
if tokens[0] in ("sudo", "time", "nice", "env"):
tokens = tokens[1:]
assert tokens[0] == "rm"
assert tokens[1] == "/tmp/foo"
def test_bash_command_detail_path_basename():
"""Path-shaped args get basename'd in the row."""
paths = [
("/Users/eric/foo.ts", "foo.ts"),
("a/b/c/long.tsx", "long.tsx"),
("foo.txt", "foo.txt"),
("/", ""),
]
def basename(p: str) -> str:
cleaned = p.rstrip("/\\")
if not cleaned:
return ""
parts = cleaned.replace("\\", "/").split("/")
return parts[-1] if parts[-1] else cleaned
for raw, expected in paths:
assert basename(raw) == expected
# =========================================================================== Group N, Pydantic AppSettings invariants ===========================================================================
def test_app_settings_defaults():
from backend.apps.settings.models import AppSettings
s = AppSettings()
assert s.connection_mode == "own_key"
assert s.default_thinking_level == "auto"
assert s.dismissed_mcp_suggestions == {}
assert s.analytics_opt_in is True
def test_custom_provider_round_trip():
from backend.apps.settings.models import AppSettings, CustomProvider
s = AppSettings()
s.custom_providers = [
CustomProvider(name="MyCorp", base_url="https://api.mycorp.test", api_key="sk-test"),
]
d = s.model_dump(mode="json")
s2 = AppSettings.model_validate(d)
assert len(s2.custom_providers) == 1
assert s2.custom_providers[0].name == "MyCorp"
# =========================================================================== Group O, Tool gate stress with denied permissions ===========================================================================
@pytest.mark.asyncio
async def test_gate_partially_denied_tool_blocked():
"""If permissions has _entirely_denied=True it's blocked."""
from backend.apps.agents.manager.prompt.tool_catalog import is_fully_denied
fake = p_fake_tool("Gmail", permissions={
"_tool_descriptions": {"send_email": "Send email"},
"send_email": "deny",
})
# Build a minimal class that has the perms_dict shape is_fully_denied expects
assert is_fully_denied(fake) in (True, False)
@pytest.mark.asyncio
async def test_gate_handles_missing_refresh_token_gracefully():
"""Tool with auth_status='configured' and no oauth shouldn't crash the gate."""
from backend.apps.agents.agent_manager import AgentManager
fake = p_fake_tool("MyApiTool", auth_status="configured")
fake.auth_type = None # no oauth
with patch("backend.apps.agents.manager.RunSupport.load_all_tools", return_value=[fake]):
mgr = AgentManager()
result = await mgr.build_mcp_servers(
allowed_tools=["mcp:MyApiTool"],
active_mcps=["myapitool"],
)
# It should be present (configured + activated + not denied)
assert "myapitool" in result
# =========================================================================== Group P, resolve_aux_model failover logic ===========================================================================
@pytest.mark.asyncio
async def test_aux_failover_anthropic_to_codex():
"""primary_api=codex but codex unreachable → falls through to anthropic-first cascade."""
from backend.apps.agents.providers import registry
from backend.apps.settings.models import AppSettings
settings = AppSettings()
settings.connection_mode = "openswarm-pro" # provides anthropic fallback
settings.openswarm_proxy_url = "https://api.openswarm.test"
settings.openswarm_bearer_token = "test-pro-token" # what a real Pro user carries
with patch("backend.apps.nine_router.is_running", return_value=True), \
patch("backend.apps.nine_router.get_providers",
new=AsyncMock(return_value=[])): # nothing connected
# primary_api=codex but codex not connected → cascade to Pro/anthropic
model_id, base = await registry.resolve_aux_model(settings, primary_api="codex")
assert "haiku" in model_id # fallthrough hit Anthropic Pro path
assert base == "https://api.openswarm.test"
@pytest.mark.asyncio
async def test_aux_returns_haiku_by_default():
"""preferred_tier='haiku' → bare haiku model id."""
from backend.apps.agents.providers import registry
from backend.apps.settings.models import AppSettings
settings = AppSettings()
settings.anthropic_api_key = "sk-test-fake"
with patch("backend.apps.nine_router.is_running", return_value=False):
model_id, base = await registry.resolve_aux_model(settings, preferred_tier="haiku")
assert "haiku" in model_id
assert base is None
@pytest.mark.asyncio
async def test_aux_returns_sonnet_when_preferred_tier_set():
from backend.apps.agents.providers import registry
from backend.apps.settings.models import AppSettings
settings = AppSettings()
settings.anthropic_api_key = "sk-test-fake"
with patch("backend.apps.nine_router.is_running", return_value=False):
model_id, base = await registry.resolve_aux_model(settings, preferred_tier="sonnet")
assert "sonnet" in model_id
# =========================================================================== Group Q, get_api_type / model id resolution ===========================================================================
def test_get_api_type_openai():
from backend.apps.agents.providers.registry import get_api_type
# gpt-5.4 maps to codex (the OpenAI-via-Codex-subscription api family)
api = get_api_type("gpt-5.4")
assert api in ("openai", "codex"), f"unexpected: {api}"
def test_find_builtin_model_returns_none_for_unknown():
from backend.apps.agents.providers.registry import find_builtin_model
assert find_builtin_model("not-a-real-model-xyz") is None
def test_find_builtin_model_returns_dict_for_known():
from backend.apps.agents.providers.registry import find_builtin_model
sonnet = find_builtin_model("sonnet")
assert sonnet is not None
assert sonnet.get("api") == "anthropic"
# =========================================================================== Group R, context window ===========================================================================
def test_get_context_window_known_model():
from backend.apps.agents.providers.registry import get_context_window
cw = get_context_window("Anthropic", "sonnet")
assert cw >= 200_000
def test_get_context_window_unknown_returns_default():
from backend.apps.agents.providers.registry import get_context_window
cw = get_context_window("Unknown", "fake-model")
assert cw == 128_000
def test_apply_context_window_overwrites_default_for_opus_4_7():
"""Regression for issue #39: AgentSession used to stick at the 200k
dataclass default for every model. apply_context_window must pull
the real 1M value from the registry for opus-4-7 / sonnet so the
soft-cap trim and the % meter both reflect the real model cap."""
from backend.apps.agents.core.models import AgentSession
from backend.apps.agents.manager.session.apply_context_window import apply_context_window
s = AgentSession(id="x", name="t", model="opus-4-7", mode="agent")
assert s.context_window == 200_000
apply_context_window(s)
assert s.context_window == 1_000_000
s2 = AgentSession(id="y", name="t", model="sonnet", mode="agent")
apply_context_window(s2)
assert s2.context_window == 1_000_000
s3 = AgentSession(id="z", name="t", model="haiku", mode="agent")
apply_context_window(s3)
assert s3.context_window == 200_000
def test_apply_context_window_silent_on_unknown_model():
"""Bad lookup must NEVER raise; sessions with unknown/custom models
that aren't in the registry fall back to the 128k registry default
without breaking session creation."""
from backend.apps.agents.core.models import AgentSession
from backend.apps.agents.manager.session.apply_context_window import apply_context_window
s = AgentSession(id="x", name="t", model="nonexistent-model-xyz", mode="agent")
apply_context_window(s)
assert s.context_window > 0
def test_estimate_pdf_tokens_floors_empty_pdf_at_byte_heuristic():
"""A truly empty / minimal PDF still returns a non-zero estimate so
the dry-run guard doesn't allow many tiny PDFs through silently."""
from backend.apps.settings.settings import estimate_pdf_tokens
assert estimate_pdf_tokens(b"") >= 1_000
assert estimate_pdf_tokens(b"%PDF-1.4\n") >= 1_000
def test_estimate_pdf_tokens_takes_max_of_pages_and_bytes():
"""An image-heavy PDF with low page count should still report high
tokens via the byte-size signal; we never under-report."""
from backend.apps.settings.settings import estimate_pdf_tokens
# 8MB PDF with 1 page (image-heavy), byte heuristic should dominate.
fake = b"%PDF-1.4\n/Type /Pages /Count 1\n" + b"X" * (8 * 1024 * 1024)
tokens = estimate_pdf_tokens(fake)
# byte heuristic: 8MB / 80 = 100k tokens > pages * 750 = 750
assert tokens >= 100_000
def test_estimate_pdf_tokens_caps_malformed_count():
"""A PDF with /Count 999999 (malformed or hostile) does NOT bypass
the 10k pages sanity cap; falls through to byte heuristic instead."""
from backend.apps.settings.settings import estimate_pdf_tokens
fake = b"%PDF-1.4\n/Type /Pages /Count 999999\n"
t = estimate_pdf_tokens(fake)
# Should NOT be 999999 * 750 = 750 million.
assert t < 50_000_000
def test_upload_dedup_under_concurrent_uploads():
"""Run N parallel uploads of the same logical filename through threads
and verify EVERY upload landed at a distinct path (no overwrites)."""
import os, threading
from backend.apps.settings.settings import UPLOAD_DIR
os.makedirs(UPLOAD_DIR, exist_ok=True)
name = f"test_concurrent_{os.getpid()}.txt"
results: list[str] = []
lock = threading.Lock()
def writer():
base, ext = os.path.splitext(name)
dest = os.path.join(UPLOAD_DIR, name)
counter = 0
fd = None
while fd is None:
try:
fd = os.open(dest, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
except FileExistsError:
counter += 1
dest = os.path.join(UPLOAD_DIR, f"{base}_{counter}{ext}")
with os.fdopen(fd, "wb") as fh:
fh.write(b"hi")
with lock:
results.append(dest)
threads = [threading.Thread(target=writer) for _ in range(10)]
for t in threads: t.start()
for t in threads: t.join()
try:
assert len(set(results)) == 10, f"expected 10 distinct paths, got {len(set(results))}"
finally:
for p in results:
try: os.remove(p)
except Exception: pass
def test_resolve_attachments_handles_missing_path_gracefully():
"""If a path in context_paths no longer exists (file deleted, TTL
cleanup fired, restored session referencing temp file across reboot),
we emit a 'not found' refusal instead of crashing."""
from backend.apps.agents.agent_manager import AgentManager
mgr = AgentManager()
text, native, refusals = mgr.resolve_attachments(
[{"path": "/var/folders/nonexistent/definitely-gone.pdf", "type": "file"}],
api_type="anthropic", model="opus-4-7",
)
assert not native
# 'not found' lands in `text` (sections), not refusals, per implementation.
assert "not found" in text.lower()
def test_resolve_attachments_handles_directory_path_not_file():
"""A directory in context_paths gets dir-tree handling, not treated
as a file. Prevents trying to base64 a directory."""
import tempfile, os
from backend.apps.agents.agent_manager import AgentManager
mgr = AgentManager()
tmpdir = tempfile.mkdtemp()
open(os.path.join(tmpdir, "a.txt"), "w").write("hello")
try:
text, native, refusals = mgr.resolve_attachments(
[{"path": tmpdir, "type": "directory"}],
api_type="anthropic", model="opus-4-7",
)
assert not native
assert "context_directory" in text
finally:
import shutil; shutil.rmtree(tmpdir)
def test_resolve_attachments_mixed_kinds_total_size_guard():
"""1 text + 1 PDF + 1 image attached together must respect both the
per-file caps AND the total-request-size cap as a single integrated
check, not three independent ones."""
import tempfile, os
from backend.apps.agents.agent_manager import AgentManager
mgr = AgentManager()
paths = []
try:
# 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)
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as fh:
fh.write(b"\x89PNG\r\n\x1a\n"); fh.write(b"X" * (10 * 1024 * 1024))
paths.append(fh.name)
with tempfile.NamedTemporaryFile(suffix=".md", mode="w", delete=False) as fh:
fh.write("# notes"); paths.append(fh.name)
text, native, refusals = mgr.resolve_attachments(
[{"path": p, "type": "file"} for p in paths],
api_type="anthropic", model="opus-4-7",
)
# All three should make it: PDF native, image native, text inline.
assert len(native) == 2
assert any(b["type"] == "document" for b in native)
assert any(b["type"] == "image" for b in native)
assert "notes" in text
assert not refusals
finally:
for p in paths:
try: os.unlink(p)
except Exception: pass
def test_upload_dedup_handles_filename_collision_atomically():
"""O_CREAT|O_EXCL must reserve the destination so two callers
racing on the same filename get distinct outputs, not one
overwriting the other."""
import os, tempfile, shutil
from backend.apps.settings.settings import UPLOAD_DIR
os.makedirs(UPLOAD_DIR, exist_ok=True)
name = f"test_dedup_{os.getpid()}.txt"
paths = []
try:
# Simulate two writers reserving the same base name back to back.
for _ in range(3):
base, ext = os.path.splitext(name)
dest = os.path.join(UPLOAD_DIR, name)
counter = 0
fd = None
while fd is None:
try:
fd = os.open(dest, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
except FileExistsError:
counter += 1
dest = os.path.join(UPLOAD_DIR, f"{base}_{counter}{ext}")
with os.fdopen(fd, "wb") as fh:
fh.write(b"hi")
paths.append(dest)
assert len(set(paths)) == 3
finally:
for p in paths:
try: os.remove(p)
except Exception: pass
def test_sniff_recognises_macos_paths_with_spaces():
"""File paths on macOS commonly contain spaces ('My Documents/file.pdf').
The sniffer reads contents, not the path, but agent_manager's
os.path.basename / open() must round-trip these correctly."""
import tempfile, os
from backend.apps.agents.agent_manager import AgentManager
mgr = AgentManager()
tmpdir = tempfile.mkdtemp(prefix="space test ")
path = os.path.join(tmpdir, "my doc.pdf")
try:
with open(path, "wb") as f:
f.write(b"%PDF-1.4\n")
p_t, native, refusals = mgr.resolve_attachments(
[{"path": path, "type": "file"}], api_type="anthropic", model="opus-4-7",
)
assert native and native[0]["type"] == "document"
assert not refusals
finally:
try: os.unlink(path)
except Exception: pass
try: os.rmdir(tmpdir)
except Exception: pass
def test_resolve_attachments_uses_os_path_basename_for_windows_paths():
"""When backend runs on Windows, paths arrive as C:\\Users\\X\\file.pdf.
os.path.basename handles backslash correctly on Windows (ntpath module),
but on POSIX (this test env) it treats backslash as a literal character.
Either way, the refusal copy embeds the result, so the test just verifies
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).
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).
assert ntpath.basename(r"D:/Downloads\test.pdf") == "test.pdf"
def test_sniff_file_kind_consistent_across_platforms():
"""The sniffer reads bytes, never paths. So platform doesn't matter
for the classification logic, same bytes → same kind on Windows/Mac/Linux."""
from backend.apps.settings.settings import sniff_file_kind
assert sniff_file_kind(b"%PDF-1.4\n", "x.pdf") == ("pdf", "application/pdf")
assert sniff_file_kind(b"\x89PNG\r\n\x1a\n", "x.png") == ("image", "image/png")
assert sniff_file_kind(b"PK\x03\x04", "x.zip") == ("binary", None)
assert sniff_file_kind(b"MZ\x90\x00", "x.exe") == ("binary", None)
assert sniff_file_kind(b"hello world", "x.txt") == ("text", "text/plain")
def test_estimate_pdf_tokens_consistent_across_platforms():
"""Same byte-level math regardless of OS."""
from backend.apps.settings.settings import estimate_pdf_tokens
# 5MB PDF should always estimate ≥ 5MB/80 = 65536 tokens.
fake = b"%PDF-1.4\n" + b"X" * (5 * 1024 * 1024)
assert estimate_pdf_tokens(fake) >= 65000
def test_sniff_handles_windows_style_backslash_path_string():
"""Some Windows paths arrive at agent_manager with backslashes when
JSON-encoded or copied from Explorer. os.path.exists() handles
forward slashes on Windows but backslashes on POSIX would NOT find
the file. The basename() helper in the frontend already normalizes,
but verify the agent_manager refusal path is graceful."""
import os
from backend.apps.agents.agent_manager import AgentManager
mgr = AgentManager()
# A path that doesn't exist (POSIX cannot interpret backslashes as separator)
p_t, native, refusals = mgr.resolve_attachments(
[{"path": r"C:\fake\path\nope.pdf", "type": "file"}],
api_type="anthropic", model="opus-4-7",
)
assert not native
# Should produce a "not found" refusal, not crash.
assert any("not found" in s.lower() or "not found" in s for s in (p_t, *refusals)) or "not found" in p_t
def test_upload_dir_writable_on_macos_temp():
"""Audit: verify UPLOAD_DIR resolves to a writable path on this OS.
On macOS, tempfile.gettempdir() → /var/folders/... which is outside
the app sandbox restrictions; our entitlements don't grant explicit
temp access but it works due to standard process inheritance. On
Windows, tempfile → C:/Users/X/AppData/Local/Temp/ which is always
writable. Failure here would block every file attachment."""
import os
from backend.apps.settings.settings import UPLOAD_DIR
assert os.path.isdir(UPLOAD_DIR), f"UPLOAD_DIR not a directory: {UPLOAD_DIR}"
probe = os.path.join(UPLOAD_DIR, ".write_probe")
try:
with open(probe, "w") as f:
f.write("ok")
assert os.path.isfile(probe)
finally:
try: os.remove(probe)
except Exception: pass
def test_resolve_attachments_classifies_renamed_binary_as_binary_not_pdf():
"""A .pdf rename of a ZIP/PNG must NOT be inlined as a document
block; magic-byte sniff guards us."""
import tempfile, os
from backend.apps.agents.agent_manager import AgentManager
mgr = AgentManager()
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as fh:
fh.write(b"PK\x03\x04fake zip masquerading as pdf")
path = fh.name
try:
p_t, native, refusals = mgr.resolve_attachments(
[{"path": path, "type": "file"}], api_type="anthropic", model="opus-4-7",
)
assert not native
assert refusals and "binary" in refusals[0].lower()
finally:
os.unlink(path)
def test_gemini_proxy_rewrites_document_to_openai_image_url_for_9router():
"""9router 0.3.60 only preserves `image_url` blocks (chunk 318 filter);
Anthropic-shape image/document blocks get stringified. We rewrite to
OpenAI image_url with data: URL so 9router emits Gemini inlineData."""
import json
from backend.apps.agents.proxy.anthropic_proxy import scrub_request_for_gemini
body = json.dumps({
"model": "gemini-3.1-pro-preview",
"messages": [{
"role": "user",
"content": [
{"type": "text", "text": "summarize this"},
{"type": "document", "source": {
"type": "base64",
"media_type": "application/pdf",
"data": "JVBERi0xLjQK",
}},
],
}],
}).encode("utf-8")
out = json.loads(scrub_request_for_gemini(body))
blocks = out["messages"][0]["content"]
assert blocks[0]["type"] == "text"
assert blocks[1]["type"] == "image_url"
assert blocks[1]["image_url"]["url"] == "data:application/pdf;base64,JVBERi0xLjQK"
def test_gemini_proxy_also_rewrites_anthropic_image_blocks_to_image_url():
"""Same fix applies to plain images: Anthropic image → OpenAI image_url
with data: URL, so 9router's filter preserves it instead of stringifying."""
import json
from backend.apps.agents.proxy.anthropic_proxy import scrub_request_for_gemini
body = json.dumps({
"model": "gemini-3-pro-preview",
"messages": [{
"role": "user",
"content": [
{"type": "image", "source": {
"type": "base64",
"media_type": "image/png",
"data": "iVBORw0KGgo=",
}},
],
}],
}).encode("utf-8")
out = json.loads(scrub_request_for_gemini(body))
block = out["messages"][0]["content"][0]
assert block["type"] == "image_url"
assert block["image_url"]["url"] == "data:image/png;base64,iVBORw0KGgo="
def test_anthropic_document_block_schema_matches_docs():
"""Schema-conformance: the document block our agent_manager emits for
Anthropic must structurally match the canonical shape from
https://docs.claude.com/en/docs/build-with-claude/pdf-support
(base64 inline). If Anthropic changes the schema we want a noisy test
failure here, not a runtime production failure."""
import tempfile, os
from backend.apps.agents.agent_manager import AgentManager
mgr = AgentManager()
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as fh:
fh.write(b"%PDF-1.4\n%canonical schema test\n")
path = fh.name
try:
p_t, native, p_r = mgr.resolve_attachments(
[{"path": path, "type": "file"}], api_type="anthropic", model="opus-4-7",
)
block = native[0]
# Per Anthropic docs, the exact required fields are:
assert set(block.keys()) >= {"type", "source"}
assert block["type"] == "document"
src = block["source"]
assert set(src.keys()) == {"type", "media_type", "data"}
assert src["type"] == "base64"
assert src["media_type"] == "application/pdf"
# cache_control is optional but our impl sets it on the last block
if "cache_control" in block:
assert block["cache_control"] == {"type": "ephemeral"}
# Base64 data must decode cleanly back to PDF magic header.
import base64 as p_b64
decoded = p_b64.b64decode(src["data"])
assert decoded.startswith(b"%PDF-")
finally:
os.unlink(path)
def test_gemini_translated_block_matches_9router_image_url_filter():
"""Per inspection of router/.next/server/chunks/318.js, 9router 0.3.60's
OpenAI→Gemini translator only handles `image_url` blocks with data: URLs
(it stringifies any other shape). Our translator must emit exactly that
shape for PDFs and images both."""
import json
from backend.apps.agents.proxy.anthropic_proxy import scrub_request_for_gemini
body = json.dumps({
"model": "gemini-3.1-pro-preview",
"messages": [{"role": "user", "content": [
{"type": "document", "source": {
"type": "base64",
"media_type": "application/pdf",
"data": "JVBERi0xLjQK",
}},
]}],
}).encode("utf-8")
out = json.loads(scrub_request_for_gemini(body))
block = out["messages"][0]["content"][0]
assert block["type"] == "image_url"
assert "image_url" in block
assert block["image_url"]["url"].startswith("data:application/pdf;base64,")
def test_gemini_schema_normalizer_allowlists_and_folds_nullable():
"""Gemini's function_declarations validator 400s (zero tokens in) on JSON
Schema constructs the old denylist kept missing: union `type`, anyOf/oneOf/
allOf, $comment, format, additionalProperties, title. The normalizer keeps
only the keys Gemini accepts and folds the two nullable encodings Anthropic
emits (union type, anyOf-with-null) into the `nullable` flag Gemini groks.
Live-confirmed against the Gemini API 2026-06-14."""
import json
from backend.apps.agents.proxy.anthropic_proxy import (
normalize_schema_for_gemini, scrub_request_for_gemini,
)
# union type -> single type + nullable
assert normalize_schema_for_gemini({"type": ["string", "null"], "description": "d"}) == \
{"type": "string", "description": "d", "nullable": True}
# anyOf-with-null -> chosen branch + nullable, allowed constraint preserved
assert normalize_schema_for_gemini({"anyOf": [{"type": "integer", "minimum": 0}, {"type": "null"}]}) == \
{"type": "integer", "minimum": 0, "nullable": True}
# forbidden keys dropped, enum kept
assert normalize_schema_for_gemini({
"type": "object", "additionalProperties": False, "title": "T",
"properties": {"u": {"type": "string", "format": "uri", "$comment": "x", "minLength": 2},
"d": {"type": "string", "enum": ["a", "b"]}},
"required": ["u"],
}) == {"type": "object",
"properties": {"u": {"type": "string"}, "d": {"type": "string", "enum": ["a", "b"]}},
"required": ["u"]}
# End to end: no Gemini-rejected key survives a realistic tool payload.
FORBIDDEN = {"$schema", "$ref", "additionalProperties", "title", "default", "$comment",
"format", "pattern", "minLength", "maxLength", "anyOf", "oneOf", "allOf", "const"}
body = json.dumps({"model": "gemini-3.1-pro-preview", "tools": [{
"name": "q", "input_schema": {
"type": "object", "additionalProperties": False, "$schema": "x",
"properties": {
"filter": {"anyOf": [{"type": "object", "properties": {"q": {"type": "string"}}},
{"type": "null"}]},
"size": {"type": ["integer", "null"], "minimum": 1, "default": 10},
"url": {"type": "string", "format": "uri", "$comment": "c"}},
"required": ["filter"]}}]}).encode()
schema = json.loads(scrub_request_for_gemini(body))["tools"][0]["input_schema"]
seen, stack = set(), [schema]
while stack:
n = stack.pop()
if isinstance(n, dict):
seen |= set(n.keys()); stack += list(n.values())
elif isinstance(n, list):
stack += n
assert seen.isdisjoint(FORBIDDEN), f"forbidden keys survived: {seen & FORBIDDEN}"
def test_gpt5_param_scrub_drops_unsupported_sampling_knobs():
"""GPT-5 reasoning models 400 on max_tokens, temperature!=1, top_p, and the
penalty/logprobs family. Both the proxy and the passthrough must strip them.
Live-confirmed the 400s against the OpenAI API 2026-06-14."""
import json
from backend.apps.agents.proxy.anthropic_proxy import scrub_request_for_openai_gpt5
from backend.apps.agents.core.openai_passthrough import scrub_gpt5_params
dirty = json.dumps({"model": "gpt-5", "messages": [{"role": "user", "content": "hi"}],
"max_tokens": 200, "temperature": 0, "top_p": 0.9,
"frequency_penalty": 0.5, "presence_penalty": 0.1, "logprobs": True}).encode()
for fn in (scrub_request_for_openai_gpt5, scrub_gpt5_params):
out = json.loads(fn(dirty))
assert out.get("max_completion_tokens") == 200 and "max_tokens" not in out, fn.__name__
for k in ("temperature", "top_p", "frequency_penalty", "presence_penalty", "logprobs"):
assert k not in out, f"{fn.__name__} left {k}"
# temperature==1 is the one allowed value; don't over-strip it
assert json.loads(scrub_gpt5_params(json.dumps(
{"model": "gpt-5", "temperature": 1}).encode())).get("temperature") == 1
# non-gpt-5 models are untouched
assert json.loads(scrub_gpt5_params(json.dumps(
{"model": "gpt-4o", "temperature": 0, "top_p": 0.5}).encode())) == \
{"model": "gpt-4o", "temperature": 0, "top_p": 0.5}
def test_openrouter_plugin_array_matches_docs():
"""Per https://openrouter.ai/docs/features/multimodal/pdfs, the
plugins array shape is `[{id:"file-parser", pdf:{engine: "..."}}]`
at the top level. Engines: pdf-text (free, deprecated → cloudflare),
mistral-ocr ($2/1k pages), native (model-supported)."""
import json
from backend.apps.agents.proxy.anthropic_proxy import inject_openrouter_file_parser
body = json.dumps({
"model": "openrouter/qwen/qwen-2.5-72b-instruct",
"messages": [{"role": "user", "content": [
{"type": "document", "source": {
"type": "base64", "media_type": "application/pdf", "data": "x",
}},
]}],
}).encode("utf-8")
out = json.loads(inject_openrouter_file_parser(body))
plugins = out["plugins"]
assert isinstance(plugins, list)
fp = [p for p in plugins if p.get("id") == "file-parser"][0]
# Shape exactly matches https://openrouter.ai/docs/features/multimodal/pdfs
assert set(fp.keys()) == {"id", "pdf"}
assert isinstance(fp["pdf"], dict)
assert fp["pdf"]["engine"] in ("pdf-text", "mistral-ocr", "native")
def test_openai_translated_image_block_matches_image_url_data_uri():
"""OpenAI's image_url accepts data: URIs only for image/* mime types
(verified May 2026, application/pdf returns HTTP 400). The
translator rewrites Anthropic image blocks; document blocks are
refused upstream in agent_manager."""
import json
from backend.apps.agents.proxy.anthropic_proxy import scrub_request_for_openai_gpt5
body = json.dumps({
"model": "gpt-5.5",
"max_tokens": 100,
"messages": [{"role": "user", "content": [
{"type": "image", "source": {
"type": "base64",
"media_type": "image/png",
"data": "iVBORw0KGgo=",
}},
]}],
}).encode("utf-8")
out = json.loads(scrub_request_for_openai_gpt5(body))
block = out["messages"][0]["content"][0]
assert block["type"] == "image_url"
assert block["image_url"]["url"] == "data:image/png;base64,iVBORw0KGgo="
def test_openai_proxy_rewrites_image_block_only_documents_pass_through():
"""OpenAI image_url only accepts image/* mime; documents are refused
upstream. Translator handles images, leaves documents untouched."""
import json
from backend.apps.agents.proxy.anthropic_proxy import scrub_request_for_openai_gpt5
body = json.dumps({
"model": "gpt-5.5",
"max_tokens": 500,
"messages": [{
"role": "user",
"content": [
{"type": "text", "text": "what's in this image?"},
{"type": "image", "source": {
"type": "base64",
"media_type": "image/png",
"data": "iVBORw0KGgo=",
}},
],
}],
}).encode("utf-8")
out = json.loads(scrub_request_for_openai_gpt5(body))
blocks = out["messages"][0]["content"]
assert blocks[0]["type"] == "text"
assert blocks[1]["type"] == "image_url"
assert blocks[1]["image_url"]["url"].startswith("data:image/png;base64,")
assert "max_completion_tokens" in out
assert "max_tokens" not in out
def test_openai_proxy_skips_rewrite_when_no_document():
"""Pure text turn on GPT-5 should only get the max_tokens rename."""
import json
from backend.apps.agents.proxy.anthropic_proxy import scrub_request_for_openai_gpt5
body = json.dumps({
"model": "gpt-5.5",
"max_tokens": 100,
"messages": [{"role": "user", "content": "hi"}],
}).encode("utf-8")
out = json.loads(scrub_request_for_openai_gpt5(body))
assert out["messages"][0]["content"] == "hi"
assert out.get("max_completion_tokens") == 100
def test_openai_proxy_defensive_on_malformed_document_blocks():
"""Malformed document blocks (missing source, missing data) pass
through untouched so the upstream returns a proper error rather
than us silently dropping the file."""
import json
from backend.apps.agents.proxy.anthropic_proxy import scrub_request_for_openai_gpt5
body = json.dumps({
"model": "gpt-5.5",
"messages": [{
"role": "user",
"content": [
{"type": "document"},
{"type": "document", "source": {"type": "url"}},
{"type": "document", "source": {"type": "base64"}},
],
}],
}).encode("utf-8")
out = json.loads(scrub_request_for_openai_gpt5(body))
for b in out["messages"][0]["content"]:
assert b["type"] == "document"
def test_resolve_attachments_openai_codex_refused_for_pdfs():
"""Codex variants refuse PDFs (both because Codex models don't read
PDFs AND because the OpenAI direct lane is currently disabled until
9router translation lands)."""
import tempfile, os
from backend.apps.agents.agent_manager import AgentManager
mgr = AgentManager()
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as fh:
fh.write(b"%PDF-1.4\n%test\n")
path = fh.name
try:
p_t, native, refusals = mgr.resolve_attachments(
[{"path": path, "type": "file"}], api_type="openai", model="gpt-5.3-codex",
)
assert not native
assert refusals
finally:
os.unlink(path)
def test_resolve_attachments_openai_codex_still_refuses_pdf():
"""Codex variants don't support PDFs even though their OpenAI family
does; refusal should fire with switch hint."""
import tempfile, os
from backend.apps.agents.agent_manager import AgentManager
mgr = AgentManager()
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as fh:
fh.write(b"%PDF-1.4\n%test\n")
path = fh.name
try:
p_t, native, refusals = mgr.resolve_attachments(
[{"path": path, "type": "file"}], api_type="openai", model="gpt-5.3-codex",
)
assert not native
assert refusals and "codex" in refusals[0].lower()
finally:
os.unlink(path)
def test_openrouter_proxy_injects_file_parser_plugin_when_document_present():
"""OR's universal-PDF feature requires top-level plugins:[{id:file-parser,...}].
When a document block is in the request bound for OR, inject it."""
import json
from backend.apps.agents.proxy.anthropic_proxy import inject_openrouter_file_parser
body = json.dumps({
"model": "openrouter/qwen/qwen-2.5-72b-instruct",
"messages": [{
"role": "user",
"content": [
{"type": "text", "text": "summarize"},
{"type": "document", "source": {
"type": "base64",
"media_type": "application/pdf",
"data": "JVBERi0xLjQK",
}},
],
}],
}).encode("utf-8")
out = json.loads(inject_openrouter_file_parser(body))
plugins = out.get("plugins")
assert isinstance(plugins, list) and len(plugins) >= 1
fp = next((p for p in plugins if p.get("id") == "file-parser"), None)
assert fp and fp["pdf"]["engine"] == "pdf-text"
def test_openrouter_proxy_skips_plugin_when_no_document():
"""No document block → don't inject the plugin (costs nothing, but
keeps the request body clean)."""
import json
from backend.apps.agents.proxy.anthropic_proxy import inject_openrouter_file_parser
body = json.dumps({
"model": "openrouter/qwen/qwen-2.5-72b-instruct",
"messages": [{"role": "user", "content": "just a question"}],
}).encode("utf-8")
out = json.loads(inject_openrouter_file_parser(body))
assert "plugins" not in out
def test_openrouter_proxy_dedupes_existing_file_parser_plugin():
"""If a caller already provided file-parser, don't duplicate it."""
import json
from backend.apps.agents.proxy.anthropic_proxy import inject_openrouter_file_parser
body = json.dumps({
"model": "openrouter/qwen/qwen-2.5-72b-instruct",
"plugins": [{"id": "file-parser", "pdf": {"engine": "mistral-ocr"}}],
"messages": [{
"role": "user",
"content": [
{"type": "document", "source": {"type": "base64", "media_type": "application/pdf", "data": "x"}},
],
}],
}).encode("utf-8")
out = json.loads(inject_openrouter_file_parser(body))
fps = [p for p in out["plugins"] if p.get("id") == "file-parser"]
assert len(fps) == 1
assert fps[0]["pdf"]["engine"] == "mistral-ocr" # caller's engine wins
def test_gemini_proxy_defensive_on_malformed_blocks():
"""Bad shapes (missing data, wrong source.type, non-string data)
must NOT be rewritten; they pass through so the upstream sees the
error rather than a silently-corrupted block."""
import json
from backend.apps.agents.proxy.anthropic_proxy import scrub_request_for_gemini
body = json.dumps({
"model": "gemini-3.1-pro-preview",
"messages": [{
"role": "user",
"content": [
{"type": "document"}, # no source
{"type": "document", "source": {}}, # empty source
{"type": "document", "source": {"type": "url"}}, # not base64
{"type": "document", "source": {"type": "base64"}}, # no data
],
}],
}).encode("utf-8")
out = json.loads(scrub_request_for_gemini(body))
for b in out["messages"][0]["content"]:
assert b["type"] == "document"
def test_resolve_attachments_anthropic_emits_native_document():
"""Anthropic upstream gets a `document` content block for PDFs, not
a text placeholder."""
import base64, tempfile, os
from backend.apps.agents.agent_manager import AgentManager
mgr = AgentManager()
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as fh:
fh.write(b"%PDF-1.4\n%test\n")
path = fh.name
try:
text, native, refusals = mgr.resolve_attachments(
[{"path": path, "type": "file"}], api_type="anthropic", model="opus-4-7",
)
assert native and native[0]["type"] == "document"
assert native[0]["source"]["media_type"] == "application/pdf"
assert not refusals
finally:
os.unlink(path)
def test_resolve_attachments_openai_accepts_pdf_via_bypass_translator():
"""OpenAI GPT-5.x non-codex accepts PDFs because anthropic_proxy
detects document blocks + bypasses 9router via anthropic_to_openai.
No refusal at agent_manager level; the bypass kicks in at proxy
time when openai_api_key is set."""
import tempfile, os
from backend.apps.agents.agent_manager import AgentManager
mgr = AgentManager()
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as fh:
fh.write(b"%PDF-1.4\n%test\n")
path = fh.name
try:
p_text, native, refusals = mgr.resolve_attachments(
[{"path": path, "type": "file"}], api_type="openai", model="gpt-5.5",
)
assert native and native[0]["type"] == "document"
assert not refusals
finally:
os.unlink(path)
def test_bypass_estimate_body_bytes_sums_image_url_and_file_blocks():
"""The size estimator must sum payload bytes across BOTH content
types the translator emits (image_url with data: URL, file with
file_data) so the pre-flight reject can fire before httpx serializes."""
from backend.apps.agents.proxy.anthropic_to_openai import estimate_body_bytes
body = {"messages": [{"role": "user", "content": [
{"type": "text", "text": "hi"},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,QUJDRA=="}}, # 8 bytes b64
{"type": "file", "file": {"file_data": "data:application/pdf;base64,RUZHSA=="}}, # 8 bytes b64
]}]}
assert estimate_body_bytes(body) == 16
def test_bypass_concurrency_semaphore_serializes_excess_requests():
"""The semaphore caps in-flight bypass requests to prevent OOM. Cap=2
means a third concurrent request waits rather than allocating another
~40MB buffer."""
from backend.apps.agents.proxy.anthropic_to_openai import bypass_sema, BYPASS_CONCURRENCY
assert BYPASS_CONCURRENCY == 2
# Initial value matches the cap (no in-flight at import time).
assert bypass_sema._value == BYPASS_CONCURRENCY
def test_anthropic_to_openai_should_bypass_fires_for_gpt5_pdf():
"""The bypass only fires for: GPT-5.x non-codex + has document block
+ openai_api_key set. Misses any of those: 9router path."""
from backend.apps.agents.proxy.anthropic_to_openai import should_bypass_9router
body = {"model": "gpt-5.5", "messages": [{"role": "user", "content": [
{"type": "document", "source": {"type": "base64", "media_type": "application/pdf", "data": "x"}},
]}]}
assert should_bypass_9router(body, "sk-abc")
assert not should_bypass_9router(body, None)
assert not should_bypass_9router(body, "")
assert not should_bypass_9router({**body, "model": "gpt-5.3-codex"}, "sk-abc")
body_no_doc = {"model": "gpt-5.5", "messages": [{"role": "user", "content": "hi"}]}
assert not should_bypass_9router(body_no_doc, "sk-abc")
body_with_tools = {**body, "tools": [{"name": "x"}]}
assert not should_bypass_9router(body_with_tools, "sk-abc")
def test_anthropic_to_openai_request_translation_shape():
"""Translator must produce a valid OpenAI Chat Completions body."""
from backend.apps.agents.proxy.anthropic_to_openai import translate_request
body = {
"model": "gpt-5.5",
"max_tokens": 200,
"system": "You are helpful.",
"messages": [{
"role": "user",
"content": [
{"type": "text", "text": "summarize"},
{"type": "document", "source": {
"type": "base64", "media_type": "application/pdf", "data": "JVBERi0=",
}},
{"type": "image", "source": {
"type": "base64", "media_type": "image/png", "data": "iVBOR=",
}},
],
}],
}
out = translate_request(body)
assert out["model"] == "gpt-5.5"
assert out["stream"] is True
assert out["max_completion_tokens"] == 200
assert out["messages"][0] == {"role": "system", "content": "You are helpful."}
user_content = out["messages"][1]["content"]
assert any(p["type"] == "text" and p["text"] == "summarize" for p in user_content)
assert any(p["type"] == "file" and p["file"]["file_data"].startswith("data:application/pdf;base64,") for p in user_content)
assert any(p["type"] == "image_url" and p["image_url"]["url"].startswith("data:image/png;base64,") for p in user_content)
def test_resolve_attachments_gemini_emits_native_document_after_translator_fix():
"""After fixing the 9router 0.3.60 block-stripping bug via
anthropic_proxy._rewrite_document_to_image (now rewrites both
image AND document → OpenAI image_url with data: URL, which 9router
translates to Gemini inlineData), PDFs flow on Gemini natively."""
import tempfile, os
from backend.apps.agents.agent_manager import AgentManager
mgr = AgentManager()
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as fh:
fh.write(b"%PDF-1.4\n%test\n")
path = fh.name
try:
p_text, native, refusals = mgr.resolve_attachments(
[{"path": path, "type": "file"}], api_type="gemini", model="gemini-3.1-pro-api",
)
assert native and native[0]["type"] == "document"
assert not refusals
finally:
os.unlink(path)
def test_resolve_attachments_text_file_inlined_not_native():
"""Text files keep flowing through the existing context_file inline
path (no native block)."""
import tempfile, os
from backend.apps.agents.agent_manager import AgentManager
mgr = AgentManager()
with tempfile.NamedTemporaryFile(suffix=".md", mode="w", delete=False) as fh:
fh.write("# hello\nworld")
path = fh.name
try:
text, native, refusals = mgr.resolve_attachments(
[{"path": path, "type": "file"}], api_type="opus-4-7", model="opus-4-7",
)
assert not native
assert not refusals
assert "hello" in text
finally:
os.unlink(path)
def test_resolve_attachments_pdf_refused_when_too_large():
"""Anthropic's per-file cap blocks PDFs over 24MB."""
import os, tempfile
from backend.apps.agents.agent_manager import AgentManager
mgr = AgentManager()
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as fh:
fh.write(b"%PDF-1.4\n")
fh.write(b"X" * (25 * 1024 * 1024))
path = fh.name
try:
p_t, native, refusals = mgr.resolve_attachments(
[{"path": path, "type": "file"}], api_type="anthropic", model="opus-4-7",
)
assert not native
assert refusals
assert "per-file cap" in refusals[0].lower() or "exceeds" in refusals[0].lower()
finally:
os.unlink(path)
def test_resolve_attachments_refuses_when_total_exceeds_request_cap():
"""4 medium PDFs that each pass the per-file cap should still be
blocked when their combined base64 size would exceed Anthropic's
32MB request cap. This is the exact Mehmet scenario (30.3MB raw
of 4 PDFs base64 to ~40MB)."""
import os, tempfile
from backend.apps.agents.agent_manager import AgentManager
mgr = AgentManager()
# 4 PDFs at ~8MB each = 32MB raw = ~43MB base64, exceeds 28MB cap.
paths = []
try:
for i in range(4):
with tempfile.NamedTemporaryFile(suffix=f"_{i}.pdf", delete=False) as fh:
fh.write(b"%PDF-1.4\n")
fh.write(b"X" * (8 * 1024 * 1024))
paths.append(fh.name)
p_t, native, refusals = mgr.resolve_attachments(
[{"path": p, "type": "file"} for p in paths],
api_type="anthropic", model="opus-4-7",
)
# First few PDFs fit; later ones refused with "request over" message.
assert refusals, "expected refusals on multi-PDF over-cap"
assert any("encoded" in r.lower() and "provider cap" in r.lower() for r in refusals), \
f"expected total-size refusal copy; got: {refusals}"
finally:
for p in paths:
try: os.unlink(p)
except Exception: pass
def test_resolve_attachments_anthropic_marks_last_document_ephemeral_for_cache():
"""Anthropic prompt caching: the last document block gets
cache_control:ephemeral so multi-turn PDF chats stay cache-warm."""
import os, tempfile
from backend.apps.agents.agent_manager import AgentManager
mgr = AgentManager()
paths = []
try:
for i in range(2):
with tempfile.NamedTemporaryFile(suffix=f"_{i}.pdf", delete=False) as fh:
fh.write(b"%PDF-1.4\n%test\n")
paths.append(fh.name)
p_t, native, p_r = mgr.resolve_attachments(
[{"path": p, "type": "file"} for p in paths],
api_type="anthropic", model="opus-4-7",
)
# Only the LAST document gets cache_control per Anthropic docs.
assert native[-1].get("cache_control") == {"type": "ephemeral"}
assert "cache_control" not in native[0]
finally:
for p in paths:
try: os.unlink(p)
except Exception: pass
def test_resolve_attachments_anthropic_does_mark_ephemeral_but_only_anthropic():
"""cache_control is Anthropic-only; don't pollute other-provider
blocks. Anthropic should get ephemeral on the last document block;
OpenRouter (which also supports PDFs) should NOT."""
import os, tempfile
from backend.apps.agents.agent_manager import AgentManager
mgr = AgentManager()
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as fh:
fh.write(b"%PDF-1.4\n%test\n")
path = fh.name
try:
p_t, ant_native, p_r = mgr.resolve_attachments(
[{"path": path, "type": "file"}], api_type="anthropic", model="opus-4-7",
)
assert ant_native and ant_native[0].get("cache_control") == {"type": "ephemeral"}
p_t, or_native, p_r = mgr.resolve_attachments(
[{"path": path, "type": "file"}], api_type="openrouter", model="openrouter/openai/gpt-5",
)
assert or_native and "cache_control" not in or_native[0]
finally:
os.unlink(path)
def test_apply_context_window_respects_custom_provider_value():
"""Custom OpenAI-compatible models supply their own context_window
via settings.custom_providers. apply_context_window must look them
up the same way get_context_window does."""
from backend.apps.agents.core.models import AgentSession
from backend.apps.agents.manager.session.apply_context_window import apply_context_window
from backend.apps.settings.models import AppSettings, CustomProvider
s = AgentSession(id="x", name="t", provider="custom", model="custom/ollama/qwen2.5:7b", mode="agent")
settings = AppSettings(custom_providers=[
CustomProvider(
name="Ollama",
base_url="http://localhost:11434/v1",
api_key="",
models=[{"value": "qwen2.5:7b", "label": "Qwen 2.5 7B", "context_window": 32_000}],
),
])
apply_context_window(s, settings)
assert s.context_window == 32_000
# --------------------------------------------------------------------------- Custom OpenAI-compatible providers (Ollama Cloud, Together, etc.) ---------------------------------------------------------------------------
def test_custom_provider_value_synthesises_route_api_entry():
"""`custom/<slug>/<bare>` picker values must synthesise a route='api',
api='custom' entry whose model_id is the 9Router routing string
`cp-<slug>/<bare>`. agent_manager keys on api='custom' and resolved_model
must be the cp- prefixed string for 9Router to forward correctly."""
from backend.apps.agents.providers.registry import find_builtin_model
entry = find_builtin_model("custom/ollama-cloud/gpt-oss:120b")
assert entry is not None
assert entry.get("api") == "custom"
assert entry.get("route") == "api"
assert entry.get("model_id") == "cp-ollama-cloud/gpt-oss:120b"
assert entry.get("router_model_id") == "cp-ollama-cloud/gpt-oss:120b"
def test_custom_provider_value_resolve_model_id_returns_cp_prefix():
from backend.apps.agents.providers.registry import resolve_model_id_for_sdk
from backend.apps.settings.models import AppSettings
rid = resolve_model_id_for_sdk("custom/ollama-cloud/gpt-oss:120b", AppSettings())
assert rid == "cp-ollama-cloud/gpt-oss:120b"
def test_custom_provider_value_with_multi_segment_model_id():
"""Model ids may contain '/' (e.g. meta-llama/llama-3-70b-instruct on
Together AI). Synthesis must use partition on the FIRST '/' so the
rest of the model id stays intact."""
from backend.apps.agents.providers.registry import find_builtin_model
entry = find_builtin_model("custom/together-ai/meta-llama/llama-3-70b-instruct")
assert entry is not None
assert entry.get("model_id") == "cp-together-ai/meta-llama/llama-3-70b-instruct"
def test_custom_provider_lookup_finds_entry_by_slug():
"""find_custom_provider_for_value must slugify the same way as the
UI/sync layer so name 'Ollama Cloud' resolves to the value
'custom/ollama-cloud/...'."""
from backend.apps.agents.providers.registry import find_custom_provider_for_value
from backend.apps.settings.models import AppSettings, CustomProvider
s = AppSettings(custom_providers=[
CustomProvider(name="Ollama Cloud", base_url="https://ollama.com/v1", api_key="x"),
CustomProvider(name="Together AI", base_url="https://api.together.xyz/v1", api_key="y"),
])
cp = find_custom_provider_for_value(s, "custom/ollama-cloud/gpt-oss:120b")
assert cp is not None and cp.name == "Ollama Cloud"
cp2 = find_custom_provider_for_value(s, "custom/together-ai/meta-llama/llama-3-70b")
assert cp2 is not None and cp2.name == "Together AI"
# Unknown slug → None.
assert find_custom_provider_for_value(s, "custom/nonexistent/whatever") is None
def test_get_context_window_custom_provider_value_format():
"""Picker values use `custom/<slug>/<bare>` but the user-stored model
list keys context_window by the bare model id. Lookup must strip the
prefix before matching."""
from backend.apps.agents.providers.registry import get_context_window
from backend.apps.settings.models import AppSettings, CustomProvider
s = AppSettings(custom_providers=[
CustomProvider(
name="Together AI",
base_url="https://api.together.xyz/v1",
api_key="x",
models=[{"value": "deepseek-r1", "label": "DeepSeek R1", "context_window": 64_000}],
),
])
assert get_context_window("Together AI", "custom/together-ai/deepseek-r1", s) == 64_000
def test_custom_provider_slug_is_url_safe():
"""The slug must be alnum-and-dash only, it's used both as the 9Router
prefix and as a URL path segment. Spaces, slashes, and special chars
must all be folded to dashes."""
from backend.apps.agents.providers.registry import custom_provider_slug_for_lookup
assert custom_provider_slug_for_lookup("Ollama Cloud") == "ollama-cloud"
assert custom_provider_slug_for_lookup("My/Local LM!!!") == "my-local-lm"
assert custom_provider_slug_for_lookup("") == "custom"
assert custom_provider_slug_for_lookup(" ") == "custom"
def test_custom_provider_slug_unicode_collapses_safely():
"""Unicode names are folded to ASCII-safe dashes; emojis/accents drop."""
from backend.apps.agents.providers.registry import custom_provider_slug_for_lookup
# Accented chars get stripped (regex is [a-zA-Z0-9-] only).
assert custom_provider_slug_for_lookup("Tögether AI 🚀") == "t-gether-ai"
# Pure-emoji name → fallback "custom".
assert custom_provider_slug_for_lookup("🚀💎") == "custom"
# Trailing/leading dashes get stripped.
assert custom_provider_slug_for_lookup("---weird---") == "weird"
def test_custom_provider_slug_does_not_collide_with_routing_prefixes():
"""The cp- prefix in the routing string must not collide with 9Router's
built-in prefixes (cc/, cx/, gc/, ag/, gemini/, openrouter/) used by
resolved_is_9router. cp- starts with 'c' and dash so it can't be
confused with cc/, but verify the dispatch logic agrees."""
from backend.apps.agents.providers.registry import find_builtin_model
entry = find_builtin_model("custom/cc/whatever") # adversarial slug "cc"
assert entry is not None
routed = entry["model_id"]
assert routed == "cp-cc/whatever"
# cp-cc is NOT cc/, startswith check would have to match the exact slash.
assert not routed.startswith(("cc/", "cx/", "gc/", "ag/", "gemini/", "openrouter/"))
def test_custom_provider_models_with_special_chars():
"""Model ids in the wild contain colons (Ollama 'gpt-oss:120b'), dots
(deepseek 'deepseek-v3.1'), version suffixes (':free'), and slashes
(Together 'meta-llama/Llama-3-70B'). All must round-trip without
being mangled."""
from backend.apps.agents.providers.registry import find_builtin_model
cases = [
"custom/ollama/gpt-oss:120b",
"custom/together/meta-llama/Llama-3.3-70B-Instruct",
"custom/deepseek/deepseek-v3.1-base",
"custom/openrouter/anthropic/claude-haiku-4.5:free",
"custom/groq/llama-3.3-70b-versatile",
]
for v in cases:
e = find_builtin_model(v)
assert e is not None, f"failed: {v}"
# Bare-model portion is everything after first slash after the slug.
rest = v[len("custom/"):]
slug, _, bare = rest.partition("/")
assert e["model_id"] == f"cp-{slug}/{bare}", f"bad routing for {v}: {e['model_id']}"
def test_custom_provider_value_with_invalid_format_returns_none():
"""Malformed picker values (no slug, no model) must not synthesise a
bogus entry, they should miss find_builtin_model entirely so the
dispatch loop falls through to the 'unknown model' branch."""
from backend.apps.agents.providers.registry import find_builtin_model
assert find_builtin_model("custom/") is None
assert find_builtin_model("custom/onlyslug") is None
assert find_builtin_model("custom//onlymodel") is None # empty slug
def test_custom_provider_get_api_type_returns_custom():
"""get_api_type drives the dispatch branch in agent_manager.py, must
return 'custom' (not 'anthropic' default fallback) for a custom value."""
from backend.apps.agents.providers.registry import get_api_type
assert get_api_type("custom/ollama/gpt-oss:120b") == "custom"
def test_custom_provider_pydantic_round_trip_preserves_models():
"""Settings save/load uses Pydantic; the models field is list[dict] and
must survive serialize-deserialize without dropping per-model fields."""
from backend.apps.settings.models import AppSettings, CustomProvider
s = AppSettings(custom_providers=[
CustomProvider(
name="My LM Studio",
base_url="http://localhost:1234/v1",
api_key="not-needed",
models=[
{"value": "qwen3-coder", "label": "Qwen 3 Coder", "context_window": 128_000},
{"value": "llama-3.3-70b", "label": "Llama 3.3 70B"}, # no ctx
],
),
])
dumped = s.model_dump()
re = AppSettings(**dumped)
assert len(re.custom_providers) == 1
cp = re.custom_providers[0]
assert cp.name == "My LM Studio"
assert len(cp.models) == 2
assert cp.models[0]["context_window"] == 128_000
assert cp.models[1].get("context_window") is None or cp.models[1].get("context_window") == 0
def test_custom_provider_settings_default_is_empty_list():
"""Existing settings.json without custom_providers must default to []
(not None / KeyError) so old installs upgrade cleanly."""
from backend.apps.settings.models import AppSettings
s = AppSettings()
assert s.custom_providers == []
assert isinstance(s.custom_providers, list)
def test_custom_provider_get_anthropic_client_routes_cp_to_9router():
"""probe-model and browser_agent both use get_anthropic_client_for_model.
A cp- prefixed router id must build a client pointed at 9Router, not
direct Anthropic, so the request actually reaches the openai-compat
translator instead of Anthropic's API rejecting an unknown model id."""
from backend.apps.settings.credentials import get_anthropic_client_for_model
from backend.apps.settings.models import AppSettings
s = AppSettings(anthropic_api_key="sk-ant-anything")
c = get_anthropic_client_for_model(s, "cp-ollama/gpt-oss:120b")
assert "20128" in str(c.base_url), f"client should point at 9Router, got {c.base_url}"
def test_custom_provider_two_providers_get_distinct_slugs():
"""Two custom providers with different display names must produce
two different slugs / routing prefixes, otherwise 9Router will route
both to whichever connection was created last."""
from backend.apps.agents.providers.registry import custom_provider_slug_for_lookup
a = custom_provider_slug_for_lookup("Ollama Cloud")
b = custom_provider_slug_for_lookup("Together AI")
c = custom_provider_slug_for_lookup("Groq")
assert len({a, b, c}) == 3
def test_custom_provider_slug_collision_after_sanitize():
"""Two raw names that slugify to the same string is a real footgun
(e.g. 'Ollama Cloud' vs 'ollama-cloud' both → 'ollama-cloud').
The dedupe-by-name UI check guards against same-string entries; this
test just documents that post-slug collisions DO collide and the
UI-level uniqueness check (in Settings.tsx) is the right enforcement
layer, backend resolution would always pick the first match."""
from backend.apps.agents.providers.registry import custom_provider_slug_for_lookup
assert custom_provider_slug_for_lookup("Ollama Cloud") == \
custom_provider_slug_for_lookup("ollama-cloud") == \
custom_provider_slug_for_lookup("OLLAMA cloud")
def test_list_models_includes_complete_custom_providers_excludes_incomplete():
"""list_models must surface fully-configured providers and silently
skip incomplete ones (missing name/base_url/models). This is what
let the user's empty-base-URL save 'silently' fail to appear in the
picker."""
import asyncio
from backend.apps.agents.agents import list_models
from backend.apps.settings.models import AppSettings, CustomProvider
from unittest.mock import patch
cfg = AppSettings(custom_providers=[
# Complete, should appear.
CustomProvider(
name="Ollama Cloud", base_url="https://ollama.com/v1", api_key="x",
models=[{"value": "gpt-oss:120b", "label": "gpt-oss:120b"}],
),
# Empty base_url, should NOT appear.
CustomProvider(
name="Broken", base_url="", api_key="y",
models=[{"value": "model-a", "label": "model-a"}],
),
# No models, should NOT appear.
CustomProvider(
name="Empty", base_url="https://example.com/v1", api_key="z",
models=[],
),
# Empty name, should NOT appear.
CustomProvider(
name="", base_url="https://example.com/v1", api_key="z",
models=[{"value": "x", "label": "x"}],
),
])
with patch("backend.apps.settings.settings.load_settings", return_value=cfg), \
patch("backend.apps.nine_router.is_running", return_value=False):
result = asyncio.run(list_models())
groups = result["models"]
assert "Ollama Cloud" in groups
assert len(groups["Ollama Cloud"]) == 1
assert groups["Ollama Cloud"][0]["value"] == "custom/ollama-cloud/gpt-oss:120b"
assert groups["Ollama Cloud"][0]["billing_kind"] == "api_key"
# None of the incomplete entries' names create a group.
assert "Broken" not in groups
assert "Empty" not in groups
def test_list_models_custom_provider_model_with_only_value_fills_label():
"""Per the Settings UI we send {value, label} where label = value. But
list_models should be tolerant of a model dict missing 'label' (e.g.
if a power user edits settings.json by hand)."""
import asyncio
from backend.apps.agents.agents import list_models
from backend.apps.settings.models import AppSettings, CustomProvider
from unittest.mock import patch
cfg = AppSettings(custom_providers=[
CustomProvider(
name="Bare", base_url="https://x/v1", api_key="k",
models=[{"value": "model-only-value"}], # no label
),
])
with patch("backend.apps.settings.settings.load_settings", return_value=cfg), \
patch("backend.apps.nine_router.is_running", return_value=False):
result = asyncio.run(list_models())
assert "Bare" in result["models"]
entry = result["models"]["Bare"][0]
assert entry["label"] == "model-only-value"
def test_list_models_custom_provider_id_field_alias_for_value():
"""Test the back-compat: cp.models[].id (alternate key) works
alongside cp.models[].value, since get_context_window uses both."""
import asyncio
from backend.apps.agents.agents import list_models
from backend.apps.settings.models import AppSettings, CustomProvider
from unittest.mock import patch
cfg = AppSettings(custom_providers=[
CustomProvider(
name="IdProvider", base_url="https://x/v1", api_key="k",
models=[{"id": "model-via-id-field", "label": "Model"}],
),
])
with patch("backend.apps.settings.settings.load_settings", return_value=cfg), \
patch("backend.apps.nine_router.is_running", return_value=False):
result = asyncio.run(list_models())
assert "IdProvider" in result["models"]
assert result["models"]["IdProvider"][0]["value"] == "custom/idprovider/model-via-id-field"
def test_custom_provider_context_window_falls_back_to_default():
"""Power users may not specify context_window. Default is 128k."""
from backend.apps.agents.providers.registry import get_context_window
from backend.apps.settings.models import AppSettings, CustomProvider
s = AppSettings(custom_providers=[
CustomProvider(
name="Provider", base_url="https://x/v1", api_key="k",
models=[{"value": "m", "label": "m"}], # no context_window
),
])
cw = get_context_window("Provider", "custom/provider/m", s)
assert cw == 128_000
def test_custom_provider_resolve_aux_model_unaffected():
"""resolve_aux_model is the one-shot LLM call path. Custom providers
are NOT in its decision tree, Haiku/9Router/OR fallbacks should still
fire. Custom providers are deliberately not used for aux because we
don't know if they support tool calling well enough."""
import asyncio
from backend.apps.agents.providers.registry import resolve_aux_model
from backend.apps.settings.models import AppSettings, CustomProvider
s = AppSettings(
anthropic_api_key="sk-ant-test",
custom_providers=[CustomProvider(name="Foo", base_url="https://x/v1", api_key="k")],
)
# Should pick Anthropic Haiku, not anything custom.
rid, base = asyncio.run(resolve_aux_model(s, preferred_tier="haiku"))
assert "haiku" in rid.lower()
assert not rid.startswith("cp-")
def test_custom_provider_with_very_long_name_still_works():
"""No upper bound on name length anywhere in the pipeline. Verify a
250-char name slugs cleanly."""
from backend.apps.agents.providers.registry import custom_provider_slug_for_lookup, find_builtin_model
long_name = "a" * 250
slug = custom_provider_slug_for_lookup(long_name)
assert slug == long_name
entry = find_builtin_model(f"custom/{slug}/some-model")
assert entry is not None
assert entry["model_id"] == f"cp-{slug}/some-model"
# =========================================================================== 9Router sync stress tests, async, mocked HTTP layer ===========================================================================
def p_make_mock_9router(initial_nodes=None, initial_conns=None, fail_endpoints=None):
"""Build a mock httpx.AsyncClient that simulates 9Router's HTTP API.
Tracks state across requests so we can assert idempotency.
Returns (mock_client_class, state_dict), state_dict is mutated by calls."""
from unittest.mock import AsyncMock, MagicMock
state = {
"nodes": list(initial_nodes or []),
"connections": list(initial_conns or []),
"calls": [], # list of (method, url, json) tuples
"next_id": 1,
}
fail = fail_endpoints or set()
def p_resp(status_code=200, payload=None):
r = MagicMock()
r.status_code = status_code
r.text = "" if not payload else str(payload)
r.json = MagicMock(return_value=payload or {})
return r
async def p_get(url, **kw):
state["calls"].append(("GET", url, None))
if "/api/provider-nodes" in url and "GET:provider-nodes" in fail:
return p_resp(500)
if url.endswith("/api/provider-nodes"):
return p_resp(200, {"nodes": state["nodes"]})
if url.endswith("/api/providers"):
return p_resp(200, {"connections": state["connections"]})
return p_resp(404)
async def p_post(url, json=None, **kw):
state["calls"].append(("POST", url, json))
if "/api/provider-nodes" in url and not url.endswith("/provider-nodes/"):
if "POST:provider-nodes" in fail:
return p_resp(500, {"error": "fail"})
node_id = f"openai-compatible-chat-{state['next_id']}"
state["next_id"] += 1
new_node = {**(json or {}), "id": node_id}
state["nodes"].append(new_node)
return p_resp(201, {"node": new_node})
if "/api/providers" in url:
if "POST:providers" in fail:
return p_resp(500, {"error": "fail"})
conn_id = f"conn-{state['next_id']}"
state["next_id"] += 1
new_conn = {**(json or {}), "id": conn_id, "isActive": True}
state["connections"].append(new_conn)
return p_resp(201, {"connection": new_conn})
return p_resp(404)
async def p_put(url, json=None, **kw):
state["calls"].append(("PUT", url, json))
# /api/provider-nodes/<id>
for n in state["nodes"]:
if url.endswith(f"/provider-nodes/{n['id']}"):
n.update(json or {})
return p_resp(200, {"node": n})
return p_resp(404)
async def p_patch(url, json=None, **kw):
state["calls"].append(("PATCH", url, json))
for c in state["connections"]:
if url.endswith(f"/providers/{c['id']}"):
c.update(json or {})
return p_resp(200, {"connection": c})
return p_resp(404)
async def p_delete(url, **kw):
state["calls"].append(("DELETE", url, None))
for n in list(state["nodes"]):
if url.endswith(f"/provider-nodes/{n['id']}"):
state["nodes"].remove(n)
# Cascade-delete connections.
state["connections"] = [
c for c in state["connections"] if c.get("provider") != n["id"]
]
return p_resp(200, {"success": True})
for c in list(state["connections"]):
if url.endswith(f"/providers/{c['id']}"):
state["connections"].remove(c)
return p_resp(200, {"success": True})
return p_resp(404)
class MockClient:
def __init__(self, *a, **kw):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *a):
return False
get = AsyncMock(side_effect=p_get)
post = AsyncMock(side_effect=p_post)
put = AsyncMock(side_effect=p_put)
patch = AsyncMock(side_effect=p_patch)
delete = AsyncMock(side_effect=p_delete)
return MockClient, state
def test_sync_custom_providers_silently_noop_when_9router_down():
"""Most important invariant: app must boot fine without 9Router. The
sync should detect down and return without raising or making any
HTTP calls."""
import asyncio
from unittest.mock import patch as upatch
from backend.apps.nine_router import sync_custom_providers
from backend.apps.settings.models import CustomProvider
with upatch("backend.apps.nine_router.is_running", return_value=False):
# Should not raise even with malformed/empty input.
asyncio.run(sync_custom_providers([]))
asyncio.run(sync_custom_providers([
CustomProvider(name="X", base_url="https://x/v1", api_key="k"),
]))
def test_sync_custom_providers_creates_node_and_connection_for_new_provider():
import asyncio
from unittest.mock import patch as upatch
from backend.apps.nine_router import sync_custom_providers
from backend.apps.settings.models import CustomProvider
MockClient, state = p_make_mock_9router()
with upatch("backend.apps.nine_router.is_running", return_value=True), \
upatch("backend.apps.nine_router.httpx.AsyncClient", MockClient), \
upatch("backend.apps.nine_router.get_providers", new=lambda: p_async_return([])):
asyncio.run(sync_custom_providers([
CustomProvider(name="Ollama Cloud", base_url="https://ollama.com/v1",
api_key="key1", models=[]),
]))
# Should have POSTed exactly one node and one connection.
posts = [c for c in state["calls"] if c[0] == "POST"]
assert len(posts) == 2, f"expected 2 POSTs, got {len(posts)}: {posts}"
node_post = next(c for c in posts if "/provider-nodes" in c[1])
assert node_post[2]["prefix"] == "cp-ollama-cloud"
assert node_post[2]["baseUrl"] == "https://ollama.com/v1"
assert node_post[2]["type"] == "openai-compatible"
assert node_post[2]["apiType"] == "chat"
conn_post = next(c for c in posts if c[1].endswith("/providers"))
assert conn_post[2]["apiKey"] == "key1"
def test_sync_custom_providers_appends_v1_when_baseurl_has_no_path():
"""Ollama prints `http://host:11434` on launch, so users paste it verbatim.
Without /v1 the upstream route is `/chat/completions` (404). Sync must
normalize bare-host URLs to `<host>/v1` so requests land on
`/v1/chat/completions`. URLs that already have a path are left alone."""
import asyncio
from unittest.mock import patch as upatch
from backend.apps.nine_router import sync_custom_providers
from backend.apps.settings.models import CustomProvider
MockClient, state = p_make_mock_9router()
with upatch("backend.apps.nine_router.is_running", return_value=True), \
upatch("backend.apps.nine_router.httpx.AsyncClient", MockClient), \
upatch("backend.apps.nine_router.get_providers", new=lambda: p_async_return([])):
asyncio.run(sync_custom_providers([
CustomProvider(name="Local Ollama", base_url="http://10.0.0.5:11434",
api_key="", models=[]),
CustomProvider(name="Together", base_url="https://api.together.xyz/v1",
api_key="k", models=[]),
]))
posts = [c for c in state["calls"] if c[0] == "POST" and "/provider-nodes" in c[1]]
by_prefix = {p[2]["prefix"]: p[2] for p in posts}
assert by_prefix["cp-local-ollama"]["baseUrl"] == "http://10.0.0.5:11434/v1"
assert by_prefix["cp-together"]["baseUrl"] == "https://api.together.xyz/v1"
def test_sync_custom_providers_updates_existing_node_in_place():
"""Idempotency: a second sync of the same provider should PUT the
existing node, not POST a duplicate."""
import asyncio
from unittest.mock import patch as upatch
from backend.apps.nine_router import sync_custom_providers
from backend.apps.settings.models import CustomProvider
existing_nodes = [
{
"id": "openai-compatible-chat-existing",
"name": "Together AI (OpenSwarm-managed)",
"prefix": "cp-together-ai",
"type": "openai-compatible",
"baseUrl": "https://api.together.xyz/v1",
"apiType": "chat",
},
]
existing_conns = [
{
"id": "conn-existing",
"provider": "openai-compatible-chat-existing",
"name": "Together AI (OpenSwarm-managed)",
"authType": "apikey",
"apiKey": "old-key",
},
]
MockClient, state = p_make_mock_9router(existing_nodes, existing_conns)
with upatch("backend.apps.nine_router.is_running", return_value=True), \
upatch("backend.apps.nine_router.httpx.AsyncClient", MockClient), \
upatch("backend.apps.nine_router.get_providers", new=lambda: p_async_return(existing_conns)):
asyncio.run(sync_custom_providers([
CustomProvider(
name="Together AI",
base_url="https://api.together.xyz/v1", # unchanged URL
api_key="new-key", # changed key
models=[],
),
]))
# Should PUT the node, PATCH the connection. NO new POSTs.
posts = [c for c in state["calls"] if c[0] == "POST"]
puts = [c for c in state["calls"] if c[0] == "PUT"]
patches = [c for c in state["calls"] if c[0] == "PATCH"]
assert posts == [], f"expected no new nodes/conns, got {posts}"
assert len(puts) >= 1, f"expected node PUT, got {puts}"
assert len(patches) >= 1, f"expected conn PATCH, got {patches}"
# And the apiKey should be the new one in the patched payload.
assert patches[0][2]["apiKey"] == "new-key"
def test_sync_custom_providers_deletes_orphaned_managed_nodes():
"""When a user removes a custom provider in Settings, the next sync
should delete the corresponding 9Router node (and its connection
cascades). Other unmanaged nodes must NOT be touched."""
import asyncio
from unittest.mock import patch as upatch
from backend.apps.nine_router import sync_custom_providers
existing_nodes = [
{
"id": "node-orphan",
"name": "OldProvider (OpenSwarm-managed)",
"prefix": "cp-oldprovider",
"type": "openai-compatible",
},
# An UNMANAGED node, should never be deleted.
{
"id": "node-user-created",
"name": "Manual Setup", # no suffix
"prefix": "manual",
"type": "openai-compatible",
},
]
MockClient, state = p_make_mock_9router(existing_nodes, [])
with upatch("backend.apps.nine_router.is_running", return_value=True), \
upatch("backend.apps.nine_router.httpx.AsyncClient", MockClient), \
upatch("backend.apps.nine_router.get_providers", new=lambda: p_async_return([])):
asyncio.run(sync_custom_providers([])) # empty list → delete all managed
deletes = [c for c in state["calls"] if c[0] == "DELETE"]
deleted_urls = [c[1] for c in deletes]
assert any("node-orphan" in u for u in deleted_urls), \
f"orphan should be deleted: {deleted_urls}"
assert not any("node-user-created" in u for u in deleted_urls), \
f"unmanaged nodes must be left alone: {deleted_urls}"
def test_sync_custom_providers_skips_incomplete_entries():
"""Empty name or empty base_url → skip silently. Don't create a
bogus 9Router node from a half-filled form state."""
import asyncio
from unittest.mock import patch as upatch
from backend.apps.nine_router import sync_custom_providers
from backend.apps.settings.models import CustomProvider
MockClient, state = p_make_mock_9router()
with upatch("backend.apps.nine_router.is_running", return_value=True), \
upatch("backend.apps.nine_router.httpx.AsyncClient", MockClient), \
upatch("backend.apps.nine_router.get_providers", new=lambda: p_async_return([])):
asyncio.run(sync_custom_providers([
CustomProvider(name="", base_url="https://x/v1", api_key="k"),
CustomProvider(name="OnlyName", base_url="", api_key="k"),
CustomProvider(name=" ", base_url=" ", api_key="k"),
]))
posts = [c for c in state["calls"] if c[0] == "POST"]
assert posts == [], f"no POSTs should fire for incomplete entries: {posts}"
def test_sync_custom_providers_handles_node_post_failure_without_crashing():
"""If 9Router rejects the node POST (e.g. duplicate prefix), don't
crash the whole sync, log and move on to the next provider."""
import asyncio
from unittest.mock import patch as upatch
from backend.apps.nine_router import sync_custom_providers
from backend.apps.settings.models import CustomProvider
MockClient, state = p_make_mock_9router(fail_endpoints={"POST:provider-nodes"})
with upatch("backend.apps.nine_router.is_running", return_value=True), \
upatch("backend.apps.nine_router.httpx.AsyncClient", MockClient), \
upatch("backend.apps.nine_router.get_providers", new=lambda: p_async_return([])):
# Should NOT raise.
asyncio.run(sync_custom_providers([
CustomProvider(name="A", base_url="https://a/v1", api_key="k1"),
CustomProvider(name="B", base_url="https://b/v1", api_key="k2"),
]))
def test_sync_custom_providers_three_distinct_providers_create_three_nodes():
"""Realistic scenario: user adds Ollama + Together + Groq simultaneously.
All three should land in 9Router with distinct prefixes."""
import asyncio
from unittest.mock import patch as upatch
from backend.apps.nine_router import sync_custom_providers
from backend.apps.settings.models import CustomProvider
MockClient, state = p_make_mock_9router()
with upatch("backend.apps.nine_router.is_running", return_value=True), \
upatch("backend.apps.nine_router.httpx.AsyncClient", MockClient), \
upatch("backend.apps.nine_router.get_providers", new=lambda: p_async_return([])):
asyncio.run(sync_custom_providers([
CustomProvider(name="Ollama Cloud", base_url="https://ollama.com/v1", api_key="k1"),
CustomProvider(name="Together AI", base_url="https://api.together.xyz/v1", api_key="k2"),
CustomProvider(name="Groq", base_url="https://api.groq.com/openai/v1", api_key="k3"),
]))
# Should have POSTed 3 nodes + 3 connections = 6 POSTs.
posts = [c for c in state["calls"] if c[0] == "POST"]
assert len(posts) == 6, f"expected 6 POSTs (3 nodes + 3 conns), got {len(posts)}"
node_posts = [c for c in posts if "/provider-nodes" in c[1] and not c[1].endswith("/providers")]
prefixes = sorted(p[2]["prefix"] for p in node_posts if "prefix" in (p[2] or {}))
assert prefixes == sorted(["cp-ollama-cloud", "cp-together-ai", "cp-groq"]), \
f"prefixes: {prefixes}"
def p_async_return(value):
"""Helper: return a coroutine that resolves to value (for mocking
`get_providers` which is called WITHOUT being awaited as a function)."""
async def p_f():
return value
return p_f()
# =========================================================================== Group T, Mode definitions ===========================================================================
def test_agent_mode_no_explicit_tools():
"""agent mode should leave tools=None so all builtin tools are available."""
from backend.apps.modes.models import BUILTIN_MODES
agent = next(m for m in BUILTIN_MODES if m.id == "agent")
assert agent.tools is None
def test_ask_mode_is_read_only():
"""ask mode must NOT include Bash/Write/Edit."""
from backend.apps.modes.models import BUILTIN_MODES
ask = next(m for m in BUILTIN_MODES if m.id == "ask")
forbidden = {"Bash", "Write", "Edit", "MultiEdit", "StrReplace"}
assert set(ask.tools or []).isdisjoint(forbidden)
def test_plan_mode_is_read_only():
from backend.apps.modes.models import BUILTIN_MODES
plan = next(m for m in BUILTIN_MODES if m.id == "plan")
forbidden = {"Bash", "Write", "Edit", "MultiEdit", "StrReplace"}
assert set(plan.tools or []).isdisjoint(forbidden)
def test_view_builder_mode_has_default_folder():
from backend.apps.modes.models import BUILTIN_MODES
vb = next(m for m in BUILTIN_MODES if m.id == "view-builder")
assert vb.default_folder is not None
# =========================================================================== Group U, Stress: gate handles 100 sequential calls without state leak ===========================================================================
@pytest.mark.asyncio
async def test_gate_100_sequential_calls_no_leak():
from backend.apps.agents.agent_manager import AgentManager
fake_tools = [p_fake_tool(f"Server{i}") for i in range(10)]
with patch("backend.apps.agents.manager.RunSupport.load_all_tools", return_value=fake_tools), \
patch("backend.apps.agents.manager.RunSupport.refresh_google_token", new=AsyncMock(return_value=True)):
mgr = AgentManager()
for i in range(100):
n = i % 10
active = [f"server{j}" for j in range(n)]
allowed = [f"mcp:Server{j}" for j in range(10)]
result = await mgr.build_mcp_servers(allowed_tools=allowed, active_mcps=active)
assert set(result.keys()) == set(active), \
f"iteration {i}: expected {set(active)}, got {set(result.keys())}"
# =========================================================================== Group V, Discord shim entrypoint sanity ===========================================================================
def test_discord_shim_main_callable():
"""The shim must still be invocable via `python -m backend.apps.discord_mcp_shim`."""
from backend.apps.discord_mcp_shim.server import main
assert callable(main)
def test_discord_shim_package_importable():
import backend.apps.discord_mcp_shim
# Empty __init__ now; just confirm the package imports without error
assert backend.apps.discord_mcp_shim is not None
# =========================================================================== Group W, Tools/web.py (live MCP for DDG search) ===========================================================================
def test_web_tools_classes_inherit_basetool():
from backend.apps.agents.tools.web import WebSearchTool, WebFetchTool
from backend.apps.agents.tools.base import BaseTool
assert issubclass(WebSearchTool, BaseTool)
assert issubclass(WebFetchTool, BaseTool)
def test_web_search_tool_has_name_and_schema():
from backend.apps.agents.tools.web import WebSearchTool
tool = WebSearchTool()
assert tool.name
assert isinstance(tool.get_schema(), dict)
def test_web_fetch_tool_has_name_and_schema():
from backend.apps.agents.tools.web import WebFetchTool
tool = WebFetchTool()
assert tool.name
assert isinstance(tool.get_schema(), dict)
# =========================================================================== Group X, ToolGroupMeta + caching ===========================================================================
def test_tool_group_meta_round_trip():
from backend.apps.agents.core.models import ToolGroupMeta, AgentSession
s = AgentSession(id="x", name="t", model="sonnet", mode="agent")
s.tool_group_meta["g1"] = ToolGroupMeta(id="g1", name="Reading files", svg="<svg/>", is_refined=True)
d = s.model_dump(mode="json")
s2 = AgentSession.model_validate(d)
assert "g1" in s2.tool_group_meta
assert s2.tool_group_meta["g1"].is_refined is True
def test_tool_group_meta_default_is_refined_false():
from backend.apps.agents.core.models import ToolGroupMeta
m = ToolGroupMeta(id="g", name="x")
assert m.is_refined is False
# =========================================================================== Group Y, MessageBranch invariants ===========================================================================
def test_session_has_main_branch_by_default():
from backend.apps.agents.core.models import AgentSession
s = AgentSession(id="x", name="t", model="sonnet", mode="agent")
assert "main" in s.branches
assert s.active_branch_id == "main"
def test_branch_serialization():
from backend.apps.agents.core.models import AgentSession, MessageBranch
s = AgentSession(id="x", name="t", model="sonnet", mode="agent")
s.branches["alt"] = MessageBranch(id="alt", parent_branch_id="main", fork_point_message_id="msg-1")
d = s.model_dump(mode="json")
s2 = AgentSession.model_validate(d)
assert "alt" in s2.branches
assert s2.branches["alt"].parent_branch_id == "main"
# =========================================================================== Group Z, End-to-end: realistic session lifecycle ===========================================================================
@pytest.mark.asyncio
async def test_e2e_session_lifecycle_with_mcp_activation():
"""
Walk a session through the realistic flow:
1. Fresh session (active_mcps empty), gate blocks all MCPs
2. MCPActivate('gmail'), set fresh_session, append to active_mcps
3. Continue turn, gate now passes gmail through
4. Persist & re-load, state survives
"""
from backend.apps.agents.agent_manager import AgentManager
from backend.apps.agents.core.models import AgentSession
fake_tools = [p_fake_tool("Gmail"), p_fake_tool("Slack")]
with patch("backend.apps.agents.manager.RunSupport.load_all_tools", return_value=fake_tools), \
patch("backend.apps.agents.manager.RunSupport.refresh_google_token", new=AsyncMock(return_value=True)):
mgr = AgentManager()
s = AgentSession(id="e2e", name="End-to-end", model="sonnet", mode="agent")
# Step 1: fresh, gate blocks everything
result = await mgr.build_mcp_servers(
allowed_tools=["mcp:Gmail", "mcp:Slack"],
active_mcps=s.active_mcps,
)
assert result == {}
# Step 2: simulate MCPActivate
s.active_mcps.append("gmail")
s.sdk_session_id = "claude-existing"
if s.sdk_session_id:
s.needs_fresh_session = True
s.pending_continuation = True
# Step 3: continuation turn, gate passes gmail
result = await mgr.build_mcp_servers(
allowed_tools=["mcp:Gmail", "mcp:Slack"],
active_mcps=s.active_mcps,
)
assert "gmail" in result
assert "slack" not in result
# Step 4: persist + reload
dumped = json.dumps(s.model_dump(mode="json"))
s2 = AgentSession.model_validate(json.loads(dumped))
assert s2.active_mcps == ["gmail"]
assert s2.needs_fresh_session is True
assert s2.pending_continuation is True
@pytest.mark.asyncio
async def test_e2e_50_random_activation_sequences():
"""Stress: 50 random activate/deactivate sequences, gate stays consistent."""
from backend.apps.agents.agent_manager import AgentManager
server_pool = [("Gmail", "gmail"), ("Slack", "slack"), ("Notion", "notion"),
("Discord", "discord"), ("GitHub", "github"), ("Linear", "linear")]
raw_names = [r for r, _ in server_pool]
sanitized = [s for _, s in server_pool]
with patch("backend.apps.agents.manager.RunSupport.load_all_tools",
return_value=[p_fake_tool(r) for r in raw_names]), \
patch("backend.apps.agents.manager.RunSupport.refresh_google_token", new=AsyncMock(return_value=True)):
mgr = AgentManager()
for _ in range(50):
n = random.randint(0, len(sanitized))
active = random.sample(sanitized, n)
allowed = [f"mcp:{r}" for r in raw_names]
result = await mgr.build_mcp_servers(allowed, active)
keys = set(result.keys())
assert keys == set(active), f"mismatch: active={active} keys={keys}"
def test_session_agent_active_ms_default_zero_for_legacy():
"""A session loaded from JSON without `agent_active_ms` deserializes
cleanly with default 0 (not None, not missing-key crash)."""
from backend.apps.agents.core.models import AgentSession
s = AgentSession(name="legacy", model="sonnet", mode="agent")
assert s.agent_active_ms == 0
assert s.time_per_model == {}
def test_session_agent_active_ms_round_trip():
from backend.apps.agents.core.models import AgentSession
s = AgentSession(name="t", model="sonnet", mode="agent",
agent_active_ms=12345, time_per_model={"haiku": 1000, "sonnet": 11345})
d = s.model_dump(mode="json")
s2 = AgentSession(**d)
assert s2.agent_active_ms == 12345
assert s2.time_per_model == {"haiku": 1000, "sonnet": 11345}
def test_session_agent_active_ms_accumulates_via_dict_update():
"""Simulates two turns adding to the bucket, the production accumulator
pattern in agent_manager._on_result."""
from backend.apps.agents.core.models import AgentSession
s = AgentSession(name="t", model="sonnet", mode="agent")
s.agent_active_ms = (s.agent_active_ms or 0) + 1500
s.time_per_model[s.model] = int(s.time_per_model.get(s.model, 0)) + 1500
s.agent_active_ms = (s.agent_active_ms or 0) + 800
s.time_per_model[s.model] = int(s.time_per_model.get(s.model, 0)) + 800
assert s.agent_active_ms == 2300
assert s.time_per_model == {"sonnet": 2300}
def test_session_time_per_model_records_switch():
"""Simulates a model switch mid-session, each model accumulates its
own bucket."""
from backend.apps.agents.core.models import AgentSession
s = AgentSession(name="t", model="haiku", mode="agent")
# Turn 1 on haiku
s.time_per_model[s.model] = int(s.time_per_model.get(s.model, 0)) + 1200
# User switches to sonnet
s.model = "sonnet"
# Turn 2 on sonnet
s.time_per_model[s.model] = int(s.time_per_model.get(s.model, 0)) + 8400
assert s.time_per_model == {"haiku": 1200, "sonnet": 8400}