Files
openswarm/backend/tests/test_agent_manager_unit.py
Arnav NavalandCursor 64dfd50d12 [arnav] remove dead code identified by audit
Each removal verified by checking actual production callers (frontend,
electron, internal HTTP, MCP-server subprocesses) — not just test
references. Symbols whose only callers were tests are removed along
with those tests.

Production removals (~390 LOC):

- backend/main.py
  - websocket_session: drop `agent:edit_message` WS branch. Frontend
    only ever uses HTTP `POST /api/agents/sessions/{id}/edit_message`
    (frontend/src/shared/state/agentsSlice.ts); nothing on the wire
    sends a WS message of this type.

- backend/apps/agents/agent_manager.py
  - AgentManager._build_connected_tools_context (~80 LOC): zero call
    sites in production; the connected-tools system-prompt context is
    built inline in _compose_system_prompt now.
  - AgentManager._approx_tokens / _summarize_message_block: pure
    helpers whose only callers were tests. The compaction path uses
    LLM-driven _maybe_compact instead.

- backend/apps/agents/browser_agent.py
  - clear_browser_history: only used by tests. _browser_history is
    pruned via the size cap inline.
  - MODEL_MAP constant: never read.

- backend/apps/agents/mcp_preflight.py
  - DISCOVERY_SCAFFOLDING (~25-line system-prompt block): defined but
    never appended anywhere. The header comment described an intended
    use that the codebase no longer has.

- backend/apps/agents/providers/registry.py
  - thinking_params_for, _is_9router_available, OPENROUTER_BASE_URL,
    get_context_window: zero callers in production. Thinking-params
    routing is done by the provider classes directly; 9Router presence
    is detected at request time; context-window numbers are stamped
    onto sessions from BUILTIN_MODELS at launch.

- backend/apps/agents/tools/{base,web}.py
  - BaseTool.get_schema (abstract) + WebSearchTool/WebFetchTool
    overrides: production code in backend/apps/web/web.py instantiates
    these tools and only calls .execute(); the JSON-schema lives in
    the HTTP wrapper, not on the tool class.

- backend/apps/outputs/outputs.py
  - _resolve_model + MODEL_MAP: tests-only.
  - load_output: docstring claimed it was a public helper for "other
    modules" but no module imported it.

- backend/apps/service/client.py
  - set_user_id, the _user_id module global, and the dead cache short-
    circuit in _get_user_id: setter was tests-only. _get_user_id now
    reads user_email directly from settings on every call.

- backend/apps/settings/credentials.py
  - get_provider_credentials: zero callers. The sibling get_agent_sdk_env
    is kept (it has the explicit "Legacy helpers" keep-comment).

Test updates:

- test_agent_manager_unit.py: drop _approx_tokens / _summarize_message_block
  cases (5 tests), update module docstring index.
- test_browser_agent_unit.py: drop clear_browser_history cases (2 tests)
  and the unused _Boom helper class in the repr-fallback test.
- test_outputs_unit.py: drop _resolve_model / load_output cases
  (4 tests), update docstring + import list.
- test_v2_invariants.py: drop get_context_window tests + get_schema
  assertions on web tools (kept name + BaseTool inheritance checks).
- test_service.py: rewrite the 4 set_user_id-driven tests to drive
  user_id through settings.user_email instead, so _get_user_id's live
  envelope-stamping path stays covered.

Verification:
- ruff --select F401,F811,F841 backend/  →  clean.
- pytest backend/tests/ → 1167 passed, 1 deselected (pre-existing
  sandbox git test, unrelated). No tests dropped silently — every
  deletion is paired with the corresponding test removal/rewrite.
