mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-11 12:17:45 +02:00
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>
571 lines
22 KiB
Python
571 lines
22 KiB
Python
"""Unit tests for `backend.apps.agents.browser_agent` pure helpers.
|
||
|
||
The browser-agent runner is a hefty module, but most of the cleverness is
|
||
in small helpers that can be exercised in isolation:
|
||
|
||
- `_hash_tool_call` / `_detect_loop` — sliding-window loop detector.
|
||
- `_validate_message_pairing` — orphan-`tool_result` guard for cached
|
||
history (the last-line-of-defense against a 400 from Anthropic).
|
||
- `_is_fresh_user_message` / `_summarize_messages` /
|
||
`_trim_history_by_turns` — programmatic compaction of long
|
||
conversations.
|
||
- `_format_tool_result` — translate `ws_manager` result dicts into
|
||
Anthropic content blocks.
|
||
- `execute_browser_tool` — small async + state helper.
|
||
|
||
The integration-level tests for `run_browser_agent` /
|
||
`run_browser_agents` / `_create_browser_card` live in the sister file
|
||
`test_browser_agent_integration.py`. This file deliberately contains NO
|
||
real API calls — it asserts only on pure-Python behaviour.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from unittest.mock import AsyncMock, patch
|
||
|
||
import pytest
|
||
|
||
from backend.apps.agents import browser_agent as ba
|
||
from backend.apps.agents.browser_agent import (
|
||
_LOOP_HARD_CAP,
|
||
_LOOP_REPEAT_THRESHOLD,
|
||
_LOOP_WARNING_TEXT,
|
||
_LOOP_WINDOW_SIZE,
|
||
_detect_loop,
|
||
_format_tool_result,
|
||
_hash_tool_call,
|
||
_is_fresh_user_message,
|
||
_summarize_messages,
|
||
_trim_history_by_turns,
|
||
_validate_message_pairing,
|
||
execute_browser_tool,
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Fixtures
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _clear_browser_history_module_state():
|
||
"""Wipe the module-level conversation cache between tests so a leaked
|
||
entry from a prior test never bleeds into the next."""
|
||
ba._browser_history.clear()
|
||
yield
|
||
ba._browser_history.clear()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# _hash_tool_call
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_hash_tool_call_deterministic_for_same_inputs():
|
||
"""Same (tool, input, result) → same key, every time. This is what
|
||
lets the sliding-window loop detector compare across turns."""
|
||
a = _hash_tool_call("BrowserClick", {"selector": "#x"}, {"text": "ok"})
|
||
b = _hash_tool_call("BrowserClick", {"selector": "#x"}, {"text": "ok"})
|
||
assert a == b
|
||
# Tuple shape: (tool_name, json_input, truncated_json_result)
|
||
assert isinstance(a, tuple) and len(a) == 3
|
||
assert a[0] == "BrowserClick"
|
||
|
||
|
||
def test_hash_tool_call_input_key_uses_sorted_keys():
|
||
"""Dict ordering must NOT change the key — otherwise a model that
|
||
happens to emit input keys in different orders would dodge the
|
||
loop guard."""
|
||
a = _hash_tool_call("BrowserType", {"selector": "#i", "text": "a"}, {})
|
||
b = _hash_tool_call("BrowserType", {"text": "a", "selector": "#i"}, {})
|
||
assert a == b
|
||
|
||
|
||
def test_hash_tool_call_different_results_yield_different_keys():
|
||
"""If the result changes (e.g. BrowserScroll on a long feed), the
|
||
key must change so legitimate progress isn't mistaken for a loop."""
|
||
a = _hash_tool_call("BrowserScroll", {"direction": "down"}, {"text": "scrolled 500px"})
|
||
b = _hash_tool_call("BrowserScroll", {"direction": "down"}, {"text": "scrolled 250px"})
|
||
assert a != b
|
||
|
||
|
||
def test_hash_tool_call_truncates_long_result_to_300():
|
||
"""The result key is sliced at 300 chars to keep huge image blobs
|
||
(BrowserScreenshot returns base64 PNG) out of the in-memory key."""
|
||
big = {"image": "A" * 5000}
|
||
key = _hash_tool_call("BrowserScreenshot", {}, big)
|
||
assert len(key[2]) <= 300
|
||
|
||
|
||
def test_hash_tool_call_falls_back_to_repr_on_serialization_failure():
|
||
"""Self-referential dicts trip json.dumps even with default=str.
|
||
The except branch must still return a (str, str, str) tuple."""
|
||
|
||
bad_input: dict = {"x": 1}
|
||
bad_input["self"] = bad_input
|
||
|
||
bad_result: dict = {}
|
||
bad_result["self"] = bad_result
|
||
|
||
key = _hash_tool_call("BrowserClick", bad_input, bad_result)
|
||
assert isinstance(key, tuple) and len(key) == 3
|
||
assert key[0] == "BrowserClick"
|
||
# Both fallbacks should be strings (repr-derived), not raise.
|
||
assert isinstance(key[1], str) and key[1]
|
||
assert isinstance(key[2], str) and len(key[2]) <= 300
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# _detect_loop
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _key(name: str, suffix: str = "") -> tuple[str, str, str]:
|
||
"""Convenience for building loop-detector keys without having to
|
||
re-derive the JSON input/result every time."""
|
||
return (name, "{}", suffix or "{}")
|
||
|
||
|
||
def test_detect_loop_excluded_tool_never_triggers():
|
||
"""Read-only / idempotent tools (Screenshot, GetText, ReportProgress…)
|
||
are exempt — the model is allowed to call them in tight loops."""
|
||
same = _key("BrowserScreenshot")
|
||
history = [same, same, same, same] # already at threshold
|
||
assert _detect_loop(history, same) is False
|
||
|
||
|
||
def test_detect_loop_third_repeat_triggers():
|
||
"""Threshold is 3 occurrences within the sliding window. Two
|
||
repeats is fine; the third (counting the new call) is the loop."""
|
||
same = _key("BrowserClick")
|
||
different = _key("BrowserType", suffix='{"text":"x"}')
|
||
|
||
# 1st & 2nd occurrence in history; the new call is the 3rd.
|
||
assert _detect_loop([same, same, different], same) is True
|
||
|
||
# Only 1 prior occurrence + the new = 2 total → not yet a loop.
|
||
assert _detect_loop([same], same) is False
|
||
|
||
|
||
def test_detect_loop_two_identical_plus_one_different_in_window_is_false():
|
||
"""Two repeats interleaved with other calls don't add up to a loop."""
|
||
same = _key("BrowserClick")
|
||
other = _key("BrowserScroll", suffix='{"text":"a"}')
|
||
# Window: [same, other, same]. New call=same → 3 total inc. new.
|
||
# Wait — that IS at threshold. Use a lower count instead.
|
||
assert _detect_loop([same, other], same) is False
|
||
|
||
|
||
def test_detect_loop_window_size_caps_lookback():
|
||
"""`_LOOP_WINDOW_SIZE - 1 = 4` past entries are considered, plus the
|
||
new call (= 5 total). Anything older falls outside the window."""
|
||
same = _key("BrowserClick")
|
||
other = _key("BrowserScroll")
|
||
# 5 oldest matches but they're outside the window of 4 prior + 1 new.
|
||
history = [same] * 5 + [other, other, other, other]
|
||
# New call=same. Window = last 4 history + new → [other, other, other, other, same].
|
||
# Only 1 match for `same` → not a loop.
|
||
assert _detect_loop(history, same) is False
|
||
|
||
|
||
def test_detect_loop_constants_match_expectations():
|
||
"""Sanity-check the module constants the integration tests rely on
|
||
so a refactor that loosens the threshold can't silently invalidate
|
||
coverage downstream."""
|
||
assert _LOOP_WINDOW_SIZE == 5
|
||
assert _LOOP_REPEAT_THRESHOLD == 3
|
||
assert _LOOP_HARD_CAP == 5
|
||
assert "LOOP DETECTED" in _LOOP_WARNING_TEXT
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# _validate_message_pairing
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_validate_message_pairing_empty_is_valid():
|
||
assert _validate_message_pairing([]) is True
|
||
|
||
|
||
def test_validate_message_pairing_string_content_skipped():
|
||
"""User messages with plain string content carry no tool_result
|
||
blocks, so they have nothing to validate."""
|
||
msgs = [
|
||
{"role": "user", "content": "hello"},
|
||
{"role": "assistant", "content": "world"},
|
||
]
|
||
assert _validate_message_pairing(msgs) is True
|
||
|
||
|
||
def test_validate_message_pairing_proper_pairing_passes():
|
||
msgs = [
|
||
{"role": "user", "content": "do it"},
|
||
{"role": "assistant", "content": [
|
||
{"type": "text", "text": "thinking"},
|
||
{"type": "tool_use", "id": "t1", "name": "BrowserClick", "input": {}},
|
||
]},
|
||
{"role": "user", "content": [
|
||
{"type": "tool_result", "tool_use_id": "t1", "content": []},
|
||
]},
|
||
]
|
||
assert _validate_message_pairing(msgs) is True
|
||
|
||
|
||
def test_validate_message_pairing_orphan_tool_result_fails():
|
||
"""tool_result whose tool_use_id was never declared by a prior
|
||
assistant tool_use → invalid (Anthropic API would 400)."""
|
||
msgs = [
|
||
{"role": "user", "content": "do it"},
|
||
{"role": "user", "content": [
|
||
{"type": "tool_result", "tool_use_id": "t-orphan", "content": []},
|
||
]},
|
||
]
|
||
assert _validate_message_pairing(msgs) is False
|
||
|
||
|
||
def test_validate_message_pairing_multiple_tool_uses_one_orphan_fails():
|
||
"""Even with several valid pairings, a single orphan must invalidate
|
||
the whole history (we drop the cache wholesale, not partially)."""
|
||
msgs = [
|
||
{"role": "assistant", "content": [
|
||
{"type": "tool_use", "id": "t1", "name": "BrowserClick", "input": {}},
|
||
]},
|
||
{"role": "user", "content": [
|
||
{"type": "tool_result", "tool_use_id": "t1", "content": []},
|
||
{"type": "tool_result", "tool_use_id": "t-missing", "content": []},
|
||
]},
|
||
]
|
||
assert _validate_message_pairing(msgs) is False
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# _is_fresh_user_message
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_is_fresh_user_message_string_content():
|
||
assert _is_fresh_user_message({"role": "user", "content": "hi"}) is True
|
||
|
||
|
||
def test_is_fresh_user_message_text_only_list_is_fresh():
|
||
msg = {"role": "user", "content": [{"type": "text", "text": "hi"}]}
|
||
assert _is_fresh_user_message(msg) is True
|
||
|
||
|
||
def test_is_fresh_user_message_with_tool_result_is_not_fresh():
|
||
"""A user message containing a tool_result is mid-turn — cutting
|
||
here would orphan the prior assistant tool_use."""
|
||
msg = {"role": "user", "content": [
|
||
{"type": "tool_result", "tool_use_id": "t1", "content": []},
|
||
]}
|
||
assert _is_fresh_user_message(msg) is False
|
||
|
||
|
||
def test_is_fresh_user_message_assistant_role_is_never_fresh():
|
||
msg = {"role": "assistant", "content": "world"}
|
||
assert _is_fresh_user_message(msg) is False
|
||
|
||
|
||
def test_is_fresh_user_message_empty_list_is_fresh():
|
||
"""A degenerate empty content list contains no tool_result → still
|
||
a safe cut point under the helper's contract."""
|
||
msg = {"role": "user", "content": []}
|
||
assert _is_fresh_user_message(msg) is True
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# _summarize_messages
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_summarize_messages_empty_returns_empty_string():
|
||
assert _summarize_messages([]) == ""
|
||
|
||
|
||
def test_summarize_messages_extracts_initial_user_task():
|
||
msgs = [
|
||
{"role": "user", "content": "swipe right ten times"},
|
||
{"role": "assistant", "content": [{"type": "text", "text": "ok"}]},
|
||
]
|
||
summary = _summarize_messages(msgs)
|
||
assert 'Original task: "swipe right ten times"' in summary
|
||
assert "Summary of earlier browser-agent activity" in summary
|
||
|
||
|
||
def test_summarize_messages_truncates_initial_task_at_300_chars():
|
||
long_task = "x" * 600
|
||
msgs = [{"role": "user", "content": long_task}]
|
||
summary = _summarize_messages(msgs)
|
||
# The quoted task substring must be at most 300 chars.
|
||
start = summary.index('Original task: "') + len('Original task: "')
|
||
end = summary.index('"', start)
|
||
assert end - start <= 300
|
||
|
||
|
||
def test_summarize_messages_counts_tool_calls_with_key_param():
|
||
"""Tool calls are bucketed by name; key params are extracted in the
|
||
priority order index → key → url → selector → direction → text."""
|
||
msgs = [
|
||
{"role": "user", "content": "do stuff"},
|
||
{"role": "assistant", "content": [
|
||
{"type": "tool_use", "id": "t1", "name": "BrowserClick",
|
||
"input": {"selector": "#a"}},
|
||
{"type": "tool_use", "id": "t2", "name": "BrowserClick",
|
||
"input": {"selector": "#b"}},
|
||
{"type": "tool_use", "id": "t3", "name": "BrowserNavigate",
|
||
"input": {"url": "https://example.com"}},
|
||
]},
|
||
]
|
||
summary = _summarize_messages(msgs)
|
||
assert "Actions taken (3 total):" in summary
|
||
# BrowserClick appears twice → "(×2)" suffix on its bucket line.
|
||
assert "BrowserClick(selector=#b) (×2)" in summary
|
||
assert "BrowserNavigate(url=https://example.com)" in summary
|
||
|
||
|
||
def test_summarize_messages_includes_recent_brain_states():
|
||
"""ReportProgress.next_goal entries surface under 'Recent intents:'
|
||
(last 5 only)."""
|
||
blocks = []
|
||
for i in range(7):
|
||
blocks.append({
|
||
"type": "tool_use", "id": f"r{i}", "name": "ReportProgress",
|
||
"input": {
|
||
"evaluation_previous": "ok",
|
||
"working_memory": "...",
|
||
"next_goal": f"goal-{i}",
|
||
},
|
||
})
|
||
msgs = [{"role": "assistant", "content": blocks}]
|
||
summary = _summarize_messages(msgs)
|
||
assert "Recent intents:" in summary
|
||
# Last 5 of 7 → goal-2..goal-6 included; goal-0/goal-1 dropped.
|
||
for i in (2, 3, 4, 5, 6):
|
||
assert f"goal-{i}" in summary
|
||
assert "goal-0" not in summary
|
||
assert "goal-1" not in summary
|
||
|
||
|
||
def test_summarize_messages_includes_last_assistant_text():
|
||
msgs = [
|
||
{"role": "assistant", "content": [{"type": "text", "text": "first thought"}]},
|
||
{"role": "assistant", "content": [{"type": "text", "text": "final thought"}]},
|
||
]
|
||
summary = _summarize_messages(msgs)
|
||
assert "Last update from assistant: final thought" in summary
|
||
|
||
|
||
def test_summarize_messages_truncates_last_assistant_text_at_400():
|
||
long = "y" * 1000
|
||
msgs = [{"role": "assistant", "content": [{"type": "text", "text": long}]}]
|
||
summary = _summarize_messages(msgs)
|
||
line = next(l for l in summary.split("\n") if l.startswith("Last update"))
|
||
payload = line[len("Last update from assistant: "):]
|
||
assert len(payload) <= 400
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# _trim_history_by_turns
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_trim_history_under_cap_returns_copy_unchanged():
|
||
msgs = [
|
||
{"role": "user", "content": "a"},
|
||
{"role": "assistant", "content": "b"},
|
||
]
|
||
out = _trim_history_by_turns(msgs, max_messages=10)
|
||
assert out == msgs
|
||
# Returns a new list (not the same object) — defensive copy.
|
||
assert out is not msgs
|
||
|
||
|
||
def test_trim_history_compacts_when_clean_cut_exists():
|
||
"""The classic case: long history, but there's a fresh user-text
|
||
message later in the list → compact prefix into a single summary."""
|
||
msgs = [
|
||
{"role": "user", "content": "task one"},
|
||
{"role": "assistant", "content": [
|
||
{"type": "tool_use", "id": "t1", "name": "BrowserClick", "input": {"selector": "#a"}},
|
||
]},
|
||
{"role": "user", "content": [
|
||
{"type": "tool_result", "tool_use_id": "t1", "content": []},
|
||
]},
|
||
{"role": "assistant", "content": [{"type": "text", "text": "done one"}]},
|
||
# Clean boundary: a brand-new user-text message
|
||
{"role": "user", "content": "task two"},
|
||
{"role": "assistant", "content": [{"type": "text", "text": "done two"}]},
|
||
]
|
||
out = _trim_history_by_turns(msgs, max_messages=3)
|
||
# First message is the synthetic summary, then the verbatim tail.
|
||
assert out[0]["role"] == "user"
|
||
assert isinstance(out[0]["content"], str)
|
||
assert "Summary of earlier browser-agent activity" in out[0]["content"]
|
||
assert "task one" in out[0]["content"]
|
||
assert out[1:] == msgs[4:]
|
||
|
||
|
||
def test_trim_history_no_clean_cut_returns_original():
|
||
"""If every user message after [0] is a tool_result (no clean
|
||
boundary anywhere), the helper returns the input unchanged rather
|
||
than corrupting the conversation."""
|
||
msgs = [
|
||
{"role": "user", "content": "start"},
|
||
{"role": "assistant", "content": [
|
||
{"type": "tool_use", "id": "t1", "name": "BrowserClick", "input": {}},
|
||
]},
|
||
{"role": "user", "content": [
|
||
{"type": "tool_result", "tool_use_id": "t1", "content": []},
|
||
]},
|
||
{"role": "assistant", "content": [
|
||
{"type": "tool_use", "id": "t2", "name": "BrowserClick", "input": {}},
|
||
]},
|
||
{"role": "user", "content": [
|
||
{"type": "tool_result", "tool_use_id": "t2", "content": []},
|
||
]},
|
||
]
|
||
out = _trim_history_by_turns(msgs, max_messages=2)
|
||
assert out == msgs
|
||
|
||
|
||
def test_trim_history_falls_back_to_latest_cut_when_current_turn_too_big():
|
||
"""If the only clean cut leaves a tail that still exceeds the cap,
|
||
we still take that latest cut — better some compaction than none."""
|
||
msgs = [
|
||
{"role": "user", "content": "first task"},
|
||
{"role": "assistant", "content": [{"type": "text", "text": "ack"}]},
|
||
# Clean boundary at index 2
|
||
{"role": "user", "content": "second task"},
|
||
# The "current" turn alone is now bigger than the cap.
|
||
{"role": "assistant", "content": [
|
||
{"type": "tool_use", "id": "t1", "name": "BrowserClick", "input": {}},
|
||
]},
|
||
{"role": "user", "content": [
|
||
{"type": "tool_result", "tool_use_id": "t1", "content": []},
|
||
]},
|
||
{"role": "assistant", "content": [
|
||
{"type": "tool_use", "id": "t2", "name": "BrowserClick", "input": {}},
|
||
]},
|
||
{"role": "user", "content": [
|
||
{"type": "tool_result", "tool_use_id": "t2", "content": []},
|
||
]},
|
||
]
|
||
out = _trim_history_by_turns(msgs, max_messages=3)
|
||
# Even though tail (5 msgs) exceeds cap (3), we still compact
|
||
# everything before the latest clean boundary at index 2.
|
||
assert out[0]["role"] == "user" and isinstance(out[0]["content"], str)
|
||
assert "first task" in out[0]["content"]
|
||
assert out[1:] == msgs[2:]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# _format_tool_result
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_format_tool_result_error_returns_text_block():
|
||
out = _format_tool_result({"error": "no browser connected"}, "BrowserClick")
|
||
assert out == [{"type": "text", "text": "Error: no browser connected"}]
|
||
|
||
|
||
def test_format_tool_result_screenshot_with_image_emits_image_and_text():
|
||
"""BrowserScreenshot results carry a base64 PNG; the formatter
|
||
must wrap it in an Anthropic `image` content block."""
|
||
out = _format_tool_result(
|
||
{"image": "AAAA==", "url": "https://example.com"},
|
||
"BrowserScreenshot",
|
||
)
|
||
assert len(out) == 2
|
||
assert out[0]["type"] == "image"
|
||
assert out[0]["source"]["type"] == "base64"
|
||
assert out[0]["source"]["media_type"] == "image/png"
|
||
assert out[0]["source"]["data"] == "AAAA=="
|
||
assert out[1]["type"] == "text"
|
||
assert "https://example.com" in out[1]["text"]
|
||
|
||
|
||
def test_format_tool_result_screenshot_without_image_falls_back_to_text():
|
||
"""If a screenshot tool somehow returns no image, fall through to
|
||
the standard text-block path rather than emitting an empty image."""
|
||
out = _format_tool_result({"text": "no image captured"}, "BrowserScreenshot")
|
||
assert out == [{"type": "text", "text": "no image captured"}]
|
||
|
||
|
||
def test_format_tool_result_text_field_passes_through():
|
||
out = _format_tool_result({"text": "clicked successfully"}, "BrowserClick")
|
||
assert out == [{"type": "text", "text": "clicked successfully"}]
|
||
|
||
|
||
def test_format_tool_result_no_text_falls_back_to_json_dump():
|
||
"""Last-resort path: arbitrary dict → JSON-stringified text block."""
|
||
out = _format_tool_result({"some": "shape", "value": 1}, "BrowserEvaluate")
|
||
assert len(out) == 1
|
||
assert out[0]["type"] == "text"
|
||
# Should be a JSON dump containing both keys.
|
||
payload = json.loads(out[0]["text"])
|
||
assert payload == {"some": "shape", "value": 1}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# execute_browser_tool (async)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
async def test_execute_browser_tool_unknown_tool_returns_error():
|
||
"""Tools not in ACTION_MAP must short-circuit — the frontend has
|
||
no handler for them and would silently drop the command."""
|
||
out = await execute_browser_tool("BrowserNotARealTool", {}, "b-1", "")
|
||
assert out == {"error": "Unknown browser tool: BrowserNotARealTool"}
|
||
|
||
|
||
async def test_execute_browser_tool_dispatches_through_ws_manager():
|
||
"""Known tool → ws_manager.send_browser_command awaited with the
|
||
mapped action, browser_id, params copy, and a tab_id."""
|
||
captured: dict = {}
|
||
|
||
async def _fake_send(request_id, action, browser_id, params, tab_id=""):
|
||
captured.update({
|
||
"request_id": request_id,
|
||
"action": action,
|
||
"browser_id": browser_id,
|
||
"params": params,
|
||
"tab_id": tab_id,
|
||
})
|
||
return {"text": "ok"}
|
||
|
||
with patch.object(ba.ws_manager, "send_browser_command",
|
||
AsyncMock(side_effect=_fake_send)) as mock_send:
|
||
result = await execute_browser_tool(
|
||
"BrowserNavigate", {"url": "https://x.com"}, "b-42", "tab-7",
|
||
)
|
||
assert result == {"text": "ok"}
|
||
mock_send.assert_awaited_once()
|
||
|
||
# Tool name → mapped action via ACTION_MAP
|
||
assert captured["action"] == "navigate"
|
||
assert captured["browser_id"] == "b-42"
|
||
assert captured["tab_id"] == "tab-7"
|
||
assert captured["params"] == {"url": "https://x.com"}
|
||
# request_id is a hex uuid4 → 32-char lowercase hex
|
||
assert isinstance(captured["request_id"], str)
|
||
assert len(captured["request_id"]) == 32
|
||
assert all(c in "0123456789abcdef" for c in captured["request_id"])
|
||
|
||
|
||
async def test_execute_browser_tool_each_action_map_entry_resolves():
|
||
"""Smoke-cover ACTION_MAP: every declared schema tool that's mapped
|
||
must dispatch with its canonical action string."""
|
||
seen: list[str] = []
|
||
|
||
async def _fake_send(request_id, action, browser_id, params, tab_id=""):
|
||
seen.append(action)
|
||
return {"text": "ok"}
|
||
|
||
with patch.object(ba.ws_manager, "send_browser_command",
|
||
AsyncMock(side_effect=_fake_send)):
|
||
for tool_name, action in ba.ACTION_MAP.items():
|
||
seen.clear()
|
||
await execute_browser_tool(tool_name, {}, "b-x")
|
||
assert seen == [action], f"{tool_name} routed to {seen}, expected {[action]}"
|