- Dead-code scan re-run: dead WS events 1→0, Tier-2 high-confidence
  14→11 (residue is SDK-callback `context` params + Pydantic `cls`
  validators — both false positives vulture can't see through),
  vulture total 165→145.

Total diff: -565 / +34 LOC across 15 files.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-06 18:49:30 -05:00

1337 lines
46 KiB
Python

"""Unit tests for `backend.apps.agents.agent_manager`.
Covers the pure-logic helpers + the lifecycle methods on `AgentManager`
that don't require a live Claude Code subprocess. The streaming /
tool-execution path (`_run_agent_loop`) is deliberately out of scope —
it depends on the real CLI and lives in a future integration suite.
Test groups:
- module-level helpers (`_save_session` / `_load_session_data` /
`_load_all_session_data`, error-classifier regex tables,
permission helpers, `_ensure_cwd_git_repo`)
- pure instance methods (`_resolve_mode`, `_compose_system_prompt`,
`_resolve_context_paths`, `_build_dir_tree`, `_resolve_forced_tools`,
`_resolve_attached_skills`, `_get_branch_messages`,
`_build_history_prefix`, `_truncate_large_tool_result`,
`_build_search_text`, `_maybe_compact`)
- lifecycle (launch/update/edit/switch_branch/duplicate/close/
delete/resume/stop, history, browser-agent children, approval,
reconcile, persist+restore)
- cross-provider fork on `send_message`
Tests rely on the conftest fixtures `tmp_data_dirs` (clears
`AgentManager.sessions/tasks` + per-feature data dirs) and where
needed, monkeypatches the SESSIONS_DIR module symbol so a single test
can write directly to a `tmp_path` without leaking into siblings.
"""
from __future__ import annotations
import asyncio
import os
from unittest.mock import MagicMock
import pytest
from backend.apps.agents import agent_manager as am_mod
from backend.apps.agents.agent_manager import (
AgentManager,
FULL_TOOLS,
_delete_session_file,
_ensure_cwd_git_repo,
_get_all_known_tool_names,
_get_denied_tool_names,
_is_auth_error,
_is_fully_denied,
_is_long_context_error,
_is_transient_capacity_error,
_load_all_session_data,
_load_session_data,
_save_session,
get_all_tool_names,
)
from backend.apps.agents.models import (
AgentConfig,
AgentSession,
ApprovalRequest,
Message,
MessageBranch,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _seed_session(
manager: AgentManager,
*,
name: str = "Test",
model: str = "sonnet",
mode: str = "agent",
dashboard_id: str | None = None,
parent_session_id: str | None = None,
messages: list[Message] | None = None,
) -> AgentSession:
"""Insert a synthetic session straight into manager.sessions.
Bypasses launch_agent so we can test the post-launch methods
deterministically without exercising the lifespan / mode store.
"""
session = AgentSession(
name=name,
model=model,
mode=mode,
dashboard_id=dashboard_id,
parent_session_id=parent_session_id,
messages=messages or [],
)
manager.sessions[session.id] = session
return session
# ---------------------------------------------------------------------------
# Module helpers: session file IO
# ---------------------------------------------------------------------------
def test_save_load_session_round_trip(tmp_data_dirs):
payload = {
"id": "abc",
"name": "round",
"model": "sonnet",
"mode": "agent",
"messages": [],
}
_save_session("abc", payload)
loaded = _load_session_data("abc")
assert loaded == payload
def test_load_session_missing_returns_none(tmp_data_dirs):
assert _load_session_data("nope-does-not-exist") is None
def test_delete_session_file_idempotent(tmp_data_dirs):
_save_session("d1", {"id": "d1"})
_delete_session_file("d1")
_delete_session_file("d1") # second call must not raise
assert _load_session_data("d1") is None
def test_load_all_session_data_empty_dir(tmp_path, monkeypatch):
"""When SESSIONS_DIR doesn't exist, must return [] rather than raise."""
monkeypatch.setattr(am_mod, "SESSIONS_DIR", str(tmp_path / "nope"))
assert _load_all_session_data() == []
def test_load_all_session_data_returns_id_and_payload(tmp_data_dirs):
_save_session("a1", {"id": "a1", "name": "A"})
_save_session("b2", {"id": "b2", "name": "B"})
pairs = _load_all_session_data()
by_id = {sid: data for sid, data in pairs}
assert by_id["a1"]["name"] == "A"
assert by_id["b2"]["name"] == "B"
# ---------------------------------------------------------------------------
# Error classifiers
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"msg",
[
"HTTP 429 rate_limit_error",
"HTTP 503 service unavailable",
"HTTP 502 bad gateway",
"Anthropic is overloaded",
"model is at capacity",
"Try again shortly",
"internal server error",
"ECONNRESET on upstream",
"ETIMEDOUT",
"fetch failed",
"upstream connect error",
"No pool capacity available. Try again shortly.",
],
)
def test_is_transient_capacity_error_matches_known_signals(msg: str):
"""Every entry in `_TRANSIENT_CAPACITY_PATTERNS` (+ the explicit
no-pool-capacity check) must classify as transient."""
assert _is_transient_capacity_error(RuntimeError(msg))
@pytest.mark.parametrize(
"msg",
[
"Usage cap exceeded for this billing period",
"You've reached your OpenSwarm Pro plan limit",
"no active subscription",
"subscription canceled",
"subscription past_due",
"Invalid token supplied",
"missing bearer token",
"401 Unauthorized",
"403 Forbidden",
"extra usage is required for long context",
],
)
def test_is_transient_capacity_error_rejects_non_transient(msg: str):
"""`_NON_TRANSIENT_PATTERNS` short-circuits even when the same
message also matches a transient pattern (via the explicit
`_NON_TRANSIENT_PATTERNS.search → return False` early-out)."""
assert not _is_transient_capacity_error(RuntimeError(msg))
def test_is_transient_capacity_error_uses_extra_text():
"""The CLI's ProcessError stringifies generically; the real cause
only shows up via the `extra_text` channel (subprocess stderr).
Both must be classified."""
exc = RuntimeError("Command failed with exit code 1")
assert _is_transient_capacity_error(exc, extra_text="rate_limit_error from upstream")
assert _is_transient_capacity_error(exc, extra_text="Overloaded; please retry")
def test_is_transient_capacity_error_empty_returns_false():
assert not _is_transient_capacity_error(RuntimeError(""))
@pytest.mark.parametrize(
"msg,expected",
[
("extra usage is required for long context", True),
("long context request requires premium tier", True),
("long context not available on this plan", True),
("regular 429 rate limit", False),
("HTTP 503", False),
],
)
def test_is_long_context_error(msg: str, expected: bool):
assert _is_long_context_error(RuntimeError(msg)) is expected
@pytest.mark.parametrize(
"msg,expected",
[
("HTTP 401 Unauthorized", True),
("Invalid authentication credentials", True),
("invalid api-key", True),
("missing bearer token", True),
("403 Forbidden", True),
("no credentials for provider: claude", True),
("provider not configured", True),
("provider not connected", True),
("HTTP 500 server error", False),
("rate_limit_error", False),
],
)
def test_is_auth_error(msg: str, expected: bool):
assert _is_auth_error(RuntimeError(msg)) is expected
def test_is_auth_error_empty_returns_false():
assert not _is_auth_error(RuntimeError(""))
# ---------------------------------------------------------------------------
# Permission helpers + get_all_tool_names
# ---------------------------------------------------------------------------
def _make_tool(perms: dict) -> object:
"""Synthetic ToolDefinition stand-in for permission helpers."""
obj = MagicMock()
obj.tool_permissions = perms
return obj
def test_get_denied_tool_names_filters_underscored_keys():
tool = _make_tool({
"list_files": "deny",
"read_file": "always_allow",
"write_file": "deny",
"_tool_descriptions": {"list_files": "x", "read_file": "y"}, # must be skipped
})
assert _get_denied_tool_names(tool) == {"list_files", "write_file"}
def test_get_all_known_tool_names_reads_descriptions_map():
tool = _make_tool({
"_tool_descriptions": {"a": "d", "b": "d", "c": "d"},
})
assert _get_all_known_tool_names(tool) == {"a", "b", "c"}
def test_is_fully_denied_true_when_every_known_subtool_denied():
tool = _make_tool({
"_tool_descriptions": {"a": "d", "b": "d"},
"a": "deny",
"b": "deny",
})
assert _is_fully_denied(tool) is True
def test_is_fully_denied_false_when_partial_or_unknown():
partial = _make_tool({
"_tool_descriptions": {"a": "d", "b": "d"},
"a": "deny", # b not denied
})
assert _is_fully_denied(partial) is False
no_known = _make_tool({"_tool_descriptions": {}})
assert _is_fully_denied(no_known) is False
def test_get_all_tool_names_returns_full_tools_when_no_perms_set(tmp_data_dirs):
"""With an empty SETTINGS/TOOLS dir (tmp_data_dirs wipes both),
no builtin permissions are set → every entry in FULL_TOOLS is
surfaced. No MCP tools because the tool dir is empty too."""
names = get_all_tool_names()
assert set(FULL_TOOLS).issubset(set(names))
def test_get_all_tool_names_drops_explicitly_denied_builtins(tmp_data_dirs):
"""Writes a builtin-permissions file to mark Bash as denied. The
file lives outside `tmp_data_dirs`'s wipe set, so we restore it
afterwards to avoid leaking state into sibling tests."""
from backend.apps.tools_lib.tools_lib import save_builtin_permissions
from backend.config.paths import BUILTIN_PERMISSIONS_PATH
try:
save_builtin_permissions({"Bash": "deny"})
names = get_all_tool_names()
assert "Bash" not in names
assert "Read" in names # other tools survive
finally:
if os.path.exists(BUILTIN_PERMISSIONS_PATH):
os.remove(BUILTIN_PERMISSIONS_PATH)
# ---------------------------------------------------------------------------
# _ensure_cwd_git_repo
# ---------------------------------------------------------------------------
def test_ensure_cwd_git_repo_creates_repo_when_missing(tmp_path):
"""Fresh tmp dir with no .git → function inits a repo + empty commit."""
cwd = tmp_path / "fresh"
cwd.mkdir()
_ensure_cwd_git_repo(str(cwd), home=str(tmp_path))
# Either .git lives here directly, or git decided we're already in
# a parent repo (the test runner's repo, for instance). Both are
# valid healthy outcomes.
if (cwd / ".git").exists():
# Verify HEAD resolves — the function commits an empty seed.
import subprocess
head = subprocess.run(
["git", "rev-parse", "--verify", "HEAD"],
cwd=str(cwd),
capture_output=True,
)
assert head.returncode == 0
def test_ensure_cwd_git_repo_skips_risky_roots(tmp_path):
"""Calling on $HOME / / / parent-of-home must short-circuit and
leave the directory untouched."""
home = str(tmp_path)
_ensure_cwd_git_repo(home, home=home)
assert not (tmp_path / ".git").exists()
def test_ensure_cwd_git_repo_silent_on_missing_dir(tmp_path):
"""Nonexistent path → silent return, no exception."""
_ensure_cwd_git_repo(str(tmp_path / "nope"), home=str(tmp_path))
# ---------------------------------------------------------------------------
# Pure instance methods
# ---------------------------------------------------------------------------
def test_resolve_mode_unknown_returns_full_tools_and_no_prompt(tmp_data_dirs):
"""No mode file on disk → fallback returns (get_all_tool_names(),
None, None)."""
mgr = AgentManager()
tools, prompt, folder = mgr._resolve_mode("definitely-not-a-mode")
assert prompt is None and folder is None
assert set(FULL_TOOLS).issubset(set(tools))
def test_resolve_mode_known_returns_mode_definition(tmp_data_dirs):
"""`ask` mode (built-in) has a fixed tool list + a system prompt."""
from backend.apps.modes.modes import _save as save_mode
from backend.apps.modes.models import BUILTIN_MODES
ask = next(m for m in BUILTIN_MODES if m.id == "ask")
save_mode(ask)
mgr = AgentManager()
tools, prompt, folder = mgr._resolve_mode("ask")
assert "Read" in tools
assert "Edit" not in tools
assert prompt and "Ask mode" in prompt
assert folder is None
def test_resolve_mode_with_null_tools_returns_get_all_tool_names(tmp_data_dirs):
"""`agent` mode ships with `tools=None`, which means 'all available'."""
from backend.apps.modes.modes import _save as save_mode
from backend.apps.modes.models import BUILTIN_MODES
agent_mode = next(m for m in BUILTIN_MODES if m.id == "agent")
save_mode(agent_mode)
mgr = AgentManager()
tools, _prompt, _folder = mgr._resolve_mode("agent")
assert set(FULL_TOOLS).issubset(set(tools))
def test_compose_system_prompt_joins_truthy_parts():
mgr = AgentManager()
out = mgr._compose_system_prompt(
"default",
"mode",
"session",
connected_tools_ctx="tools",
outputs_ctx="outputs",
browser_ctx="browser",
mcp_registry_ctx="registry",
)
# Order: default, mode, session, tools, registry, outputs, browser
assert out is not None
parts = out.split("\n\n")
assert parts == ["default", "mode", "session", "tools", "registry", "outputs", "browser"]
def test_compose_system_prompt_drops_falsy_parts():
mgr = AgentManager()
out = mgr._compose_system_prompt(None, "", "session", connected_tools_ctx="ctx")
assert out == "session\n\nctx"
def test_compose_system_prompt_all_none_returns_none():
assert AgentManager()._compose_system_prompt(None, None, None) is None
def test_resolve_context_paths_empty_returns_empty():
assert AgentManager()._resolve_context_paths(None) == ""
assert AgentManager()._resolve_context_paths([]) == ""
def test_resolve_context_paths_file_round_trip(tmp_path):
f = tmp_path / "note.txt"
f.write_text("hello world")
out = AgentManager()._resolve_context_paths(
[{"path": str(f), "type": "file"}]
)
assert "<context_file" in out
assert "hello world" in out
def test_resolve_context_paths_directory_uses_dir_tree(tmp_path):
(tmp_path / "a.txt").write_text("A")
(tmp_path / "sub").mkdir()
(tmp_path / "sub" / "b.txt").write_text("B")
out = AgentManager()._resolve_context_paths(
[{"path": str(tmp_path), "type": "directory"}]
)
assert "<context_directory" in out
assert "a.txt" in out
assert "sub/" in out
def test_resolve_context_paths_missing_path_emits_marker(tmp_path):
out = AgentManager()._resolve_context_paths(
[{"path": str(tmp_path / "nope.txt"), "type": "file"}]
)
assert "not found" in out
def test_resolve_context_paths_type_mismatch(tmp_path):
"""Saying type=directory on something that's actually a file → marker."""
f = tmp_path / "real.txt"
f.write_text("x")
out = AgentManager()._resolve_context_paths(
[{"path": str(f), "type": "directory"}]
)
assert "type mismatch" in out
def test_build_dir_tree_lists_files_and_recurses(tmp_path):
(tmp_path / "a.txt").write_text("A")
(tmp_path / "z.txt").write_text("Z") # alphabetical sort guard
nested = tmp_path / "subdir"
nested.mkdir()
(nested / "deep.txt").write_text("D")
lines = AgentManager()._build_dir_tree(str(tmp_path))
# Files come before dirs (per implementation), sorted within each.
assert lines[0] == "a.txt"
assert lines[1] == "z.txt"
assert "subdir/" in lines
# Recursion into subdir prefixes with two spaces
assert any(l.startswith(" ") and "deep.txt" in l for l in lines)
def test_build_dir_tree_respects_max_depth(tmp_path):
(tmp_path / "sub").mkdir()
(tmp_path / "sub" / "nested.txt").write_text("x")
shallow = AgentManager()._build_dir_tree(str(tmp_path), max_depth=1)
# max_depth=1 → don't descend; sub/ is listed but its content isn't
assert "sub/" in shallow
assert all("nested.txt" not in l for l in shallow)
def test_build_dir_tree_skips_dotfiles(tmp_path):
(tmp_path / ".hidden").write_text("h")
(tmp_path / "shown.txt").write_text("s")
lines = AgentManager()._build_dir_tree(str(tmp_path))
assert ".hidden" not in lines
assert "shown.txt" in lines
def test_resolve_forced_tools_empty_returns_empty():
assert AgentManager()._resolve_forced_tools(None) == ""
assert AgentManager()._resolve_forced_tools([]) == ""
def test_resolve_forced_tools_decorates_with_descriptions(tmp_data_dirs):
out = AgentManager()._resolve_forced_tools(["Read", "Bash"])
assert "<forced_tools>" in out and "</forced_tools>" in out
assert "- Read" in out
assert "- Bash" in out
def test_resolve_attached_skills_empty_returns_empty():
assert AgentManager()._resolve_attached_skills(None) == ""
assert AgentManager()._resolve_attached_skills([]) == ""
def test_resolve_attached_skills_emits_block_per_skill():
out = AgentManager()._resolve_attached_skills([
{"name": "Skill A", "content": "do X"},
{"name": "Skill B", "content": "do Y"},
{"name": "Empty", "content": ""}, # silently dropped
])
assert "[Using skill: Skill A]" in out
assert "do X" in out
assert "[Using skill: Skill B]" in out
assert "Empty" not in out
# ---------------------------------------------------------------------------
# _get_branch_messages
# ---------------------------------------------------------------------------
def test_get_branch_messages_main_only():
"""No branches beyond main → returns the main-branch messages."""
s = AgentSession(name="x", model="sonnet")
s.messages = [
Message(role="user", content="hi", branch_id="main"),
Message(role="assistant", content="hello", branch_id="main"),
]
out = AgentManager._get_branch_messages(s)
assert [m.content for m in out] == ["hi", "hello"]
def test_get_branch_messages_walks_fork_lineage():
"""Branch B forks off main at the second user message; switching
active to B should yield main-up-to-fork + B's own messages."""
s = AgentSession(name="x", model="sonnet")
u1 = Message(id="u1", role="user", content="first", branch_id="main")
a1 = Message(id="a1", role="assistant", content="reply1", branch_id="main")
u2 = Message(id="u2", role="user", content="second", branch_id="main")
a2 = Message(id="a2", role="assistant", content="reply2", branch_id="main")
# B forks at u2
bu = Message(id="bu", role="user", content="fork-msg", branch_id="b1")
ba = Message(id="ba", role="assistant", content="fork-reply", branch_id="b1")
s.messages = [u1, a1, u2, a2, bu, ba]
s.branches["b1"] = MessageBranch(id="b1", parent_branch_id="main", fork_point_message_id="u2")
s.active_branch_id = "b1"
out = AgentManager._get_branch_messages(s)
contents = [m.content for m in out]
# main slice (u1, a1) + b1 segment (bu, ba). u2 / a2 must be excluded.
assert "first" in contents and "reply1" in contents
assert "fork-msg" in contents and "fork-reply" in contents
assert "second" not in contents and "reply2" not in contents
# ---------------------------------------------------------------------------
# _build_history_prefix
# ---------------------------------------------------------------------------
def test_build_history_prefix_skips_non_user_assistant_and_hidden():
msgs = [
Message(role="user", content="hi"),
Message(role="tool_call", content={"tool": "Read"}), # skipped
Message(role="assistant", content="hello"),
Message(role="user", content="hidden", hidden=True), # skipped
]
out = AgentManager._build_history_prefix(msgs)
assert "<prior_conversation>" in out
assert "User: hi" in out
assert "Assistant: hello" in out
assert "Read" not in out
assert "hidden" not in out
def test_build_history_prefix_empty_returns_empty():
assert AgentManager._build_history_prefix([]) == ""
# ---------------------------------------------------------------------------
# _truncate_large_tool_result
# ---------------------------------------------------------------------------
def test_truncate_large_tool_result_under_threshold_unchanged(tmp_data_dirs):
content, blob = AgentManager._truncate_large_tool_result(
"small", "sess1", "msg1", max_bytes=100,
)
assert content == "small"
assert blob is None
def test_truncate_large_tool_result_spills_over_threshold(tmp_data_dirs):
"""Content over threshold → first 4K kept inline + saved to disk
under SESSIONS_DIR/<sid>/blobs/<msg>.txt."""
huge = "x" * 80_000
replacement, blob_path = AgentManager._truncate_large_tool_result(
huge, "sessA", "msgA", max_bytes=50_000,
)
assert blob_path is not None
assert os.path.exists(blob_path)
with open(blob_path) as fh:
assert fh.read() == huge
assert isinstance(replacement, str)
assert "[truncated" in replacement
# Inline keeps a 4K head.
assert replacement.startswith("x" * 4_000)
def test_truncate_large_tool_result_serializes_non_string(tmp_data_dirs):
big_dict = {"k": "v" * 40_000}
replacement, blob_path = AgentManager._truncate_large_tool_result(
big_dict, "sessB", "msgB", max_bytes=10_000,
)
assert blob_path is not None
assert "[truncated" in replacement
# ---------------------------------------------------------------------------
# _build_search_text
# ---------------------------------------------------------------------------
def test_build_search_text_concatenates_user_assistant_only():
s = AgentSession(name="My Session", model="sonnet")
s.messages = [
Message(role="user", content="user msg"),
Message(role="assistant", content="asst msg"),
Message(role="tool_call", content={"tool": "Read"}), # skipped (dict content)
Message(role="tool_result", content="result"), # skipped
]
out = AgentManager._build_search_text(s)
assert "My Session" in out
assert "user msg" in out
assert "asst msg" in out
assert "Read" not in out
assert "result" not in out
def test_build_search_text_truncates_to_max_len():
s = AgentSession(name="x", model="sonnet")
s.messages = [Message(role="user", content="a" * 10_000)]
out = AgentManager._build_search_text(s, max_len=200)
assert len(out) == 200
# ---------------------------------------------------------------------------
# _maybe_compact
# ---------------------------------------------------------------------------
def test_maybe_compact_below_threshold_returns_false():
s = AgentSession(name="x", model="sonnet", compact_threshold_pct=0.65, context_window=200_000)
s.tokens["input"] = 1_000 # way under threshold
s.messages = [Message(role="user", content="hi") for _ in range(20)]
assert AgentManager()._maybe_compact(s) is False
def test_maybe_compact_force_with_few_messages_returns_false():
"""Even with force=True, a session with <4 messages can't compact."""
s = AgentSession(name="x", model="sonnet")
s.messages = [Message(role="user", content="hi")]
assert AgentManager()._maybe_compact(s, force=True) is False
def test_maybe_compact_force_advances_compacted_through_id():
s = AgentSession(name="x", model="sonnet")
msgs = [Message(role="user", content=f"m{i}") for i in range(20)]
s.messages = msgs
assert AgentManager()._maybe_compact(s, force=True) is True
# compacted_through_msg_id is the message at len-6 - 1 = 13.
assert s.compacted_through_msg_id == msgs[13].id
def test_maybe_compact_idempotent_when_already_compacted():
s = AgentSession(name="x", model="sonnet")
msgs = [Message(role="user", content=f"m{i}") for i in range(20)]
s.messages = msgs
AgentManager()._maybe_compact(s, force=True)
# Second call with same state and not forced past the saved id → no-op
snapshot = s.compacted_through_msg_id
assert AgentManager()._maybe_compact(s) is False
assert s.compacted_through_msg_id == snapshot
# ---------------------------------------------------------------------------
# _build_prompt_content
# ---------------------------------------------------------------------------
def test_build_prompt_content_no_images_returns_string():
out = AgentManager()._build_prompt_content("hello")
assert out == "hello"
def test_build_prompt_content_with_images_returns_blocks():
out = AgentManager()._build_prompt_content(
"describe this",
images=[{"data": "base64bytes", "media_type": "image/png"}],
)
assert isinstance(out, list)
assert out[0] == {"type": "text", "text": "describe this"}
assert out[1]["type"] == "image"
assert out[1]["source"]["data"] == "base64bytes"
def test_build_prompt_content_combines_context_and_forced(tmp_path):
f = tmp_path / "ctx.txt"
f.write_text("context content")
out = AgentManager()._build_prompt_content(
"real prompt",
context_paths=[{"path": str(f), "type": "file"}],
forced_tools=["Read"],
)
assert "<forced_tools>" in out
assert "<context_file" in out
assert "real prompt" in out
# ---------------------------------------------------------------------------
# Lifecycle: launch_agent
# ---------------------------------------------------------------------------
async def test_launch_agent_round_trips_optional_fields(tmp_data_dirs, tmp_path):
"""Launch with the full optional surface; assert the post-resolve
session has every field we expect (mode-resolved tools land,
target_directory becomes cwd, dashboard_id round-trips)."""
workdir = tmp_path / "work"
workdir.mkdir()
mgr = AgentManager()
cfg = AgentConfig(
name="Full",
model="sonnet",
mode="agent",
system_prompt="be helpful",
target_directory=str(workdir),
dashboard_id="dash-1",
)
session = await mgr.launch_agent(cfg)
assert session.name == "Full"
assert session.model == "sonnet"
assert session.system_prompt == "be helpful"
assert session.cwd == str(workdir)
assert session.dashboard_id == "dash-1"
assert session.allowed_tools # non-empty mode-resolved roster
assert session.id in mgr.sessions
async def test_launch_agent_falls_back_to_scratch_workspace_when_cwd_is_home(tmp_data_dirs, monkeypatch):
"""If the resolved cwd ends up being $HOME (no target_directory,
no default_folder), the launcher reroutes to
~/.openswarm/workspaces/<sid> to avoid writing into $HOME."""
home = os.environ["HOME"]
monkeypatch.setattr("backend.apps.agents.agent_manager._ensure_cwd_git_repo", lambda *a, **kw: None)
mgr = AgentManager()
cfg = AgentConfig(name="HomeFallback", model="sonnet", mode="agent")
session = await mgr.launch_agent(cfg)
assert session.cwd != home
assert session.cwd.startswith(os.path.join(home, ".openswarm", "workspaces"))
# ---------------------------------------------------------------------------
# Lifecycle: update_session
# ---------------------------------------------------------------------------
async def test_update_session_allowlist_updates_only_known_fields(tmp_data_dirs):
mgr = AgentManager()
s = _seed_session(mgr, name="Original", model="sonnet")
await mgr.update_session(
s.id,
name="Renamed",
system_prompt="new sys",
thinking_level="high",
model="ignored-not-allowed",
cost_usd=999.0, # also ignored
)
assert s.name == "Renamed"
assert s.system_prompt == "new sys"
assert s.thinking_level == "high"
assert s.model == "sonnet" # not changed
assert s.cost_usd == 0.0 # not changed
async def test_update_session_rejects_invalid_thinking_level(tmp_data_dirs):
mgr = AgentManager()
s = _seed_session(mgr)
s.thinking_level = "auto"
await mgr.update_session(s.id, thinking_level="extreme")
assert s.thinking_level == "auto"
async def test_update_session_unknown_id_raises(tmp_data_dirs):
with pytest.raises(ValueError):
await AgentManager().update_session("nope", name="x")
# ---------------------------------------------------------------------------
# Lifecycle: switch_branch
# ---------------------------------------------------------------------------
async def test_switch_branch_unknown_raises(tmp_data_dirs):
mgr = AgentManager()
s = _seed_session(mgr)
with pytest.raises(ValueError):
await mgr.switch_branch(s.id, "no-such-branch")
async def test_switch_branch_to_existing_updates_active(tmp_data_dirs):
mgr = AgentManager()
s = _seed_session(mgr)
s.branches["b1"] = MessageBranch(id="b1", parent_branch_id="main", fork_point_message_id="abc")
await mgr.switch_branch(s.id, "b1")
assert s.active_branch_id == "b1"
async def test_switch_branch_unknown_session_raises(tmp_data_dirs):
with pytest.raises(ValueError):
await AgentManager().switch_branch("nope", "main")
# ---------------------------------------------------------------------------
# Lifecycle: edit_message
# ---------------------------------------------------------------------------
async def test_edit_message_unknown_session_raises(tmp_data_dirs):
with pytest.raises(ValueError):
await AgentManager().edit_message("nope", "m1", "x")
async def test_edit_message_non_user_role_raises(tmp_data_dirs, stub_agent_loop):
mgr = AgentManager()
s = _seed_session(mgr)
asst = Message(id="m-asst", role="assistant", content="reply", branch_id="main")
s.messages.append(asst)
with pytest.raises(ValueError):
await mgr.edit_message(s.id, "m-asst", "new content")
async def test_edit_message_creates_new_branch(tmp_data_dirs, stub_agent_loop):
mgr = AgentManager()
s = _seed_session(mgr)
user_msg = Message(id="u1", role="user", content="orig", branch_id="main")
s.messages.append(user_msg)
await mgr.edit_message(s.id, "u1", "edited content")
# New branch created and is active. main remains a key.
assert s.active_branch_id != "main"
new_branch = s.branches[s.active_branch_id]
assert new_branch.parent_branch_id == "main"
assert new_branch.fork_point_message_id == "u1"
# New user message appended on the new branch with the edited content.
edited = next(m for m in s.messages if m.branch_id == s.active_branch_id and m.role == "user")
assert edited.content == "edited content"
async def test_edit_message_on_branched_msg_uses_parent_fork_point(tmp_data_dirs, stub_agent_loop):
"""Editing the FIRST user message of a forked branch should fold
the new branch back to the parent's fork_point_message_id, not
the message we're editing — otherwise re-edits chain forever."""
mgr = AgentManager()
s = _seed_session(mgr)
s.branches["b1"] = MessageBranch(
id="b1", parent_branch_id="main", fork_point_message_id="orig-fork",
)
s.active_branch_id = "b1"
branch_first = Message(id="bf", role="user", content="first on b1", branch_id="b1")
s.messages.append(branch_first)
await mgr.edit_message(s.id, "bf", "new")
new_branch = s.branches[s.active_branch_id]
assert new_branch.parent_branch_id == "main" # not "b1"
assert new_branch.fork_point_message_id == "orig-fork" # parent's, not "bf"
# ---------------------------------------------------------------------------
# Lifecycle: stop_agent / handle_approval
# ---------------------------------------------------------------------------
async def test_stop_agent_sets_status_stopped_and_drains_approvals(tmp_data_dirs):
mgr = AgentManager()
s = _seed_session(mgr)
s.status = "running"
req = ApprovalRequest(session_id=s.id, tool_name="Bash", tool_input={"cmd": "x"})
s.pending_approvals.append(req)
await mgr.stop_agent(s.id)
assert s.status == "stopped"
assert s.pending_approvals == []
assert s.closed_at is not None
async def test_stop_agent_unknown_session_no_op(tmp_data_dirs):
"""No raise, just a no-op."""
await AgentManager().stop_agent("nope")
async def test_stop_agent_cancels_browser_children(tmp_data_dirs):
mgr = AgentManager()
parent = _seed_session(mgr, name="parent")
child = _seed_session(mgr, name="child", mode="browser-agent", parent_session_id=parent.id)
child.status = "running"
await mgr.stop_agent(parent.id)
assert child.status == "stopped"
assert parent.status == "stopped"
def test_handle_approval_resolves_pending_future(tmp_data_dirs):
from backend.apps.agents.ws_manager import ws_manager
async def runner():
loop = asyncio.get_event_loop()
fut = loop.create_future()
ws_manager.pending_futures["req-1"] = fut
AgentManager().handle_approval("req-1", {"behavior": "allow"})
result = await asyncio.wait_for(fut, timeout=1.0)
return result
decision = asyncio.run(runner())
assert decision == {"behavior": "allow"}
def test_handle_approval_unknown_request_id_no_op():
"""Resolving an unknown id must not raise."""
AgentManager().handle_approval("nonexistent-id", {"behavior": "deny"})
# ---------------------------------------------------------------------------
# Lifecycle: close / delete / resume
# ---------------------------------------------------------------------------
async def test_close_session_persists_and_evicts_from_memory(tmp_data_dirs):
mgr = AgentManager()
s = _seed_session(mgr, name="ToClose")
s.messages = [
Message(role="user", content="hello"),
Message(role="assistant", content="hi back"),
]
await mgr.close_session(s.id)
assert s.id not in mgr.sessions
# Persisted to disk with search_text injected
data = _load_session_data(s.id)
assert data is not None
assert data["name"] == "ToClose"
assert "search_text" in data
assert "hello" in data["search_text"]
async def test_close_session_unknown_raises(tmp_data_dirs):
with pytest.raises(ValueError):
await AgentManager().close_session("nope")
async def test_delete_session_removes_memory_and_disk(tmp_data_dirs):
mgr = AgentManager()
s = _seed_session(mgr)
_save_session(s.id, {"id": s.id, "name": s.name, "model": s.model})
await mgr.delete_session(s.id)
assert s.id not in mgr.sessions
assert _load_session_data(s.id) is None
async def test_delete_session_unknown_silent(tmp_data_dirs):
"""Hard-delete is best-effort; deleting an unknown id is a no-op."""
await AgentManager().delete_session("nope") # must not raise
async def test_resume_session_loads_from_disk(tmp_data_dirs):
"""Round-trip: close → resume returns the same session, file is
deleted so it doesn't show up in /history any more."""
mgr = AgentManager()
s = _seed_session(mgr, name="Resume me")
await mgr.close_session(s.id)
restored = await mgr.resume_session(s.id)
assert restored.id == s.id
assert restored.name == "Resume me"
assert restored.closed_at is None
assert _load_session_data(s.id) is None # file gone
async def test_resume_session_already_in_memory_returns_existing(tmp_data_dirs):
mgr = AgentManager()
s = _seed_session(mgr)
out = await mgr.resume_session(s.id)
assert out is s
async def test_resume_session_unknown_raises(tmp_data_dirs):
with pytest.raises(ValueError):
await AgentManager().resume_session("nope")
# ---------------------------------------------------------------------------
# Lifecycle: duplicate_session
# ---------------------------------------------------------------------------
async def test_duplicate_session_clones_messages_and_appends_copy_suffix(tmp_data_dirs):
mgr = AgentManager()
s = _seed_session(mgr, name="Original")
s.messages = [
Message(id="m1", role="user", content="hi"),
Message(id="m2", role="assistant", content="hello"),
]
new = await mgr.duplicate_session(s.id)
assert new.id != s.id
assert new.name == "Original (copy)"
assert new.needs_fork is True
# New ids on each cloned message
new_ids = {m.id for m in new.messages}
assert "m1" not in new_ids and "m2" not in new_ids
# Same content, mapped order
assert [m.content for m in new.messages] == ["hi", "hello"]
async def test_duplicate_session_up_to_message_truncates(tmp_data_dirs):
mgr = AgentManager()
s = _seed_session(mgr)
s.messages = [
Message(id="m1", role="user", content="A"),
Message(id="m2", role="assistant", content="B"),
Message(id="m3", role="user", content="C"),
]
new = await mgr.duplicate_session(s.id, up_to_message_id="m2")
assert [m.content for m in new.messages] == ["A", "B"]
async def test_duplicate_session_unknown_raises(tmp_data_dirs):
with pytest.raises(ValueError):
await AgentManager().duplicate_session("nope")
# ---------------------------------------------------------------------------
# Lifecycle: get_history / get_browser_agent_children
# ---------------------------------------------------------------------------
def test_get_history_paginates_and_filters_by_dashboard(tmp_data_dirs):
"""Seed three closed sessions on disk + one on a different
dashboard. Filter by dashboard_id and assert only matches return."""
for i, dash in enumerate(["A", "A", "B"]):
_save_session(f"sess-{i}", {
"id": f"sess-{i}",
"name": f"name-{i}",
"model": "sonnet",
"mode": "agent",
"status": "stopped",
"closed_at": f"2026-04-29T00:00:0{i}",
"dashboard_id": dash,
"search_text": f"some text-{i}",
"messages": [],
})
history_a = AgentManager().get_history(dashboard_id="A")
assert history_a["total"] == 2
assert all(s["dashboard_id"] == "A" for s in history_a["sessions"])
def test_get_history_search_matches_name_and_search_text(tmp_data_dirs):
_save_session("alpha", {
"id": "alpha", "name": "alpha-name", "model": "sonnet",
"mode": "agent", "status": "stopped", "closed_at": "2026-04-30T00:00:00",
"search_text": "lorem", "messages": [],
})
_save_session("beta", {
"id": "beta", "name": "unrelated", "model": "sonnet",
"mode": "agent", "status": "stopped", "closed_at": "2026-04-30T00:00:01",
"search_text": "ALPHA hidden in body", "messages": [],
})
out = AgentManager().get_history(q="alpha")
ids = {s["id"] for s in out["sessions"]}
assert ids == {"alpha", "beta"}
def test_get_history_pagination_math(tmp_data_dirs):
for i in range(5):
_save_session(f"s{i}", {
"id": f"s{i}", "name": f"n{i}", "model": "sonnet",
"mode": "agent", "status": "stopped",
"closed_at": f"2026-04-30T00:00:0{i}",
"messages": [],
})
page = AgentManager().get_history(limit=2, offset=2)
assert page["total"] == 5
assert len(page["sessions"]) == 2
assert page["has_more"] is True
last = AgentManager().get_history(limit=2, offset=4)
assert len(last["sessions"]) == 1
assert last["has_more"] is False
def test_get_browser_agent_children_combines_memory_and_disk(tmp_data_dirs):
mgr = AgentManager()
parent = _seed_session(mgr, name="parent")
# In-memory child
in_mem = _seed_session(mgr, name="in-mem", mode="browser-agent", parent_session_id=parent.id)
# Disk-only child
_save_session("disk-child", {
"id": "disk-child", "name": "disk", "model": "sonnet",
"mode": "browser-agent", "parent_session_id": parent.id,
"status": "stopped", "messages": [],
})
out = mgr.get_browser_agent_children(parent.id)
ids = {c["id"] for c in out}
assert in_mem.id in ids
assert "disk-child" in ids
def test_get_browser_agent_children_dedupes_by_id(tmp_data_dirs):
"""If the same child is in memory AND on disk, memory wins; the
disk row is dropped to avoid double-listing."""
mgr = AgentManager()
parent = _seed_session(mgr, name="parent")
child = _seed_session(mgr, name="child", mode="browser-agent", parent_session_id=parent.id)
_save_session(child.id, {
"id": child.id, "name": "stale-disk-copy",
"model": "sonnet", "mode": "browser-agent",
"parent_session_id": parent.id, "messages": [],
})
out = mgr.get_browser_agent_children(parent.id)
matching = [c for c in out if c["id"] == child.id]
assert len(matching) == 1
# Memory copy wins
assert matching[0]["name"] == "child"
def test_get_all_sessions_filters_by_dashboard(tmp_data_dirs):
mgr = AgentManager()
a = _seed_session(mgr, dashboard_id="A")
b = _seed_session(mgr, dashboard_id="B")
none = _seed_session(mgr, dashboard_id=None)
all_sessions = mgr.get_all_sessions()
assert {s.id for s in all_sessions} == {a.id, b.id, none.id}
just_a = mgr.get_all_sessions(dashboard_id="A")
assert {s.id for s in just_a} == {a.id}
def test_get_session_returns_none_for_unknown(tmp_data_dirs):
assert AgentManager().get_session("nope") is None
# ---------------------------------------------------------------------------
# Lifecycle: reconcile + persist + restore
# ---------------------------------------------------------------------------
async def test_reconcile_on_startup_flips_stale_running_to_stopped(tmp_data_dirs):
_save_session("s1", {
"id": "s1", "name": "x", "model": "sonnet", "mode": "agent",
"status": "running", "messages": [],
})
await AgentManager().reconcile_on_startup()
data = _load_session_data("s1")
assert data["status"] == "stopped"
async def test_reconcile_on_startup_migrates_chat_to_ask(tmp_data_dirs):
_save_session("s2", {
"id": "s2", "name": "x", "model": "sonnet", "mode": "chat",
"status": "stopped", "messages": [],
})
await AgentManager().reconcile_on_startup()
data = _load_session_data("s2")
assert data["mode"] == "ask"
async def test_reconcile_on_startup_idempotent(tmp_data_dirs):
"""Two consecutive reconciles must not rewrite a stable file."""
_save_session("s3", {
"id": "s3", "name": "x", "model": "sonnet", "mode": "ask",
"status": "stopped", "messages": [],
})
mgr = AgentManager()
await mgr.reconcile_on_startup()
from backend.config.paths import SESSIONS_DIR
path = os.path.join(SESSIONS_DIR, "s3.json")
mtime1 = os.path.getmtime(path)
await mgr.reconcile_on_startup()
assert os.path.getmtime(path) == mtime1
async def test_persist_all_sessions_writes_and_clears(tmp_data_dirs):
mgr = AgentManager()
s = _seed_session(mgr, name="persist me")
s.status = "running"
await mgr.persist_all_sessions()
assert mgr.sessions == {}
assert mgr.tasks == {}
data = _load_session_data(s.id)
assert data is not None
assert data["status"] == "stopped"
assert "search_text" in data
async def test_restore_all_sessions_skips_closed(tmp_data_dirs):
"""closed_at set → keep on disk for /history; closed_at None → restore."""
_save_session("active", {
"id": "active", "name": "alive", "model": "sonnet", "mode": "agent",
"status": "running", "messages": [], "closed_at": None,
})
_save_session("closed", {
"id": "closed", "name": "dead", "model": "sonnet", "mode": "agent",
"status": "stopped", "messages": [], "closed_at": "2026-04-30T00:00:00",
})
mgr = AgentManager()
await mgr.restore_all_sessions()
assert "active" in mgr.sessions
assert "closed" not in mgr.sessions
# The active one's status is normalized stopped+ file removed.
assert mgr.sessions["active"].status == "stopped"
assert _load_session_data("active") is None
assert _load_session_data("closed") is not None
async def test_restore_all_sessions_skips_corrupt_file(tmp_data_dirs, caplog):
"""A corrupt session file must NOT abort restore — log + skip + move on."""
# Garbage payload that AgentSession can't validate
_save_session("good", {
"id": "good", "name": "ok", "model": "sonnet", "mode": "agent",
"status": "stopped", "messages": [],
})
_save_session("bad", {"this": "is not a session"})
mgr = AgentManager()
await mgr.restore_all_sessions()
assert "good" in mgr.sessions
assert "bad" not in mgr.sessions
# ---------------------------------------------------------------------------
# Lifecycle: send_message cross-provider fork
# ---------------------------------------------------------------------------
async def test_send_message_marks_needs_fork_on_cross_provider_switch(tmp_data_dirs, stub_agent_loop):
mgr = AgentManager()
s = _seed_session(mgr, model="sonnet") # api=anthropic
# Switch to gpt-5.4-mini (api=codex) — different api_type → fork.
await mgr.send_message(s.id, "ping", model="gpt-5.4-mini")
assert s.needs_fork is True
assert s.model == "gpt-5.4-mini"
async def test_send_message_same_api_no_fork(tmp_data_dirs, stub_agent_loop):
mgr = AgentManager()
s = _seed_session(mgr, model="sonnet")
s.needs_fork = False
# opus is also anthropic → no fork required.
await mgr.send_message(s.id, "ping", model="opus")
assert s.needs_fork is False
assert s.model == "opus"
async def test_send_message_unknown_session_raises(tmp_data_dirs):
with pytest.raises(ValueError):
await AgentManager().send_message("nope", "hi")
async def test_send_message_skips_when_task_already_running(tmp_data_dirs, stub_agent_loop):
"""If a task for this session is in flight, send_message must
early-return without appending the user message twice."""
mgr = AgentManager()
s = _seed_session(mgr)
# Plant a never-resolving task in the registry.
async def _hang():
await asyncio.Event().wait()
task = asyncio.create_task(_hang())
mgr.tasks[s.id] = task
try:
before = len(s.messages)
await mgr.send_message(s.id, "second prompt")
assert len(s.messages) == before # nothing appended
finally:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass