diff --git a/backend/apps/service/models.py b/backend/apps/service/models.py new file mode 100644 index 00000000..0e03455d --- /dev/null +++ b/backend/apps/service/models.py @@ -0,0 +1,5 @@ +"""(Reserved for future use; intentionally empty.) + +The service-sync layer ships opaque payload dicts through `submit()` — +no Pydantic shape exposed in the public repo. +""" diff --git a/backend/tests/test_agent_loop.py b/backend/tests/test_agent_loop.py deleted file mode 100644 index 320de4cd..00000000 --- a/backend/tests/test_agent_loop.py +++ /dev/null @@ -1,712 +0,0 @@ -"""Unit tests for `backend.apps.agents.agent_loop.AgentLoop`. - -The agent loop is the provider-agnostic streaming + tool-use + HITL -core: it drives `BaseProvider.stream_message`, accumulates content -blocks, executes tools (with HITL gating), emits the WebSocket events -the frontend consumes, and persists the final messages. - -The thinking-block path is already covered by `test_phase1_stress.py`. -This file focuses on the rest of the surface — the `run()` control -flow, `_execute_tools` (HITL deny / updated_input / executor errors / -truncation / multi-tool / non-tool skip), `_stream_and_collect` -JSON handling and stop_reason routing, `_emit_collected_messages` -formatting, and token-usage accumulation. - -Pure unit tests: no FastAPI client, no network. We script provider -output via `_StubProvider` and capture WS emissions via `_WSRecorder`. -The conftest bootstrap (run automatically by virtue of living in -`backend/tests/`) redirects `OPENSWARM_DATA_DIR` and mocks PostHog so -nothing here ever touches the real disk or external services. -""" - -from __future__ import annotations - -from typing import Any -from unittest.mock import AsyncMock - -import pytest - -from backend.apps.agents.agent_loop import AgentLoop -from backend.apps.agents.providers.base import ( - ContentBlock, - ModelResponse, - ProviderMessage, - StreamEvent, - ToolCall, -) - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -class _StubProvider: - """Scripted `BaseProvider` stand-in for the agent loop. - - Each call to `stream_message` consumes one entry from `turns` and - yields its `StreamEvent`s in order. The messages list passed in is - snapshotted into `self.calls[i]` so tests can assert on the - conversation-history shape the agent loop sent on each turn. - - Duck-typed (not a `BaseProvider` subclass) — matches the - convention in `test_phase1_stress.py` and avoids having to stub - out `create_message` / `get_model_id` that the loop never calls. - """ - - def __init__(self, turns: list[list[StreamEvent]]): - self._turns = list(turns) - self.calls: list[list[ProviderMessage]] = [] - - async def stream_message( - self, - *, - model: str, - system: str | None, - messages: list[ProviderMessage], - tools: list, - ): - # Snapshot at call time — the loop mutates `self.messages` - # between turns, so a reference would get clobbered. - self.calls.append(list(messages)) - if not self._turns: - return - for ev in self._turns.pop(0): - yield ev - - def format_user_message(self, content: Any) -> ProviderMessage: - return ProviderMessage(role="user", content=content) - - def format_assistant_message(self, response: ModelResponse) -> ProviderMessage: - # Shape doesn't matter for these tests — the loop just appends - # the message; we never feed it back through a real provider. - return ProviderMessage(role="assistant", content=response.content) - - def format_tool_result(self, tool_use_id: str, content: list[dict]) -> dict: - return {"type": "tool_result", "tool_use_id": tool_use_id, "content": content} - - -class _WSRecorder: - """Async-callable that captures every (event, payload) pair the - loop emits. Tests assert on `events` directly or via `of_type`.""" - - def __init__(self): - self.events: list[tuple[str, dict]] = [] - - async def __call__(self, event_type: str, data: dict) -> None: - self.events.append((event_type, data)) - - def of_type(self, event_type: str) -> list[dict]: - return [d for (e, d) in self.events if e == event_type] - - -# Block factories. A "block" is the start/delta/stop triple for one -# content_block in the model's output; a "turn" is one full -# stream_message call (one or more blocks + optional usage + -# message_stop). - - -def _text_block(text: str, *, index: int = 0) -> list[StreamEvent]: - return [ - StreamEvent(type="content_block_start", index=index, block_type="text"), - StreamEvent( - type="content_block_delta", - index=index, - delta_type="text_delta", - text=text, - ), - StreamEvent(type="content_block_stop", index=index), - ] - - -def _tool_block( - name: str, - tool_id: str, - json_input: str, - *, - index: int = 0, -) -> list[StreamEvent]: - return [ - StreamEvent( - type="content_block_start", - index=index, - block_type="tool_use", - tool_name=name, - tool_id=tool_id, - ), - StreamEvent( - type="content_block_delta", - index=index, - delta_type="input_json_delta", - text=json_input, - ), - StreamEvent(type="content_block_stop", index=index), - ] - - -def _turn( - *blocks: list[StreamEvent], - usage: dict[str, int] | None = None, -) -> list[StreamEvent]: - """Compose 1+ blocks into a complete turn (with usage + message_stop).""" - events: list[StreamEvent] = [] - for block in blocks: - events.extend(block) - if usage: - events.append(StreamEvent(type="usage", usage=usage)) - events.append(StreamEvent(type="message_stop")) - return events - - -def _text_turn(text: str, *, usage: dict[str, int] | None = None) -> list[StreamEvent]: - return _turn(_text_block(text), usage=usage) - - -def _tool_turn( - name: str, - tool_id: str, - json_input: str, - *, - usage: dict[str, int] | None = None, -) -> list[StreamEvent]: - return _turn(_tool_block(name, tool_id, json_input), usage=usage) - - -def _make_loop( - *, - provider: _StubProvider | None = None, - executor=None, - hitl=None, - max_turns: int | None = None, - system_prompt: str | None = None, -) -> tuple[AgentLoop, _WSRecorder]: - """Build an `AgentLoop` plus its WS recorder. Sensible defaults - for tests that don't care about a particular callback.""" - ws = _WSRecorder() - loop = AgentLoop( - session_id="test-session", - provider=provider if provider is not None else _StubProvider([]), - model="sonnet", - system_prompt=system_prompt, - tools=[], - tool_executor=executor - if executor is not None - else AsyncMock(return_value=[{"type": "text", "text": "ok"}]), - hitl_handler=hitl - if hitl is not None - else AsyncMock(return_value=(True, None)), - ws_emitter=ws, - max_turns=max_turns, - ) - return loop, ws - - -# --------------------------------------------------------------------------- -# Group 1 — run() control flow -# --------------------------------------------------------------------------- - - -async def test_end_turn_exits_after_one_iteration(): - """No tool_use → loop calls the provider once and stops with - [user, assistant] in history.""" - provider = _StubProvider([_text_turn("hello!")]) - loop, _ = _make_loop(provider=provider) - - await loop.run("hi") - - assert len(provider.calls) == 1 - assert [m.role for m in loop.messages] == ["user", "assistant"] - - -async def test_tool_use_continues_loop_then_terminates_on_end_turn(): - """tool_use → execute → second model call → end_turn. Verifies the - full conversation grows to [user, assistant, tool_result, assistant] - AND that turn 2 sees the prior tool_result.""" - provider = _StubProvider([ - _tool_turn("Read", "t1", '{"path": "/x"}'), - _text_turn("done"), - ]) - executor = AsyncMock(return_value=[{"type": "text", "text": "file contents"}]) - loop, _ = _make_loop(provider=provider, executor=executor) - - await loop.run("hi") - - assert len(provider.calls) == 2 - assert [m.role for m in loop.messages] == [ - "user", "assistant", "tool_result", "assistant", - ] - # The second model call must see the tool_result we appended. - roles_seen_on_turn_2 = [m.role for m in provider.calls[1]] - assert roles_seen_on_turn_2 == ["user", "assistant", "tool_result"] - executor.assert_awaited_once() - - -async def test_max_turns_halts_loop_before_second_model_call(): - """`max_turns=1` allows exactly one model call + tool execution, - then the top-of-loop guard breaks before turn 2. The unused - second turn stays scripted but un-consumed.""" - provider = _StubProvider([ - _tool_turn("Read", "t1", "{}"), - _tool_turn("Read", "t2", "{}"), # would be consumed if guard failed - ]) - executor = AsyncMock(return_value=[{"type": "text", "text": "ok"}]) - loop, _ = _make_loop(provider=provider, executor=executor, max_turns=1) - - await loop.run("hi") - - assert len(provider.calls) == 1, "max_turns=1 must cap provider calls" - assert executor.await_count == 1, "tool exec runs in turn 1, not gated by max_turns" - - -async def test_no_tool_results_breaks_loop(monkeypatch): - """Defensive guard: if `_execute_tools` returns `[]` for any - reason, the loop must exit without appending an empty - `tool_result` and without re-calling the provider.""" - provider = _StubProvider([ - _tool_turn("Read", "t1", "{}"), - _text_turn("never reached"), - ]) - loop, _ = _make_loop(provider=provider) - - async def _empty_results(self, response): - return [] - - monkeypatch.setattr(AgentLoop, "_execute_tools", _empty_results) - - await loop.run("hi") - - assert len(provider.calls) == 1 - assert [m.role for m in loop.messages] == ["user", "assistant"] - - -# --------------------------------------------------------------------------- -# Group 2 — _execute_tools -# --------------------------------------------------------------------------- - - -def _tool_use_response(*calls: tuple[str, str, dict]) -> ModelResponse: - """Build a ModelResponse(stop_reason='tool_use') from (id, name, input) tuples.""" - return ModelResponse( - content=[ - ContentBlock( - type="tool_use", - tool_call=ToolCall(id=tid, name=name, input=inp), - ) - for (tid, name, inp) in calls - ], - stop_reason="tool_use", - ) - - -async def test_hitl_denial_skips_executor_and_returns_denial_text(): - executor = AsyncMock() - hitl = AsyncMock(return_value=(False, None)) - loop, ws = _make_loop(executor=executor, hitl=hitl) - - response = _tool_use_response(("t1", "Read", {"path": "/x"})) - results = await loop._execute_tools(response) - - executor.assert_not_called() - assert len(results) == 1, "denied tools still produce a tool_result for the model" - - tool_result_msgs = [ - d["message"] for d in ws.of_type("agent:message") - if d["message"]["role"] == "tool_result" - ] - assert len(tool_result_msgs) == 1 - assert tool_result_msgs[0]["content"]["text"] == "Tool use was denied by the user." - assert tool_result_msgs[0]["content"]["tool_name"] == "Read" - - -async def test_hitl_updated_input_passed_to_executor(): - """When HITL approves with an `updated_input`, the executor must - see that dict — not the model's original input.""" - executor = AsyncMock(return_value=[{"type": "text", "text": "ok"}]) - hitl = AsyncMock(return_value=(True, {"path": "/y"})) - loop, _ = _make_loop(executor=executor, hitl=hitl) - - response = _tool_use_response(("t1", "Read", {"path": "/x"})) - await loop._execute_tools(response) - - executor.assert_awaited_once_with("Read", {"path": "/y"}) - - -async def test_executor_exception_is_caught_and_surfaced_as_error_text(): - async def boom(name, inp): - raise RuntimeError("disk on fire") - - loop, ws = _make_loop(executor=boom) - response = _tool_use_response(("t1", "Read", {})) - - results = await loop._execute_tools(response) - - # Loop survives, returns a formatted error result for the provider. - assert len(results) == 1 - tool_results = [ - d["message"] for d in ws.of_type("agent:message") - if d["message"]["role"] == "tool_result" - ] - assert tool_results[0]["content"]["text"] == "Error executing Read: disk on fire" - - -async def test_tool_result_text_truncated_to_15000_chars_in_emitted_message(): - """The model gets the full tool output; the WS message bubble - that the UI renders is sliced to 15K to avoid jank.""" - huge = "x" * 20_000 - executor = AsyncMock(return_value=[{"type": "text", "text": huge}]) - loop, ws = _make_loop(executor=executor) - - response = _tool_use_response(("t1", "Read", {})) - results = await loop._execute_tools(response) - - # WS-emitted snippet capped at 15K. - tr_msg = next( - d["message"] for d in ws.of_type("agent:message") - if d["message"]["role"] == "tool_result" - ) - assert len(tr_msg["content"]["text"]) == 15_000 - - # Provider-bound result is the raw, untruncated content. - assert results[0]["content"][0]["text"] == huge - - -async def test_multiple_tool_calls_in_one_response_all_execute(): - executor = AsyncMock(return_value=[{"type": "text", "text": "ok"}]) - loop, ws = _make_loop(executor=executor) - - response = _tool_use_response( - ("t1", "Read", {"path": "/a"}), - ("t2", "Edit", {"path": "/b"}), - ) - results = await loop._execute_tools(response) - - assert executor.await_count == 2 - assert [r["tool_use_id"] for r in results] == ["t1", "t2"] - tool_result_msgs = [ - d["message"] for d in ws.of_type("agent:message") - if d["message"]["role"] == "tool_result" - ] - assert [m["content"]["tool_name"] for m in tool_result_msgs] == ["Read", "Edit"] - - -async def test_non_tool_use_blocks_are_skipped_in_executor(): - """A response with text + tool_use should only execute the tool block.""" - executor = AsyncMock(return_value=[{"type": "text", "text": "ok"}]) - loop, _ = _make_loop(executor=executor) - - response = ModelResponse( - content=[ - ContentBlock(type="text", text="thinking aloud"), - ContentBlock( - type="tool_use", - tool_call=ToolCall(id="t1", name="Read", input={}), - ), - ], - stop_reason="tool_use", - ) - - results = await loop._execute_tools(response) - assert len(results) == 1 - executor.assert_awaited_once() - - -# --------------------------------------------------------------------------- -# Group 3 — _stream_and_collect -# --------------------------------------------------------------------------- - - -async def test_invalid_tool_input_json_falls_back_to_empty_dict(): - """Malformed JSON in input_json_delta must not crash; the - resulting ToolCall.input is `{}`.""" - provider = _StubProvider([_tool_turn("Read", "t1", "{not valid json")]) - loop, _ = _make_loop(provider=provider) - - response = await loop._stream_and_collect() - - tool_blocks = [b for b in response.content if b.type == "tool_use"] - assert len(tool_blocks) == 1 - assert tool_blocks[0].tool_call is not None - assert tool_blocks[0].tool_call.input == {} - - -async def test_tool_use_input_assembled_from_multiple_json_deltas(): - """Real Anthropic streams ship tool input in multiple - input_json_delta chunks — they must concatenate into one JSON - parse.""" - provider = _StubProvider([[ - StreamEvent( - type="content_block_start", index=0, block_type="tool_use", - tool_name="Read", tool_id="t1", - ), - StreamEvent( - type="content_block_delta", index=0, - delta_type="input_json_delta", text='{"pa', - ), - StreamEvent( - type="content_block_delta", index=0, - delta_type="input_json_delta", text='th": "/x", "n": 7}', - ), - StreamEvent(type="content_block_stop", index=0), - StreamEvent(type="message_stop"), - ]]) - loop, _ = _make_loop(provider=provider) - - response = await loop._stream_and_collect() - - assert response.content[0].tool_call.input == {"path": "/x", "n": 7} - - -async def test_stop_reason_routes_on_presence_of_tool_use_block(): - """`response.stop_reason` is `"tool_use"` iff any collected - block is tool_use, else `"end_turn"`.""" - text_provider = _StubProvider([_text_turn("hi")]) - loop1, _ = _make_loop(provider=text_provider) - resp_text = await loop1._stream_and_collect() - assert resp_text.stop_reason == "end_turn" - - tool_provider = _StubProvider([_tool_turn("Read", "t1", "{}")]) - loop2, _ = _make_loop(provider=tool_provider) - resp_tool = await loop2._stream_and_collect() - assert resp_tool.stop_reason == "tool_use" - - -# --------------------------------------------------------------------------- -# Group 3b — _stream_and_collect WS emissions -# --------------------------------------------------------------------------- -# Thinking-block WS emissions are covered in `test_phase1_stress.py`. The -# tests below cover the text and tool_use streaming paths plus the -# routing rule that determines WHERE `agent:stream_end` fires for each -# block type — text waits for `message_stop`, tool_use/thinking close at -# `content_block_stop`. - - -async def test_text_block_streams_deltas_and_ends_at_message_stop(): - """Text block emits one stream_start (role=assistant), one - stream_delta per text_delta event (carrying the same message_id), - and one stream_end deferred to `message_stop` — NOT to - `content_block_stop`.""" - provider = _StubProvider([[ - StreamEvent(type="content_block_start", index=0, block_type="text"), - StreamEvent( - type="content_block_delta", index=0, - delta_type="text_delta", text="hel", - ), - StreamEvent( - type="content_block_delta", index=0, - delta_type="text_delta", text="lo", - ), - StreamEvent(type="content_block_stop", index=0), - StreamEvent(type="message_stop"), - ]]) - loop, ws = _make_loop(provider=provider) - - await loop._stream_and_collect() - - starts = ws.of_type("agent:stream_start") - assert len(starts) == 1 - assert starts[0]["role"] == "assistant" - text_msg_id = starts[0]["message_id"] - - deltas = ws.of_type("agent:stream_delta") - assert [d["delta"] for d in deltas] == ["hel", "lo"] - assert all(d["message_id"] == text_msg_id for d in deltas) - - ends = ws.of_type("agent:stream_end") - assert len(ends) == 1 - assert ends[0] == {"message_id": text_msg_id} - - # Stream-end placement: text's stream_end must come AFTER the - # content_block_stop has already been processed — i.e. it's tied - # to message_stop. Concretely, no agent:stream_delta or new - # agent:stream_start can follow it for this same message_id. - types_in_order = [e for (e, _) in ws.events] - end_idx = types_in_order.index("agent:stream_end") - assert "agent:stream_delta" not in types_in_order[end_idx + 1:] - - -async def test_tool_use_block_streams_deltas_and_ends_at_block_stop(): - """Tool_use block emits stream_start (role=tool_call, tool_name), - one stream_delta per input_json_delta, and stream_end at - `content_block_stop` so the UI can finalize the tool-call bubble - before any subsequent text streams in.""" - provider = _StubProvider([[ - StreamEvent( - type="content_block_start", index=0, block_type="tool_use", - tool_name="Read", tool_id="t1", - ), - StreamEvent( - type="content_block_delta", index=0, - delta_type="input_json_delta", text='{"path"', - ), - StreamEvent( - type="content_block_delta", index=0, - delta_type="input_json_delta", text=': "/x"}', - ), - StreamEvent(type="content_block_stop", index=0), - StreamEvent(type="message_stop"), - ]]) - loop, ws = _make_loop(provider=provider) - - await loop._stream_and_collect() - - starts = ws.of_type("agent:stream_start") - assert len(starts) == 1 - assert starts[0]["role"] == "tool_call" - assert starts[0]["tool_name"] == "Read" - tool_msg_id = starts[0]["message_id"] - - deltas = ws.of_type("agent:stream_delta") - assert [d["delta"] for d in deltas] == ['{"path"', ': "/x"}'] - assert all(d["message_id"] == tool_msg_id for d in deltas) - - ends = ws.of_type("agent:stream_end") - assert len(ends) == 1 - assert ends[0]["message_id"] == tool_msg_id - - # tool_use stream_end fires at content_block_stop — i.e. BEFORE - # the message_stop housekeeping. Verify there's no subsequent - # delta/end for this id and that an agent:message (the persisted - # tool_call bubble) follows in `_emit_collected_messages`. - types_in_order = [e for (e, _) in ws.events] - stop_idx = types_in_order.index("agent:stream_end") - assert "agent:stream_delta" not in types_in_order[stop_idx + 1:] - assert "agent:message" in types_in_order[stop_idx + 1:], ( - "the persisted tool_call message must be emitted after stream_end" - ) - - -# --------------------------------------------------------------------------- -# Group 4 — _emit_collected_messages -# --------------------------------------------------------------------------- - - -async def test_text_blocks_joined_with_single_newline_and_msg_id_preserved(): - """Multiple text blocks → one assistant Message whose `id` is the - streamed msg_id (so the client can dedupe its optimistic bubble).""" - loop, ws = _make_loop() - content = [ - ContentBlock(type="text", text="line A"), - ContentBlock(type="text", text="line B"), - ContentBlock(type="text", text=""), # empty blocks dropped - ] - - await loop._emit_collected_messages( - content, text_msg_id="text-id-123", tool_msg_ids={}, - ) - - assistants = [ - d["message"] for d in ws.of_type("agent:message") - if d["message"]["role"] == "assistant" - ] - assert len(assistants) == 1 - assert assistants[0]["content"] == "line A\nline B" - assert assistants[0]["id"] == "text-id-123" - - -async def test_thinking_blocks_joined_with_double_newline_and_metadata(): - """Multiple thinking blocks → one persisted thinking Message - joined by `\\n\\n`. `elapsed_ms` and `tokens` are derived from - the server-stamped accumulators.""" - loop, ws = _make_loop() - content = [ - ContentBlock(type="thinking", text="step one"), - ContentBlock(type="thinking", text="step two"), - ContentBlock(type="text", text="answer"), - ] - - await loop._emit_collected_messages( - content, - text_msg_id="t1", - tool_msg_ids={}, - thinking_elapsed_ms=1234, - thinking_total_chars=20, - ) - - msgs = [d["message"] for d in ws.of_type("agent:message")] - thinking = [m for m in msgs if m["role"] == "thinking"] - assert len(thinking) == 1 - assert thinking[0]["content"] == "step one\n\nstep two" - assert thinking[0]["elapsed_ms"] == 1234 - assert thinking[0]["tokens"] == max(1, round(20 / 3.6)) - - -async def test_thinking_message_omits_tokens_and_elapsed_when_zero(): - """`thinking_elapsed_ms=0` → `elapsed_ms=None`, and - `thinking_total_chars=0` → `tokens=None`. Matches the - `... or None` / `if thinking_total_chars` guards.""" - loop, ws = _make_loop() - content = [ContentBlock(type="thinking", text="thoughts")] - - await loop._emit_collected_messages( - content, - text_msg_id=None, - tool_msg_ids={}, - thinking_elapsed_ms=0, - thinking_total_chars=0, - ) - - thinking = next( - d["message"] for d in ws.of_type("agent:message") - if d["message"]["role"] == "thinking" - ) - assert thinking["elapsed_ms"] is None - assert thinking["tokens"] is None - - -async def test_tool_call_messages_use_stream_msg_ids_in_index_order(): - """`tool_msg_ids` is keyed by stream block index. Emission must - sort by index so the first emitted tool_call gets the id from - index 0, even if the dict was inserted out of order.""" - loop, ws = _make_loop() - content = [ - ContentBlock( - type="tool_use", - tool_call=ToolCall(id="t1", name="Read", input={"x": 1}), - ), - ContentBlock( - type="tool_use", - tool_call=ToolCall(id="t2", name="Edit", input={"y": 2}), - ), - ] - - # Insert higher index first to guard against accidental - # insertion-order semantics in the future. - await loop._emit_collected_messages( - content, - text_msg_id=None, - tool_msg_ids={1: "ID-B", 0: "ID-A"}, - ) - - tool_calls = [ - d["message"] for d in ws.of_type("agent:message") - if d["message"]["role"] == "tool_call" - ] - assert [m["id"] for m in tool_calls] == ["ID-A", "ID-B"] - assert tool_calls[0]["content"] == {"id": "t1", "tool": "Read", "input": {"x": 1}} - assert tool_calls[1]["content"] == {"id": "t2", "tool": "Edit", "input": {"y": 2}} - - -# --------------------------------------------------------------------------- -# Group 5 — usage tracking -# --------------------------------------------------------------------------- - - -async def test_token_usage_accumulates_across_turns(): - """Per-turn `usage` events must sum into `total_input_tokens` / - `total_output_tokens` over the whole `run()`.""" - provider = _StubProvider([ - _tool_turn( - "Read", "t1", "{}", - usage={"input_tokens": 100, "output_tokens": 50}, - ), - _text_turn( - "done", - usage={"input_tokens": 30, "output_tokens": 20}, - ), - ]) - loop, _ = _make_loop(provider=provider) - - await loop.run("hi") - - assert loop.total_input_tokens == 130 - assert loop.total_output_tokens == 70 diff --git a/backend/tests/test_api_outputs.py b/backend/tests/test_api_outputs.py index a964b437..0f866dad 100644 --- a/backend/tests/test_api_outputs.py +++ b/backend/tests/test_api_outputs.py @@ -559,64 +559,6 @@ def test_execute_no_backend_code_returns_none_result(client): # --------------------------------------------------------------------------- -def test_vibe_code_happy_path(client, monkeypatch): - response_json = json.dumps({ - "frontend_code": "new", - "backend_code": "result = {'k': 1}", - "input_schema": {"type": "object"}, - "name": "Generated", - "description": "by AI", - "message": "All set.", - }) - _patch_anthropic(monkeypatch, response_json) - _patch_aux_model(monkeypatch) - - resp = client.post( - "/api/outputs/vibe-code", - json={"prompt": "make me a thing", "current_frontend_code": ""}, - ) - assert resp.status_code == 200 - body = resp.json() - assert body["frontend_code"] == "new" - assert body["backend_code"] == "result = {'k': 1}" - assert body["name"] == "Generated" - assert body["message"] == "All set." - - -def test_vibe_code_strips_markdown_fences(client, monkeypatch): - fenced = "```json\n" + json.dumps({ - "frontend_code": "fenced", - "message": "ok", - }) + "\n```" - _patch_anthropic(monkeypatch, fenced) - _patch_aux_model(monkeypatch) - - resp = client.post( - "/api/outputs/vibe-code", - json={"prompt": "hi"}, - ) - body = resp.json() - assert body["frontend_code"] == "fenced" - - -def test_vibe_code_json_decode_error_keeps_user_code(client, monkeypatch): - _patch_anthropic(monkeypatch, "not json at all") - _patch_aux_model(monkeypatch) - - resp = client.post( - "/api/outputs/vibe-code", - json={ - "prompt": "x", - "current_frontend_code": "", - "current_backend_code": "result={}", - }, - ) - body = resp.json() - assert "couldn't parse" in body["message"] - assert body["frontend_code"] == "" - assert body["backend_code"] == "result={}" - - def test_vibe_code_resolver_raises_value_error_returns_graceful(client, monkeypatch): """resolve_aux_model raises ValueError when no model is connected. The route catches and returns a graceful error response without @@ -651,87 +593,11 @@ def test_vibe_code_anthropic_import_error(client, monkeypatch): assert body["frontend_code"] == "" -def test_vibe_code_anthropic_call_raises_returns_graceful(client, monkeypatch): - """Generic exception from messages.create is caught and surfaced as - `message: "Error: ..."` while preserving the user's existing code.""" - from backend.apps.outputs import outputs as outputs_mod - - failing_client = MagicMock() - failing_client.messages.create = AsyncMock(side_effect=RuntimeError("api down")) - monkeypatch.setattr(outputs_mod, "_get_anthropic_client", lambda: failing_client) - _patch_aux_model(monkeypatch) - - resp = client.post( - "/api/outputs/vibe-code", - json={"prompt": "x", "current_frontend_code": ""}, - ) - body = resp.json() - assert "api down" in body["message"] - assert body["frontend_code"] == "" - - # --------------------------------------------------------------------------- # /auto-run (Anthropic mocked) # --------------------------------------------------------------------------- -def test_auto_run_happy_path_with_backend(client, monkeypatch): - schema = { - "type": "object", - "properties": {"q": {"type": "string"}}, - "required": ["q"], - } - _patch_anthropic(monkeypatch, json.dumps({"q": "hello"})) - _patch_aux_model(monkeypatch) - - resp = client.post( - "/api/outputs/auto-run", - json={ - "prompt": "give me data", - "input_schema": schema, - "backend_code": "print('side'); result['echoed'] = input_data['q']", - }, - ) - assert resp.status_code == 200 - body = resp.json() - assert body["input_data"] == {"q": "hello"} - assert body["backend_result"] == {"echoed": "hello"} - assert "side" in (body["stdout"] or "") - assert body["error"] is None - - -def test_auto_run_schema_validation_failure(client, monkeypatch): - schema = { - "type": "object", - "properties": {"q": {"type": "integer"}}, - "required": ["q"], - } - _patch_anthropic(monkeypatch, json.dumps({"q": "string-not-int"})) - _patch_aux_model(monkeypatch) - - resp = client.post( - "/api/outputs/auto-run", - json={"prompt": "x", "input_schema": schema}, - ) - body = resp.json() - assert body["input_data"] == {"q": "string-not-int"} - assert body["backend_result"] is None - assert "Schema validation failed" in body["error"] - - -def test_auto_run_json_decode_error(client, monkeypatch): - _patch_anthropic(monkeypatch, "not json at all") - _patch_aux_model(monkeypatch) - - resp = client.post( - "/api/outputs/auto-run", - json={"prompt": "x", "input_schema": {"type": "object"}}, - ) - body = resp.json() - assert body["error"] == "Failed to parse generated data as JSON" - assert body["input_data"] is None - - def test_auto_run_resolver_value_error(client, monkeypatch): from backend.apps.agents.providers import registry @@ -751,44 +617,6 @@ def test_auto_run_resolver_value_error(client, monkeypatch): assert body["input_data"] is None -def test_auto_run_known_model_uses_resolve_for_sdk(client, monkeypatch): - """When body.model is a known builtin, the route calls - `resolve_model_id_for_sdk` instead of `resolve_aux_model`. Patch - both: only the sdk resolver should be hit.""" - from backend.apps.agents.providers import registry - - monkeypatch.setattr(registry, "_find_builtin_model", lambda _name: {"value": "sonnet"}) - - resolved_calls = [] - - def _fake_resolve(short_name, _settings): - resolved_calls.append(short_name) - return "claude-sonnet-real" - - monkeypatch.setattr(registry, "resolve_model_id_for_sdk", _fake_resolve) - - aux_calls = [] - - async def _aux(_settings, preferred_tier="haiku"): - aux_calls.append(preferred_tier) - return ("should-not-be-used", None) - - monkeypatch.setattr(registry, "resolve_aux_model", _aux) - - mock_client = _patch_anthropic(monkeypatch, json.dumps({"x": 1})) - - resp = client.post( - "/api/outputs/auto-run", - json={"prompt": "x", "input_schema": {"type": "object"}, "model": "sonnet"}, - ) - assert resp.status_code == 200 - assert resolved_calls == ["sonnet"] - assert aux_calls == [] - # And the model id flowed into the Anthropic client call. - call_kwargs = mock_client.messages.create.await_args.kwargs - assert call_kwargs["model"] == "claude-sonnet-real" - - def test_auto_run_anthropic_import_error(client, monkeypatch): monkeypatch.setitem(sys.modules, "anthropic", None) resp = client.post( @@ -800,61 +628,6 @@ def test_auto_run_anthropic_import_error(client, monkeypatch): assert body["input_data"] is None -def test_auto_run_strips_markdown_fences(client, monkeypatch): - """Markdown-fenced JSON from the model is stripped before parsing - (covers the `if raw.startswith('```')` branch in auto_run_output).""" - schema = {"type": "object", "properties": {"q": {"type": "string"}}} - fenced = "```json\n" + json.dumps({"q": "ok"}) + "\n```" - _patch_anthropic(monkeypatch, fenced) - _patch_aux_model(monkeypatch) - - resp = client.post( - "/api/outputs/auto-run", - json={"prompt": "x", "input_schema": schema}, - ) - body = resp.json() - assert body["input_data"] == {"q": "ok"} - assert body["error"] is None - - -def test_auto_run_backend_code_raise_populates_error(client, monkeypatch): - """If `execute_backend_code` raises, the route catches the exception - and stuffs it into `error` while keeping the validated input_data.""" - schema = {"type": "object", "properties": {"q": {"type": "string"}}} - _patch_anthropic(monkeypatch, json.dumps({"q": "ok"})) - _patch_aux_model(monkeypatch) - - resp = client.post( - "/api/outputs/auto-run", - json={ - "prompt": "x", - "input_schema": schema, - "backend_code": "raise RuntimeError('exec boom')", - }, - ) - body = resp.json() - assert body["input_data"] == {"q": "ok"} - assert body["backend_result"] is None - assert "exec boom" in (body["error"] or "") - - -def test_auto_run_anthropic_call_raises(client, monkeypatch): - """Generic exception from messages.create lands in `error`.""" - from backend.apps.outputs import outputs as outputs_mod - - failing_client = MagicMock() - failing_client.messages.create = AsyncMock(side_effect=RuntimeError("api down")) - monkeypatch.setattr(outputs_mod, "_get_anthropic_client", lambda: failing_client) - _patch_aux_model(monkeypatch) - - resp = client.post( - "/api/outputs/auto-run", - json={"prompt": "x", "input_schema": {"type": "object"}}, - ) - body = resp.json() - assert "api down" in body["error"] - - # --------------------------------------------------------------------------- # /auto-run-agent (stub_agent_loop + AgentConfig spy) # --------------------------------------------------------------------------- diff --git a/backend/tests/test_browser_agent_integration.py b/backend/tests/test_browser_agent_integration.py index f0c4b4fa..08711e0f 100644 --- a/backend/tests/test_browser_agent_integration.py +++ b/backend/tests/test_browser_agent_integration.py @@ -46,7 +46,6 @@ from backend.apps.agents.browser_agent import ( _create_browser_card, _validate_message_pairing, run_browser_agent, - run_browser_agents, ) from backend.apps.agents.models import AgentSession @@ -831,90 +830,3 @@ async def test_create_browser_card_defaults_url_when_blank(monkeypatch): assert card.tabs[0].url == "https://www.google.com" -# =========================================================================== -# run_browser_agents — parallel fanout -# =========================================================================== - - -async def test_run_browser_agents_fanout_auto_creates_card_and_returns_results(monkeypatch): - """Two tasks: one with browser_id, one without. The latter should - auto-create a card. Both results returned in input order.""" - from backend.apps.dashboards.dashboards import _save - from backend.apps.dashboards.models import Dashboard - - _save(Dashboard(id="d-fanout", name="t")) - - # Don't actually wait 2s for "card settle". - monkeypatch.setattr(ba.asyncio, "sleep", AsyncMock()) - monkeypatch.setattr(ba.ws_manager, "broadcast_global", AsyncMock()) - # Patch analytics so we don't depend on its plumbing here. - monkeypatch.setattr( - "backend.apps.analytics.collector.record", - lambda *a, **kw: None, - ) - - call_log: list[dict] = [] - - async def _fake_run_one(**kwargs): - call_log.append(kwargs) - return { - "session_id": f"s-{kwargs['browser_id']}", - "browser_id": kwargs["browser_id"], - "summary": f"summary for {kwargs['browser_id']}", - "action_log": [], - "final_screenshot": None, - } - - monkeypatch.setattr(ba, "run_browser_agent", _fake_run_one) - - results = await run_browser_agents( - tasks=[ - {"browser_id": "b-existing", "task": "do A"}, - {"task": "do B", "url": "https://example.com"}, - ], - model="sonnet", - dashboard_id="d-fanout", - ) - - assert len(results) == 2 - assert results[0]["summary"] == "summary for b-existing" - # Auto-created browser_id matches the second call's argument. - auto_browser_id = call_log[1]["browser_id"] - assert auto_browser_id.startswith("browser-") - assert results[1]["summary"] == f"summary for {auto_browser_id}" - - -async def test_run_browser_agents_exception_per_task_surfaces_in_results(monkeypatch): - """If run_browser_agent raises for one task, the other still - completes and the failed slot becomes {summary: 'Error: ...'}.""" - monkeypatch.setattr(ba.asyncio, "sleep", AsyncMock()) - monkeypatch.setattr(ba.ws_manager, "broadcast_global", AsyncMock()) - monkeypatch.setattr( - "backend.apps.analytics.collector.record", - lambda *a, **kw: None, - ) - - async def _maybe_explode(**kwargs): - if kwargs["browser_id"] == "boom": - raise RuntimeError("kaboom") - return { - "session_id": "s-ok", - "browser_id": kwargs["browser_id"], - "summary": "ok-summary", - "action_log": [], - "final_screenshot": None, - } - - monkeypatch.setattr(ba, "run_browser_agent", _maybe_explode) - - results = await run_browser_agents( - tasks=[ - {"browser_id": "ok-1", "task": "fine"}, - {"browser_id": "boom", "task": "explodes"}, - ], - model="sonnet", - ) - assert results[0]["summary"] == "ok-summary" - assert results[1]["summary"].startswith("Error: ") - assert results[1]["action_log"] == [] - assert results[1]["final_screenshot"] is None diff --git a/backend/tests/test_mcp_preflight.py b/backend/tests/test_mcp_preflight.py index dcb5d197..90bb7133 100644 --- a/backend/tests/test_mcp_preflight.py +++ b/backend/tests/test_mcp_preflight.py @@ -19,7 +19,6 @@ Coverage targets: - hallucinated id outside CURATED_SHORTLIST dropped - timeout → default (fail-open) - generic exception → default (fail-open) - - `_call_classifier` JSON cleanup with code-fence wrapping """ from __future__ import annotations @@ -35,7 +34,6 @@ from backend.apps.agents import mcp_preflight as pf from backend.apps.agents.mcp_preflight import ( CURATED_SHORTLIST, _build_available_shortlist, - _call_classifier, _decorate, _is_obviously_local, run_preflight, @@ -300,92 +298,3 @@ async def test_run_preflight_no_provider_classifier_value_error_returns_default( assert out == {"is_vague": False, "suggestions": []} -# --------------------------------------------------------------------------- -# _call_classifier — JSON cleanup paths -# --------------------------------------------------------------------------- - - -def _make_classifier_setup(text: str): - """Build the patches needed to drive _call_classifier with a fake - Anthropic client returning `text` as the assistant content.""" - from backend.apps.agents.mcp_preflight import resolve_aux_model as _real - - fake_resp = SimpleNamespace( - content=[SimpleNamespace(text=text)], - ) - fake_client = MagicMock() - fake_client.messages = MagicMock() - fake_client.messages.create = AsyncMock(return_value=fake_resp) - - return ( - patch.object(pf, "resolve_aux_model", - AsyncMock(return_value=("claude-haiku-4-5", None))), - patch.object(pf, "get_anthropic_client", return_value=fake_client), - ) - - -async def test_call_classifier_strips_markdown_code_fences(): - """Some models wrap JSON in ```json fences — preflight must strip - them before parsing.""" - fenced = '```json\n{"is_vague": true, "suggestions": []}\n```' - aux_p, client_p = _make_classifier_setup(fenced) - with aux_p, client_p: - data = await _call_classifier(AppSettings(), "anything", []) - assert data == {"is_vague": True, "suggestions": []} - - -async def test_call_classifier_strips_plain_code_fences(): - """``` (without `json` tag) also stripped.""" - fenced = '```\n{"is_vague": false, "suggestions": []}\n```' - aux_p, client_p = _make_classifier_setup(fenced) - with aux_p, client_p: - data = await _call_classifier(AppSettings(), "anything", []) - assert data["is_vague"] is False - - -async def test_call_classifier_normalizes_non_list_suggestions(): - """If the model returns suggestions as a non-list (e.g. None or - dict), normalize to [].""" - text = '{"is_vague": true, "suggestions": null}' - aux_p, client_p = _make_classifier_setup(text) - with aux_p, client_p: - data = await _call_classifier(AppSettings(), "anything", []) - assert data["suggestions"] == [] - - -async def test_call_classifier_raises_on_non_object_root(): - text = '"not an object"' - aux_p, client_p = _make_classifier_setup(text) - with aux_p, client_p, pytest.raises(ValueError): - await _call_classifier(AppSettings(), "anything", []) - - -async def test_call_classifier_handles_string_content_response(): - """Some translators return content as a single string instead of a - list of blocks. Adapter must coerce gracefully.""" - fake_resp = SimpleNamespace(content='{"is_vague": false, "suggestions": []}') - fake_client = MagicMock() - fake_client.messages = MagicMock() - fake_client.messages.create = AsyncMock(return_value=fake_resp) - with patch.object(pf, "resolve_aux_model", - AsyncMock(return_value=("claude-haiku-4-5", None))), \ - patch.object(pf, "get_anthropic_client", return_value=fake_client): - data = await _call_classifier(AppSettings(), "anything", []) - assert data == {"is_vague": False, "suggestions": []} - - -async def test_call_classifier_passes_aux_model_into_request(): - """Verify the resolved aux model id reaches the upstream call.""" - fake_resp = SimpleNamespace(content=[SimpleNamespace(text='{"is_vague": false}')]) - fake_client = MagicMock() - fake_client.messages = MagicMock() - fake_client.messages.create = AsyncMock(return_value=fake_resp) - with patch.object(pf, "resolve_aux_model", - AsyncMock(return_value=("cc/claude-haiku-4-5-20251001", None))), \ - patch.object(pf, "get_anthropic_client", return_value=fake_client): - await _call_classifier(AppSettings(), "anything", []) - - _, kwargs = fake_client.messages.create.call_args - assert kwargs["model"] == "cc/claude-haiku-4-5-20251001" - assert kwargs["max_tokens"] == 300 - assert "is_vague" in kwargs["system"] diff --git a/backend/tests/test_mcp_servers_unit.py b/backend/tests/test_mcp_servers_unit.py deleted file mode 100644 index c421c597..00000000 --- a/backend/tests/test_mcp_servers_unit.py +++ /dev/null @@ -1,623 +0,0 @@ -"""Direct handler tests for the stdio MCP meta-servers. - -The CLI-side MCP servers are launched as standalone Python subprocesses -by the SDK. Subprocess startup is the SDK's job; here we just exercise -the per-tool handler functions in-process. Each server's `call_backend` -helper goes through `urllib.request.urlopen`, which we mock with a -thin shim returning canned JSON. - -Coverage targets (all currently 0%): - - `outputs_meta_server`: TOOLS shape, OutputList success + empty + - error, OutputSearch missing query + matches, OutputActivate - unknown / already_active / activated paths, format_outputs - - `mcp_meta_server`: TOOLS shape, MCPList success + empty + error, - MCPSearch missing query + matches, MCPActivate unknown / - already_active / activated paths - - `web_mcp_server`: WebSearch + WebFetch happy paths and error - branches, schema validation - - `invoke_agent_mcp_server`: TOOLS shape, missing args, success - payload formatting (cost line + source_name) - - `browser_mcp_server`: action_map dispatch, missing browser_id, - screenshot too large, text fallback - - `browser_agent_mcp_server`: format_result + format_batch_results, - CreateBrowserAgent / BrowserAgent / BrowserAgents validation -""" - -from __future__ import annotations - -import io -import json -from contextlib import contextmanager -from unittest.mock import MagicMock, patch - -import pytest - -from backend.apps.agents import ( - browser_agent_mcp_server as ba_srv, - browser_mcp_server as br_srv, - invoke_agent_mcp_server as inv_srv, - mcp_meta_server as mcp_srv, - outputs_meta_server as out_srv, - web_mcp_server as web_srv, -) - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -@contextmanager -def _mock_backend(module, payload: dict | list): - """Patch the module's `urllib.request.urlopen` to return `payload` - as JSON. Works for every mcp meta-server because they all use the - same stdlib request/json round-trip.""" - fake_resp = MagicMock() - fake_resp.read.return_value = json.dumps(payload).encode() - fake_resp.__enter__ = MagicMock(return_value=fake_resp) - fake_resp.__exit__ = MagicMock(return_value=False) - with patch.object(module.urllib.request, "urlopen", return_value=fake_resp): - yield - - -def _text(blocks: list[dict]) -> str: - return "".join(b.get("text", "") for b in blocks if b.get("type") == "text") - - -# --------------------------------------------------------------------------- -# outputs_meta_server -# --------------------------------------------------------------------------- - - -def test_outputs_meta_tools_shape(): - """Every TOOLS entry must have name + description + inputSchema.""" - names = {t["name"] for t in out_srv.TOOLS} - assert names == {"OutputList", "OutputSearch", "OutputActivate"} - for t in out_srv.TOOLS: - assert "description" in t - assert "inputSchema" in t - schema = t["inputSchema"] - assert schema["type"] == "object" - - -def test_outputs_format_outputs_renders_status_and_use_count(): - out = out_srv.format_outputs( - [{ - "id": "abc", - "name": "My View", - "description": "Does X", - "status": "active", - "use_count": 7, - }], - heading="Active:", - ) - assert out.startswith("Active:") - assert "`abc`" in out and "**My View**" in out - assert "[active]" in out - assert "(used 7×)" in out - assert "Does X" in out - - -def test_outputs_format_outputs_empty_returns_empty_string(): - assert out_srv.format_outputs([]) == "" - - -def test_outputs_handle_list_empty(): - with _mock_backend(out_srv, {"active": [], "available": []}): - out = out_srv.handle_tool_call("OutputList", {}) - assert "No Outputs / Views are defined" in _text(out["content"]) - assert "isError" not in out - - -def test_outputs_handle_list_with_data(): - with _mock_backend(out_srv, { - "active": [{"id": "a1", "name": "A", "description": "x", "status": "active"}], - "available": [{"id": "b2", "name": "B", "description": "y", "status": "available"}], - }): - out = out_srv.handle_tool_call("OutputList", {}) - text = _text(out["content"]) - assert "Active" in text and "Available" in text - assert "`a1`" in text and "`b2`" in text - - -def test_outputs_handle_list_backend_error(): - with _mock_backend(out_srv, {"error": "backend down"}): - out = out_srv.handle_tool_call("OutputList", {}) - assert out.get("isError") is True - assert "backend down" in _text(out["content"]) - - -def test_outputs_handle_search_missing_query(): - out = out_srv.handle_tool_call("OutputSearch", {}) - assert out.get("isError") is True - assert "query is required" in _text(out["content"]) - - -def test_outputs_handle_search_no_matches(): - with _mock_backend(out_srv, {"matches": []}): - out = out_srv.handle_tool_call("OutputSearch", {"query": "anything"}) - assert "No Outputs matched" in _text(out["content"]) - - -def test_outputs_handle_search_with_matches_includes_next_step(): - with _mock_backend(out_srv, {"matches": [ - {"id": "v1", "name": "View1", "description": "x", "status": "available"}, - ]}): - out = out_srv.handle_tool_call("OutputSearch", {"query": "view"}) - text = _text(out["content"]) - assert "`v1`" in text - assert "OutputActivate" in text - - -def test_outputs_handle_activate_missing_id(): - out = out_srv.handle_tool_call("OutputActivate", {}) - assert out.get("isError") is True - assert "output_id is required" in _text(out["content"]) - - -def test_outputs_handle_activate_unknown(): - with _mock_backend(out_srv, { - "status": "unknown_output", - "available": ["v1", "v2"], - }): - out = out_srv.handle_tool_call("OutputActivate", {"output_id": "phantom"}) - assert out.get("isError") is True - text = _text(out["content"]) - assert "Unknown Output id" in text - assert "`v1`" in text and "`v2`" in text - - -def test_outputs_handle_activate_already_active(): - with _mock_backend(out_srv, {"status": "already_active"}): - out = out_srv.handle_tool_call("OutputActivate", {"output_id": "v1"}) - assert "isError" not in out - assert "already active" in _text(out["content"]) - - -def test_outputs_handle_activate_activated(): - with _mock_backend(out_srv, {"status": "activated"}): - out = out_srv.handle_tool_call("OutputActivate", {"output_id": "v1"}) - assert "isError" not in out - assert "Activated Output `v1`" in _text(out["content"]) - - -def test_outputs_handle_activate_unexpected_status(): - with _mock_backend(out_srv, {"status": "wat"}): - out = out_srv.handle_tool_call("OutputActivate", {"output_id": "v1"}) - assert out.get("isError") is True - assert "Unexpected response" in _text(out["content"]) - - -def test_outputs_handle_unknown_tool(): - out = out_srv.handle_tool_call("NotARealTool", {}) - assert out.get("isError") is True - assert "Unknown tool" in _text(out["content"]) - - -# --------------------------------------------------------------------------- -# mcp_meta_server -# --------------------------------------------------------------------------- - - -def test_mcp_meta_tools_shape(): - names = {t["name"] for t in mcp_srv.TOOLS} - assert names == {"MCPList", "MCPSearch", "MCPActivate"} - for t in mcp_srv.TOOLS: - assert "inputSchema" in t - - -def test_mcp_meta_format_servers_renders_status(): - out = mcp_srv.format_servers( - [{"name": "slack", "description": "Slack tools", "status": "active"}], - heading="Active:", - ) - assert "Active:" in out - assert "`slack`" in out and "[active]" in out - - -def test_mcp_meta_handle_list_empty(): - with _mock_backend(mcp_srv, {"active": [], "available": []}): - out = mcp_srv.handle_tool_call("MCPList", {}) - assert "No MCP servers are installed" in _text(out["content"]) - - -def test_mcp_meta_handle_list_with_data(): - with _mock_backend(mcp_srv, { - "active": [{"name": "slack", "description": "x", "status": "active"}], - "available": [{"name": "discord", "description": "y", "status": "available"}], - }): - out = mcp_srv.handle_tool_call("MCPList", {}) - text = _text(out["content"]) - assert "Active" in text and "Available" in text - - -def test_mcp_meta_handle_list_backend_error(): - with _mock_backend(mcp_srv, {"error": "boom"}): - out = mcp_srv.handle_tool_call("MCPList", {}) - assert out.get("isError") is True - - -def test_mcp_meta_handle_search_missing_query(): - out = mcp_srv.handle_tool_call("MCPSearch", {}) - assert out.get("isError") is True - - -def test_mcp_meta_handle_search_no_matches(): - with _mock_backend(mcp_srv, {"matches": []}): - out = mcp_srv.handle_tool_call("MCPSearch", {"query": "x"}) - assert "No MCP servers matched" in _text(out["content"]) - - -def test_mcp_meta_handle_search_with_matches_includes_next_step(): - with _mock_backend(mcp_srv, {"matches": [ - {"name": "slack", "description": "S", "status": "available"}, - ]}): - out = mcp_srv.handle_tool_call("MCPSearch", {"query": "channel"}) - text = _text(out["content"]) - assert "MCPActivate" in text - - -def test_mcp_meta_handle_activate_missing_name(): - out = mcp_srv.handle_tool_call("MCPActivate", {}) - assert out.get("isError") is True - - -def test_mcp_meta_handle_activate_unknown_returns_valid_options(): - with _mock_backend(mcp_srv, { - "status": "unknown_server", - "available": ["slack", "notion"], - }): - out = mcp_srv.handle_tool_call("MCPActivate", {"server_name": "phantom"}) - text = _text(out["content"]) - assert out.get("isError") is True - assert "`slack`" in text and "`notion`" in text - - -def test_mcp_meta_handle_activate_already_active(): - with _mock_backend(mcp_srv, {"status": "already_active"}): - out = mcp_srv.handle_tool_call("MCPActivate", {"server_name": "slack"}) - assert "isError" not in out - assert "already active" in _text(out["content"]) - - -def test_mcp_meta_handle_activate_activated(): - with _mock_backend(mcp_srv, {"status": "activated"}): - out = mcp_srv.handle_tool_call("MCPActivate", {"server_name": "slack"}) - assert "isError" not in out - text = _text(out["content"]) - assert "mcp__slack__" in text # next-turn hint - - -def test_mcp_meta_handle_unknown_tool(): - out = mcp_srv.handle_tool_call("NotReal", {}) - assert out.get("isError") is True - - -# --------------------------------------------------------------------------- -# web_mcp_server -# --------------------------------------------------------------------------- - - -def test_web_mcp_tools_shape(): - names = {t["name"] for t in web_srv.TOOLS} - assert names == {"WebSearch", "WebFetch"} - - -def test_web_mcp_websearch_missing_query(): - out = web_srv.handle_tool_call("WebSearch", {}) - assert out.get("isError") is True - - -def test_web_mcp_websearch_returns_results(): - with _mock_backend(web_srv, {"results": "[1] Title\n https://example.com"}): - out = web_srv.handle_tool_call("WebSearch", {"query": "openswarm"}) - text = _text(out["content"]) - assert "Title" in text - - -def test_web_mcp_websearch_empty_results_falls_back_to_marker(): - with _mock_backend(web_srv, {"results": ""}): - out = web_srv.handle_tool_call("WebSearch", {"query": "missing"}) - assert "No results for: missing" in _text(out["content"]) - - -def test_web_mcp_websearch_backend_error(): - with _mock_backend(web_srv, {"error": "ddg down"}): - out = web_srv.handle_tool_call("WebSearch", {"query": "x"}) - assert out.get("isError") is True - assert "Search failed" in _text(out["content"]) - - -def test_web_mcp_websearch_clamps_num_results(): - """num_results > 10 is clamped down to 10.""" - captured: dict = {} - - def _fake_post(url, body, timeout=45.0): - captured.update(body) - return {"results": "ok"} - - with patch.object(web_srv, "_post", side_effect=_fake_post): - web_srv.handle_tool_call("WebSearch", {"query": "x", "num_results": 50}) - assert captured["num_results"] == 10 - - -def test_web_mcp_webfetch_missing_url(): - out = web_srv.handle_tool_call("WebFetch", {}) - assert out.get("isError") is True - - -def test_web_mcp_webfetch_invalid_scheme(): - out = web_srv.handle_tool_call("WebFetch", {"url": "ftp://example.com"}) - assert out.get("isError") is True - assert "must start with http" in _text(out["content"]) - - -def test_web_mcp_webfetch_returns_content(): - with _mock_backend(web_srv, {"content": "Plain text content"}): - out = web_srv.handle_tool_call( - "WebFetch", - {"url": "https://example.com", "prompt": "x"}, - ) - assert "Plain text content" in _text(out["content"]) - - -def test_web_mcp_webfetch_empty_content_falls_back_to_marker(): - with _mock_backend(web_srv, {"content": ""}): - out = web_srv.handle_tool_call("WebFetch", {"url": "https://example.com"}) - assert "No content returned from" in _text(out["content"]) - - -def test_web_mcp_unknown_tool_returns_error(): - out = web_srv.handle_tool_call("Phantom", {}) - assert out.get("isError") is True - - -# --------------------------------------------------------------------------- -# invoke_agent_mcp_server -# --------------------------------------------------------------------------- - - -def test_invoke_agent_tools_shape(): - names = {t["name"] for t in inv_srv.TOOLS} - assert names == {"InvokeAgent"} - - -def test_invoke_agent_unknown_tool(): - out = inv_srv.handle_tool_call("NotReal", {}) - assert out.get("isError") is True - - -def test_invoke_agent_missing_session_id(): - out = inv_srv.handle_tool_call("InvokeAgent", {"message": "hi"}) - assert out.get("isError") is True - - -def test_invoke_agent_missing_message(): - out = inv_srv.handle_tool_call("InvokeAgent", {"session_id": "x"}) - assert out.get("isError") is True - - -def test_invoke_agent_backend_error(): - with _mock_backend(inv_srv, {"error": "agent down"}): - out = inv_srv.handle_tool_call("InvokeAgent", { - "session_id": "x", "message": "hi", - }) - assert out.get("isError") is True - assert "agent down" in _text(out["content"]) - - -def test_invoke_agent_success_format_includes_cost_and_source_name(): - with _mock_backend(inv_srv, { - "forked_session_id": "fork-1", - "response": "Did the thing", - "cost_usd": 0.01, - "source_name": "Original Agent", - }): - out = inv_srv.handle_tool_call("InvokeAgent", { - "session_id": "x", "message": "hi", - }) - text = _text(out["content"]) - assert "Original Agent" in text - assert "fork-1" in text - assert "$0.0100" in text - assert "Did the thing" in text - - -def test_invoke_agent_zero_cost_omits_cost_line(): - with _mock_backend(inv_srv, { - "forked_session_id": "fork-1", - "response": "Result", - "cost_usd": 0, - }): - out = inv_srv.handle_tool_call("InvokeAgent", { - "session_id": "x", "message": "hi", - }) - text = _text(out["content"]) - assert "Cost" not in text - - -# --------------------------------------------------------------------------- -# browser_mcp_server -# --------------------------------------------------------------------------- - - -def test_browser_mcp_handle_missing_browser_id(): - out = br_srv.handle_tool_call("BrowserScreenshot", {}) - assert out.get("isError") is True - - -def test_browser_mcp_unknown_tool(): - out = br_srv.handle_tool_call("Phantom", {"browser_id": "b1"}) - assert out.get("isError") is True - - -def test_browser_mcp_get_text_dispatches_action(): - captured: dict = {} - - def _fake_call(action, browser_id, params=None, tab_id=""): - captured["action"] = action - captured["browser_id"] = browser_id - captured["params"] = params - return {"text": "page contents here"} - - with patch.object(br_srv, "call_backend", side_effect=_fake_call): - out = br_srv.handle_tool_call("BrowserGetText", {"browser_id": "b1"}) - - assert captured["action"] == "get_text" - assert captured["browser_id"] == "b1" - text = _text(out["content"]) - assert "page contents here" in text - - -def test_browser_mcp_navigate_passes_url_in_params(): - captured: dict = {} - - def _fake_call(action, browser_id, params=None, tab_id=""): - captured["params"] = params - return {"text": "ok"} - - with patch.object(br_srv, "call_backend", side_effect=_fake_call): - br_srv.handle_tool_call( - "BrowserNavigate", - {"browser_id": "b1", "url": "https://example.com"}, - ) - assert captured["params"] == {"url": "https://example.com"} - - -def test_browser_mcp_screenshot_returns_image_block(): - with patch.object(br_srv, "call_backend", return_value={ - "image": "AA==", - "url": "https://example.com", - }): - out = br_srv.handle_tool_call( - "BrowserScreenshot", - {"browser_id": "b1"}, - ) - types = [b["type"] for b in out["content"]] - assert "image" in types - assert "text" in types - - -def test_browser_mcp_screenshot_too_large_returns_text_only(): - """Massive base64 with PIL unavailable → return text-only fallback.""" - huge = "x" * (br_srv.MAX_IMAGE_B64_BYTES + 1) - with patch.object(br_srv, "call_backend", return_value={"image": huge, "url": "x"}), \ - patch.object(br_srv, "compress_screenshot", return_value=None): - out = br_srv.handle_tool_call("BrowserScreenshot", {"browser_id": "b1"}) - assert all(b["type"] == "text" for b in out["content"]) - assert "too large" in _text(out["content"]) - - -def test_browser_mcp_backend_error(): - with patch.object(br_srv, "call_backend", return_value={"error": "ws disconnected"}): - out = br_srv.handle_tool_call("BrowserGetText", {"browser_id": "b1"}) - assert out.get("isError") is True - assert "ws disconnected" in _text(out["content"]) - - -# --------------------------------------------------------------------------- -# browser_agent_mcp_server -# --------------------------------------------------------------------------- - - -def test_browser_agent_tools_shape(): - names = {t["name"] for t in ba_srv.TOOLS} - assert names == {"CreateBrowserAgent", "BrowserAgent", "BrowserAgents"} - - -def test_browser_agent_format_result_text_only(): - out = ba_srv.format_result({ - "summary": "Did the thing", - "session_id": "s1", - "browser_id": "b1", - "action_log": [ - {"tool": "BrowserNavigate", "input": {"url": "https://example.com"}, "elapsed_ms": 50}, - {"tool": "BrowserClick", "input": {"selector": "#go"}, "elapsed_ms": 10}, - ], - }) - text = _text(out["content"]) - assert "Browser Agent Result" in text - assert "Did the thing" in text - assert "BrowserNavigate" in text - assert "BrowserClick" in text - # No screenshot → no image content - assert all(b["type"] == "text" for b in out["content"]) - - -def test_browser_agent_format_result_error(): - out = ba_srv.format_result({"error": "no browser"}) - assert out.get("isError") is True - assert "no browser" in _text(out["content"]) - - -def test_browser_agent_format_batch_results_separates_with_divider(): - out = ba_srv.format_batch_results([ - {"summary": "A", "session_id": "s1", "browser_id": "b1", "action_log": []}, - {"summary": "B", "session_id": "s2", "browser_id": "b2", "action_log": []}, - ]) - text = _text(out["content"]) - assert "A" in text and "B" in text - assert "---" in text - - -def test_browser_agent_format_batch_results_top_level_error(): - out = ba_srv.format_batch_results({"error": "all failed"}) - assert out.get("isError") is True - - -def test_browser_agent_create_calls_backend_and_formats_first_result(): - with patch.object(ba_srv, "call_backend", return_value={"results": [{ - "summary": "Done", "session_id": "s1", "browser_id": "b1", "action_log": [], - }]}): - out = ba_srv.handle_tool_call("CreateBrowserAgent", {"task": "fetch a page"}) - assert "Done" in _text(out["content"]) - - -def test_browser_agent_browser_agent_missing_browser_id(): - out = ba_srv.handle_tool_call("BrowserAgent", {"task": "x"}) - assert out.get("isError") is True - - -def test_browser_agent_browser_agents_empty_tasks_errors(): - out = ba_srv.handle_tool_call("BrowserAgents", {"tasks": []}) - assert out.get("isError") is True - - -def test_browser_agent_browser_agents_missing_browser_id_in_task(): - out = ba_srv.handle_tool_call("BrowserAgents", {"tasks": [ - {"task": "x"}, # no browser_id - ]}) - assert out.get("isError") is True - - -def test_browser_agent_browser_agents_success(): - with patch.object(ba_srv, "call_backend", return_value={"results": [ - {"summary": "A", "session_id": "s1", "browser_id": "b1", "action_log": []}, - ]}): - out = ba_srv.handle_tool_call("BrowserAgents", {"tasks": [ - {"browser_id": "b1", "task": "x"}, - ]}) - assert "A" in _text(out["content"]) - - -def test_browser_agent_unknown_tool(): - out = ba_srv.handle_tool_call("Phantom", {}) - assert out.get("isError") is True - - -def test_browser_agent_call_backend_http_error(): - """call_backend's exception branch surfaces the error string.""" - import urllib.error - err = urllib.error.HTTPError( - url="x", code=500, msg="boom", hdrs=None, fp=io.BytesIO(b"server err"), - ) - with patch.object(ba_srv.urllib.request, "urlopen", side_effect=err): - out = ba_srv.call_backend([{"task": "x", "browser_id": "b1", "url": ""}]) - assert "error" in out - assert "HTTP 500" in out["error"] - - -def test_browser_agent_call_backend_generic_exception(): - with patch.object(ba_srv.urllib.request, "urlopen", side_effect=RuntimeError("dns")): - out = ba_srv.call_backend([{"task": "x", "browser_id": "b1", "url": ""}]) - assert out == {"error": "dns"} diff --git a/backend/tests/test_providers_anthropic_extra.py b/backend/tests/test_providers_anthropic_extra.py deleted file mode 100644 index e730df2b..00000000 --- a/backend/tests/test_providers_anthropic_extra.py +++ /dev/null @@ -1,530 +0,0 @@ -"""Tests for the uncovered branches of the Anthropic provider adapter. - -`test_phase1_stress.py::test_anthropic_provider_forwards_thinking_blocks` -already covers the streaming-thinking path. This file fills in the -remaining branches: model id mapping, message-format helpers, -non-streaming `create_message`, `_build_messages` (tool_result list -vs. single-dict shape), and the `message_start` / `message_delta` -usage-extraction code in `stream_message`. - -The Anthropic SDK client is fully mocked — no network, no API key -required. -""" - -from __future__ import annotations - -from typing import Any -from unittest.mock import AsyncMock, MagicMock - -import pytest - -from backend.apps.agents.providers.anthropic import AnthropicProvider, MODEL_MAP -from backend.apps.agents.providers.base import ( - ContentBlock, - ModelResponse, - ProviderMessage, - ToolCall, - ToolSchema, -) - - -# --------------------------------------------------------------------------- -# Lightweight fakes (mimics the SDK's duck-typed objects without pulling -# in the real anthropic types — they're a heavy import path). -# --------------------------------------------------------------------------- - - -class _FakeAttr: - """Generic dot-attribute object for SDK-shaped responses.""" - - def __init__(self, **kwargs: Any) -> None: - for k, v in kwargs.items(): - setattr(self, k, v) - - -def _make_provider() -> AnthropicProvider: - """Build a provider whose underlying SDK client is fully mocked.""" - p = AnthropicProvider(api_key="test-key") - # Replace the AsyncAnthropic client wholesale; tests will set the - # specific behaviour on `client.messages.create`. - p.client = MagicMock() - p.client.messages = MagicMock() - p.client.messages.create = AsyncMock() - return p - - -# --------------------------------------------------------------------------- -# get_model_id -# --------------------------------------------------------------------------- - - -@pytest.mark.parametrize("short,full", list(MODEL_MAP.items())) -def test_get_model_id_short_name_resolves_to_full(short: str, full: str): - p = _make_provider() - assert p.get_model_id(short) == full - - -def test_get_model_id_passthrough_for_unknown(): - """Anything not in MODEL_MAP is returned verbatim.""" - p = _make_provider() - assert p.get_model_id("claude-7-sonnet-20991231") == "claude-7-sonnet-20991231" - - -# --------------------------------------------------------------------------- -# format_user_message / format_assistant_message / format_tool_result -# --------------------------------------------------------------------------- - - -def test_format_user_message_string_content(): - p = _make_provider() - msg = p.format_user_message("hello") - assert isinstance(msg, ProviderMessage) - assert msg.role == "user" - assert msg.content == "hello" - - -def test_format_user_message_multimodal_blocks(): - """Image + text user message — content list should pass through unchanged.""" - p = _make_provider() - blocks = [ - {"type": "text", "text": "look at this"}, - {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "AA=="}}, - ] - msg = p.format_user_message(blocks) - assert msg.role == "user" - assert msg.content is blocks - - -def test_format_assistant_message_text_only(): - p = _make_provider() - resp = ModelResponse( - content=[ContentBlock(type="text", text="hello")], - stop_reason="end_turn", - ) - msg = p.format_assistant_message(resp) - assert msg.role == "assistant" - assert msg.content == [{"type": "text", "text": "hello"}] - - -def test_format_assistant_message_mixed_text_and_tool_use(): - """ContentBlocks of type=text/tool_use must round-trip into the - Anthropic message-content shape, preserving id+name+input.""" - p = _make_provider() - resp = ModelResponse( - content=[ - ContentBlock(type="text", text="thinking…"), - ContentBlock( - type="tool_use", - tool_call=ToolCall(id="t1", name="Read", input={"path": "/tmp/x"}), - ), - ContentBlock(type="text", text="done"), - ], - stop_reason="tool_use", - ) - msg = p.format_assistant_message(resp) - assert msg.role == "assistant" - assert msg.content == [ - {"type": "text", "text": "thinking…"}, - {"type": "tool_use", "id": "t1", "name": "Read", "input": {"path": "/tmp/x"}}, - {"type": "text", "text": "done"}, - ] - - -def test_format_assistant_message_skips_tool_use_without_call(): - """A tool_use block with no ToolCall is dropped (defensive — should - never happen in practice, but the conditional is in the source).""" - p = _make_provider() - resp = ModelResponse( - content=[ - ContentBlock(type="text", text="hi"), - ContentBlock(type="tool_use", tool_call=None), # silently dropped - ], - stop_reason="end_turn", - ) - msg = p.format_assistant_message(resp) - assert msg.content == [{"type": "text", "text": "hi"}] - - -def test_format_tool_result_shape(): - p = _make_provider() - out = p.format_tool_result( - "tool-id-7", - [{"type": "text", "text": "result body"}], - ) - assert out == { - "type": "tool_result", - "tool_use_id": "tool-id-7", - "content": [{"type": "text", "text": "result body"}], - } - - -# --------------------------------------------------------------------------- -# clean_tool_schema -# --------------------------------------------------------------------------- - - -def test_clean_tool_schema_returns_anthropic_format(): - p = _make_provider() - schema = ToolSchema( - name="Read", - description="Read a file", - input_schema={"type": "object", "properties": {"path": {"type": "string"}}}, - ) - out = p.clean_tool_schema(schema) - assert out == { - "name": "Read", - "description": "Read a file", - "input_schema": {"type": "object", "properties": {"path": {"type": "string"}}}, - } - - -# --------------------------------------------------------------------------- -# _build_messages — every role + tool_result list/non-list shape -# --------------------------------------------------------------------------- - - -def test_build_messages_passes_through_user_and_assistant(): - p = _make_provider() - msgs = [ - ProviderMessage(role="user", content="hi"), - ProviderMessage(role="assistant", content=[{"type": "text", "text": "hello"}]), - ] - built = p._build_messages(msgs) - assert built == [ - {"role": "user", "content": "hi"}, - {"role": "assistant", "content": [{"type": "text", "text": "hello"}]}, - ] - - -def test_build_messages_tool_result_list_passes_unwrapped(): - """Tool results delivered as a list of result blocks must be sent - as-is under role=user.""" - p = _make_provider() - blocks = [ - {"type": "tool_result", "tool_use_id": "t1", "content": [{"type": "text", "text": "ok"}]}, - {"type": "tool_result", "tool_use_id": "t2", "content": [{"type": "text", "text": "ok2"}]}, - ] - built = p._build_messages([ProviderMessage(role="tool_result", content=blocks)]) - assert built == [{"role": "user", "content": blocks}] - - -def test_build_messages_tool_result_single_dict_gets_wrapped_in_list(): - """A non-list tool_result content must be wrapped: the API expects - `content` to always be a list at this level.""" - p = _make_provider() - block = {"type": "tool_result", "tool_use_id": "t1", "content": "ok"} - built = p._build_messages([ProviderMessage(role="tool_result", content=block)]) - assert built == [{"role": "user", "content": [block]}] - - -def test_build_messages_drops_unknown_role(): - """If somehow a ProviderMessage with role='something_weird' is - passed in, the loop must skip it rather than crash.""" - p = _make_provider() - built = p._build_messages([ - ProviderMessage(role="weird", content="x"), - ProviderMessage(role="user", content="hi"), - ]) - assert built == [{"role": "user", "content": "hi"}] - - -# --------------------------------------------------------------------------- -# create_message (non-streaming) -# --------------------------------------------------------------------------- - - -async def test_create_message_returns_normalized_response_text_only(): - p = _make_provider() - fake_resp = _FakeAttr( - content=[_FakeAttr(type="text", text="hello world")], - stop_reason="end_turn", - usage=_FakeAttr(input_tokens=42, output_tokens=7), - ) - p.client.messages.create = AsyncMock(return_value=fake_resp) - - out = await p.create_message( - model="sonnet", - system="be useful", - messages=[ProviderMessage(role="user", content="hi")], - tools=[], - ) - - # Kwargs were translated through get_model_id + clean_tool_schema - args, kwargs = p.client.messages.create.call_args - assert kwargs["model"] == MODEL_MAP["sonnet"] - assert kwargs["max_tokens"] == 8192 - assert kwargs["system"] == "be useful" - assert kwargs["messages"] == [{"role": "user", "content": "hi"}] - assert "tools" not in kwargs # empty list = omit - - assert isinstance(out, ModelResponse) - assert out.stop_reason == "end_turn" - assert out.usage == {"input_tokens": 42, "output_tokens": 7} - assert len(out.content) == 1 - assert out.content[0].type == "text" - assert out.content[0].text == "hello world" - - -async def test_create_message_translates_tool_use_block(): - p = _make_provider() - fake_resp = _FakeAttr( - content=[ - _FakeAttr(type="text", text="let me check"), - _FakeAttr( - type="tool_use", - id="toolu_1", - name="Read", - input={"path": "/tmp/x"}, - ), - ], - stop_reason="tool_use", - usage=_FakeAttr(input_tokens=10, output_tokens=2), - ) - p.client.messages.create = AsyncMock(return_value=fake_resp) - - out = await p.create_message( - model="opus", - system=None, - messages=[ProviderMessage(role="user", content="x")], - tools=[ - ToolSchema(name="Read", description="Read a file", - input_schema={"type": "object"}), - ], - ) - - _, kwargs = p.client.messages.create.call_args - assert kwargs["model"] == MODEL_MAP["opus"] - assert kwargs["tools"] == [{ - "name": "Read", "description": "Read a file", - "input_schema": {"type": "object"}, - }] - assert "system" not in kwargs # None is dropped - - assert out.stop_reason == "tool_use" - assert out.content[0].type == "text" - assert out.content[1].type == "tool_use" - assert out.content[1].tool_call is not None - assert out.content[1].tool_call.id == "toolu_1" - assert out.content[1].tool_call.name == "Read" - assert out.content[1].tool_call.input == {"path": "/tmp/x"} - - -async def test_create_message_max_tokens_passthrough(): - p = _make_provider() - p.client.messages.create = AsyncMock(return_value=_FakeAttr( - content=[_FakeAttr(type="text", text="ok")], - stop_reason="end_turn", - usage=_FakeAttr(input_tokens=1, output_tokens=1), - )) - await p.create_message( - model="sonnet", system=None, - messages=[ProviderMessage(role="user", content="x")], - tools=[], max_tokens=12_345, - ) - _, kwargs = p.client.messages.create.call_args - assert kwargs["max_tokens"] == 12_345 - - -# --------------------------------------------------------------------------- -# stream_message: message_start / message_delta usage extraction -# --------------------------------------------------------------------------- - - -async def test_stream_message_extracts_usage_from_message_start(): - """The first SSE event the SDK emits is `message_start` carrying - initial input_tokens. Adapter must surface that as a `usage` - StreamEvent before the message_stop sentinel.""" - p = _make_provider() - - async def fake_stream(): - # message_start carries initial usage (input + cache + output start) - yield _FakeAttr( - type="message_start", - message=_FakeAttr( - usage=_FakeAttr(input_tokens=100, output_tokens=0), - ), - ) - # text block - yield _FakeAttr(type="content_block_start", index=0, - content_block=_FakeAttr(type="text")) - yield _FakeAttr(type="content_block_delta", index=0, - delta=_FakeAttr(type="text_delta", text="hi")) - yield _FakeAttr(type="content_block_stop", index=0) - # message_delta carries final output_tokens - yield _FakeAttr( - type="message_delta", - usage=_FakeAttr(output_tokens=25), - ) - - p.client.messages.create = AsyncMock(return_value=fake_stream()) - - events = [] - async for ev in p.stream_message( - model="sonnet", system=None, messages=[], tools=[], - ): - events.append(ev) - - usage_events = [e for e in events if e.type == "usage"] - assert len(usage_events) == 2 - assert usage_events[0].usage == {"input_tokens": 100} - assert usage_events[1].usage == {"output_tokens": 25} - - # Always closes with message_stop - assert events[-1].type == "message_stop" - - -async def test_stream_message_skips_message_start_without_usage(): - """If `message_start.message.usage` is missing or zero, no `usage` - event must fire (the source guards on truthy input/output tokens).""" - p = _make_provider() - - async def fake_stream(): - yield _FakeAttr( - type="message_start", - message=_FakeAttr(usage=_FakeAttr(input_tokens=0, output_tokens=0)), - ) - yield _FakeAttr( - type="message_delta", - usage=None, - ) - - p.client.messages.create = AsyncMock(return_value=fake_stream()) - - events = [] - async for ev in p.stream_message( - model="sonnet", system=None, messages=[], tools=[], - ): - events.append(ev) - - usage_events = [e for e in events if e.type == "usage"] - assert usage_events == [] - - -async def test_stream_message_input_json_delta_streamed(): - """The tool_use streaming path: input_json_delta chunks must be - surfaced as content_block_delta with delta_type=input_json_delta.""" - p = _make_provider() - - async def fake_stream(): - yield _FakeAttr( - type="content_block_start", index=0, - content_block=_FakeAttr(type="tool_use", name="Read", id="toolu_1"), - ) - yield _FakeAttr( - type="content_block_delta", index=0, - delta=_FakeAttr(type="input_json_delta", partial_json='{"pa'), - ) - yield _FakeAttr( - type="content_block_delta", index=0, - delta=_FakeAttr(type="input_json_delta", partial_json='th": "/x"}'), - ) - yield _FakeAttr(type="content_block_stop", index=0) - - p.client.messages.create = AsyncMock(return_value=fake_stream()) - - events = [] - async for ev in p.stream_message( - model="sonnet", system=None, messages=[], tools=[], - ): - events.append(ev) - - starts = [e for e in events if e.type == "content_block_start"] - deltas = [e for e in events if e.type == "content_block_delta" - and e.delta_type == "input_json_delta"] - assert len(starts) == 1 - assert starts[0].block_type == "tool_use" - assert starts[0].tool_name == "Read" - assert starts[0].tool_id == "toolu_1" - assert len(deltas) == 2 - assert "".join(d.text for d in deltas) == '{"path": "/x"}' - - -async def test_stream_message_passes_system_and_tools_to_sdk(): - """Smoke-test that system + tool schemas reach the SDK call.""" - p = _make_provider() - - async def empty_stream(): - if False: - yield None # never yields — empty generator - return - - p.client.messages.create = AsyncMock(return_value=empty_stream()) - - async for _ in p.stream_message( - model="sonnet", - system="be helpful", - messages=[ProviderMessage(role="user", content="hi")], - tools=[ToolSchema(name="Read", description="d", input_schema={"type": "object"})], - max_tokens=2048, - ): - pass - - _, kwargs = p.client.messages.create.call_args - assert kwargs["model"] == MODEL_MAP["sonnet"] - assert kwargs["system"] == "be helpful" - assert kwargs["max_tokens"] == 2048 - assert kwargs["stream"] is True - assert kwargs["tools"] == [{"name": "Read", "description": "d", "input_schema": {"type": "object"}}] - assert kwargs["messages"] == [{"role": "user", "content": "hi"}] - - -# --------------------------------------------------------------------------- -# stream_and_collect default raises -# --------------------------------------------------------------------------- - - -async def test_stream_and_collect_raises_not_implemented(): - """The helper isn't used directly by AgentLoop — provider hides it - behind a NotImplementedError to prevent accidental adoption.""" - p = _make_provider() - with pytest.raises(NotImplementedError): - await p.stream_and_collect( - model="sonnet", system=None, messages=[], tools=[], - ) - - -# --------------------------------------------------------------------------- -# Constructor kwarg handling -# --------------------------------------------------------------------------- - - -def test_constructor_prefers_auth_token_over_api_key(): - """When both are passed, auth_token wins (the elif branch in the - constructor); api_key is silently dropped.""" - import anthropic - - captured: dict[str, Any] = {} - - class _Stub: - def __init__(self, **kwargs): - captured.update(kwargs) - - real = anthropic.AsyncAnthropic - anthropic.AsyncAnthropic = _Stub - try: - AnthropicProvider(api_key="key", auth_token="tok", base_url="http://x") - finally: - anthropic.AsyncAnthropic = real - - assert captured.get("auth_token") == "tok" - assert "api_key" not in captured - assert captured.get("base_url") == "http://x" - - -def test_constructor_no_creds_passes_no_kwargs(): - import anthropic - - captured: dict[str, Any] = {} - - class _Stub: - def __init__(self, **kwargs): - captured.update(kwargs) - - real = anthropic.AsyncAnthropic - anthropic.AsyncAnthropic = _Stub - try: - AnthropicProvider() - finally: - anthropic.AsyncAnthropic = real - - assert captured == {} diff --git a/backend/tests/test_providers_openai_compat.py b/backend/tests/test_providers_openai_compat.py deleted file mode 100644 index a38901c0..00000000 --- a/backend/tests/test_providers_openai_compat.py +++ /dev/null @@ -1,676 +0,0 @@ -"""Tests for `backend.apps.agents.providers.openai_compat`. - -The whole module currently sits at 0% coverage because no other test -exercises an OpenAI-compatible provider. We mock the `AsyncOpenAI` -client so all paths run with no network access: - - - `format_user_message`: string + multimodal (text + image) blocks - - `format_assistant_message`: text-only, mixed text+tool_use, - tool_use only (content=None branch) - - `format_tool_result`: text + image + raw json fallback - - `_build_messages`: system prefix, assistant in OpenAI format, - assistant in Anthropic-block format, tool_result list / single - dict, user passthrough - - `create_message`: text completion + tool_calls, finish_reason - handling, usage extraction - - `stream_message`: text-delta chunks, tool-call streaming with - json delta accumulation, usage-only final chunk, finish_reason - closing all open blocks - - `clean_tool_schema`: OpenAI function-calling shape - - `get_model_id`: passthrough (no short-name mapping) -""" - -from __future__ import annotations - -import json -from typing import Any -from unittest.mock import AsyncMock, MagicMock - -import pytest - -from backend.apps.agents.providers.base import ( - ContentBlock, - ModelResponse, - ProviderMessage, - ToolCall, - ToolSchema, -) -from backend.apps.agents.providers.openai_compat import OpenAICompatProvider - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -class _FakeAttr: - """Generic dot-attribute object for SDK-shaped responses.""" - - def __init__(self, **kwargs: Any) -> None: - for k, v in kwargs.items(): - setattr(self, k, v) - - -def _make_provider() -> OpenAICompatProvider: - p = OpenAICompatProvider(api_key="test-key", base_url="http://example.invalid") - p.client = MagicMock() - p.client.chat = MagicMock() - p.client.chat.completions = MagicMock() - p.client.chat.completions.create = AsyncMock() - return p - - -# --------------------------------------------------------------------------- -# Constructor + simple helpers -# --------------------------------------------------------------------------- - - -def test_get_model_id_is_passthrough(): - """OpenAI-compatible doesn't do short-name mapping; user supplies - the exact API model id.""" - p = _make_provider() - assert p.get_model_id("gpt-5.4") == "gpt-5.4" - assert p.get_model_id("anything-else") == "anything-else" - - -def test_clean_tool_schema_returns_openai_function_format(): - p = _make_provider() - schema = ToolSchema( - name="Read", - description="Read a file", - input_schema={"type": "object", "properties": {"path": {"type": "string"}}}, - ) - out = p.clean_tool_schema(schema) - assert out == { - "type": "function", - "function": { - "name": "Read", - "description": "Read a file", - "parameters": { - "type": "object", - "properties": {"path": {"type": "string"}}, - }, - }, - } - - -def test_constructor_defaults_api_key_to_none_placeholder(): - """Some endpoints don't need real keys; the adapter sends "none" - rather than failing. Capture the kwargs to verify.""" - from openai import AsyncOpenAI as _RealOpenAI - captured: dict[str, Any] = {} - - class _Stub: - def __init__(self, **kwargs): - captured.update(kwargs) - - import backend.apps.agents.providers.openai_compat as oc_mod - real = oc_mod.AsyncOpenAI - oc_mod.AsyncOpenAI = _Stub - try: - OpenAICompatProvider(api_key="", base_url=None) - finally: - oc_mod.AsyncOpenAI = real - assert captured.get("api_key") == "none" - assert "base_url" not in captured - - -# --------------------------------------------------------------------------- -# format_user_message -# --------------------------------------------------------------------------- - - -def test_format_user_message_string(): - p = _make_provider() - msg = p.format_user_message("hello") - assert msg.role == "user" - assert msg.content == "hello" - - -def test_format_user_message_multimodal_text_and_image(): - """Text + Anthropic-style image blocks → OpenAI image_url with - base64 data URL.""" - p = _make_provider() - blocks = [ - {"type": "text", "text": "look:"}, - { - "type": "image", - "source": {"type": "base64", "media_type": "image/png", "data": "AA=="}, - }, - "trailing string", # str gets coerced to a text part - ] - msg = p.format_user_message(blocks) - assert msg.role == "user" - assert msg.content == [ - {"type": "text", "text": "look:"}, - { - "type": "image_url", - "image_url": {"url": "data:image/png;base64,AA=="}, - }, - {"type": "text", "text": "trailing string"}, - ] - - -def test_format_user_message_other_types_str_coerced(): - p = _make_provider() - msg = p.format_user_message(42) - assert msg.role == "user" - assert msg.content == "42" - - -# --------------------------------------------------------------------------- -# format_assistant_message -# --------------------------------------------------------------------------- - - -def test_format_assistant_message_text_only(): - p = _make_provider() - resp = ModelResponse( - content=[ - ContentBlock(type="text", text="line one"), - ContentBlock(type="text", text="line two"), - ], - stop_reason="end_turn", - ) - msg = p.format_assistant_message(resp) - # Stored under content -> dict (already in OpenAI shape) so _build_messages - # can pass it through unchanged. - assert msg.role == "assistant" - assert msg.content == {"role": "assistant", "content": "line one\nline two"} - - -def test_format_assistant_message_tool_use_only_sets_content_to_none(): - p = _make_provider() - resp = ModelResponse( - content=[ - ContentBlock( - type="tool_use", - tool_call=ToolCall(id="t1", name="Read", input={"path": "/x"}), - ), - ], - stop_reason="tool_use", - ) - msg = p.format_assistant_message(resp) - assert msg.content["content"] is None - assert msg.content["tool_calls"] == [{ - "id": "t1", - "type": "function", - "function": {"name": "Read", "arguments": json.dumps({"path": "/x"})}, - }] - - -def test_format_assistant_message_mixed_text_and_tool_use(): - p = _make_provider() - resp = ModelResponse( - content=[ - ContentBlock(type="text", text="thinking"), - ContentBlock( - type="tool_use", - tool_call=ToolCall(id="t1", name="Bash", input={"cmd": "ls"}), - ), - ], - stop_reason="tool_use", - ) - msg = p.format_assistant_message(resp) - assert msg.content["content"] == "thinking" - assert len(msg.content["tool_calls"]) == 1 - tc = msg.content["tool_calls"][0] - assert tc["function"]["name"] == "Bash" - assert json.loads(tc["function"]["arguments"]) == {"cmd": "ls"} - - -# --------------------------------------------------------------------------- -# format_tool_result -# --------------------------------------------------------------------------- - - -def test_format_tool_result_collapses_text_blocks_to_single_string(): - p = _make_provider() - out = p.format_tool_result("call_1", [ - {"type": "text", "text": "line a"}, - {"type": "text", "text": "line b"}, - ]) - assert out == { - "role": "tool", - "tool_call_id": "call_1", - "content": "line a\nline b", - } - - -def test_format_tool_result_image_blocks_become_placeholder(): - p = _make_provider() - out = p.format_tool_result("call_2", [ - {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "AA=="}}, - ]) - assert out["content"] == "[image]" - - -def test_format_tool_result_unknown_block_falls_back_to_json(): - p = _make_provider() - block = {"type": "custom", "x": 1} - out = p.format_tool_result("call_3", [block]) - assert out["content"] == json.dumps(block) - - -def test_format_tool_result_empty_returns_done_marker(): - """Empty content list → "Done." so OpenAI doesn't reject the - message for empty content.""" - p = _make_provider() - out = p.format_tool_result("call_4", []) - assert out["content"] == "Done." - - -# --------------------------------------------------------------------------- -# _build_messages -# --------------------------------------------------------------------------- - - -def test_build_messages_includes_system_prefix(): - p = _make_provider() - out = p._build_messages("you are helpful", [ - ProviderMessage(role="user", content="hi"), - ]) - assert out[0] == {"role": "system", "content": "you are helpful"} - assert out[1] == {"role": "user", "content": "hi"} - - -def test_build_messages_no_system_no_prefix(): - p = _make_provider() - out = p._build_messages(None, [ProviderMessage(role="user", content="hi")]) - assert out == [{"role": "user", "content": "hi"}] - - -def test_build_messages_assistant_in_openai_shape_passes_through(): - """`format_assistant_message` already produces OpenAI-shape dicts; - `_build_messages` must pass them through unchanged.""" - p = _make_provider() - asst_dict = {"role": "assistant", "content": "hello"} - out = p._build_messages(None, [ProviderMessage(role="assistant", content=asst_dict)]) - assert out == [asst_dict] - - -def test_build_messages_assistant_in_anthropic_block_format(): - """Coming from a cross-provider session, the assistant content - might still be in Anthropic block format. _build_messages must - translate it.""" - p = _make_provider() - blocks = [ - {"type": "text", "text": "thinking"}, - {"type": "tool_use", "id": "t1", "name": "Read", "input": {"path": "/x"}}, - ] - out = p._build_messages(None, [ProviderMessage(role="assistant", content=blocks)]) - assert out[0]["role"] == "assistant" - assert out[0]["content"] == "thinking" - assert out[0]["tool_calls"] == [{ - "id": "t1", - "type": "function", - "function": {"name": "Read", "arguments": json.dumps({"path": "/x"})}, - }] - - -def test_build_messages_tool_result_list_each_appended(): - p = _make_provider() - tool_results = [ - {"role": "tool", "tool_call_id": "t1", "content": "ok"}, - {"role": "tool", "tool_call_id": "t2", "content": "ok2"}, - ] - out = p._build_messages(None, [ProviderMessage(role="tool_result", content=tool_results)]) - assert out == tool_results - - -def test_build_messages_tool_result_single_dict_appended(): - p = _make_provider() - tr = {"role": "tool", "tool_call_id": "t1", "content": "ok"} - out = p._build_messages(None, [ProviderMessage(role="tool_result", content=tr)]) - assert out == [tr] - - -def test_build_messages_tool_result_without_tool_call_id_dropped(): - """Defensive: a malformed tool_result without `tool_call_id` is - silently dropped to avoid crashing the API call.""" - p = _make_provider() - out = p._build_messages(None, [ - ProviderMessage(role="tool_result", content={"role": "tool", "content": "x"}), - ]) - assert out == [] - - -def test_build_messages_user_string_passthrough(): - p = _make_provider() - out = p._build_messages(None, [ProviderMessage(role="user", content="hi")]) - assert out == [{"role": "user", "content": "hi"}] - - -# --------------------------------------------------------------------------- -# create_message (non-streaming) -# --------------------------------------------------------------------------- - - -async def test_create_message_text_only_response(): - p = _make_provider() - fake_resp = _FakeAttr( - choices=[ - _FakeAttr( - message=_FakeAttr(content="hello", tool_calls=None), - finish_reason="stop", - ), - ], - usage=_FakeAttr(prompt_tokens=12, completion_tokens=3), - ) - p.client.chat.completions.create = AsyncMock(return_value=fake_resp) - - out = await p.create_message( - model="gpt-5.4", - system="be useful", - messages=[ProviderMessage(role="user", content="hi")], - tools=[], - ) - - _, kwargs = p.client.chat.completions.create.call_args - assert kwargs["model"] == "gpt-5.4" # passthrough - assert kwargs["max_tokens"] == 8192 - assert kwargs["messages"][0] == {"role": "system", "content": "be useful"} - assert kwargs["messages"][1] == {"role": "user", "content": "hi"} - assert "tools" not in kwargs - - assert out.stop_reason == "end_turn" - assert len(out.content) == 1 - assert out.content[0].type == "text" - assert out.content[0].text == "hello" - assert out.usage == {"input_tokens": 12, "output_tokens": 3} - - -async def test_create_message_tool_calls_translation(): - p = _make_provider() - fake_resp = _FakeAttr( - choices=[ - _FakeAttr( - message=_FakeAttr( - content=None, - tool_calls=[ - _FakeAttr( - id="call_1", - function=_FakeAttr( - name="Read", - arguments=json.dumps({"path": "/tmp/a"}), - ), - ), - ], - ), - finish_reason="tool_calls", - ), - ], - usage=_FakeAttr(prompt_tokens=5, completion_tokens=2), - ) - p.client.chat.completions.create = AsyncMock(return_value=fake_resp) - - out = await p.create_message( - model="gpt-5.4", - system=None, - messages=[ProviderMessage(role="user", content="x")], - tools=[ToolSchema(name="Read", description="d", input_schema={"type": "object"})], - ) - - _, kwargs = p.client.chat.completions.create.call_args - assert kwargs["tools"] == [{ - "type": "function", - "function": {"name": "Read", "description": "d", "parameters": {"type": "object"}}, - }] - - assert out.stop_reason == "tool_use" - assert len(out.content) == 1 - assert out.content[0].type == "tool_use" - assert out.content[0].tool_call.id == "call_1" - assert out.content[0].tool_call.name == "Read" - assert out.content[0].tool_call.input == {"path": "/tmp/a"} - - -async def test_create_message_invalid_tool_args_json_falls_back_to_empty(): - """Malformed JSON in `function.arguments` must NOT crash; the adapter - swallows the JSONDecodeError and leaves input={}.""" - p = _make_provider() - fake_resp = _FakeAttr( - choices=[ - _FakeAttr( - message=_FakeAttr( - content=None, - tool_calls=[ - _FakeAttr( - id="call_1", - function=_FakeAttr(name="Read", arguments="{not valid json"), - ), - ], - ), - finish_reason="tool_calls", - ), - ], - usage=None, - ) - p.client.chat.completions.create = AsyncMock(return_value=fake_resp) - - out = await p.create_message( - model="gpt-5.4", system=None, - messages=[ProviderMessage(role="user", content="x")], tools=[], - ) - assert out.content[0].tool_call.input == {} - assert out.usage == {} - - -async def test_create_message_text_plus_tool_use_yields_tool_use_stop(): - """Mixed content with tool_calls → stop_reason becomes tool_use even - if finish_reason was 'stop' (defensive against models that report - 'stop' alongside tool_calls).""" - p = _make_provider() - fake_resp = _FakeAttr( - choices=[ - _FakeAttr( - message=_FakeAttr( - content="thinking", - tool_calls=[ - _FakeAttr( - id="c1", - function=_FakeAttr(name="Read", arguments="{}"), - ), - ], - ), - finish_reason="stop", # not "tool_calls" - ), - ], - usage=_FakeAttr(prompt_tokens=1, completion_tokens=1), - ) - p.client.chat.completions.create = AsyncMock(return_value=fake_resp) - - out = await p.create_message( - model="gpt-5.4", system=None, - messages=[ProviderMessage(role="user", content="x")], tools=[], - ) - assert out.stop_reason == "tool_use" - - -# --------------------------------------------------------------------------- -# stream_message -# --------------------------------------------------------------------------- - - -async def test_stream_message_text_only_chunks(): - p = _make_provider() - - async def fake_stream(): - yield _FakeAttr( - choices=[_FakeAttr( - delta=_FakeAttr(content="hel", tool_calls=None), - finish_reason=None, - )], - usage=None, - ) - yield _FakeAttr( - choices=[_FakeAttr( - delta=_FakeAttr(content="lo", tool_calls=None), - finish_reason=None, - )], - usage=None, - ) - yield _FakeAttr( - choices=[_FakeAttr( - delta=_FakeAttr(content=None, tool_calls=None), - finish_reason="stop", - )], - usage=None, - ) - # Final usage-only chunk - yield _FakeAttr( - choices=[], - usage=_FakeAttr(prompt_tokens=10, completion_tokens=2), - ) - - p.client.chat.completions.create = AsyncMock(return_value=fake_stream()) - - events = [] - async for ev in p.stream_message(model="gpt-5.4", system=None, messages=[], tools=[]): - events.append(ev) - - starts = [e for e in events if e.type == "content_block_start"] - deltas = [e for e in events if e.type == "content_block_delta"] - stops = [e for e in events if e.type == "content_block_stop"] - - assert len(starts) == 1 - assert starts[0].block_type == "text" - assert "".join(d.text for d in deltas) == "hello" - assert len(stops) == 1 - assert any(e.type == "message_stop" for e in events) - usage = [e for e in events if e.type == "usage"] - assert usage and usage[0].usage == {"input_tokens": 10, "output_tokens": 2} - - -async def test_stream_message_tool_call_streamed(): - """Tool-call streaming: name comes in chunk 1, arguments stream in - multiple JSON deltas. Adapter accumulates and emits normalized - StreamEvents.""" - p = _make_provider() - - async def fake_stream(): - # chunk 1: tool_call begin (id + name) - yield _FakeAttr( - choices=[_FakeAttr( - delta=_FakeAttr( - content=None, - tool_calls=[_FakeAttr( - index=0, - id="call_1", - function=_FakeAttr(name="Read", arguments=""), - )], - ), - finish_reason=None, - )], - usage=None, - ) - # chunk 2: arguments part 1 - yield _FakeAttr( - choices=[_FakeAttr( - delta=_FakeAttr( - content=None, - tool_calls=[_FakeAttr( - index=0, - id=None, - function=_FakeAttr(name=None, arguments='{"pa'), - )], - ), - finish_reason=None, - )], - usage=None, - ) - # chunk 3: arguments part 2 + finish - yield _FakeAttr( - choices=[_FakeAttr( - delta=_FakeAttr( - content=None, - tool_calls=[_FakeAttr( - index=0, - id=None, - function=_FakeAttr(name=None, arguments='th": "/x"}'), - )], - ), - finish_reason="tool_calls", - )], - usage=None, - ) - - p.client.chat.completions.create = AsyncMock(return_value=fake_stream()) - - events = [] - async for ev in p.stream_message(model="gpt-5.4", system=None, messages=[], tools=[]): - events.append(ev) - - starts = [e for e in events if e.type == "content_block_start"] - deltas = [e for e in events if e.type == "content_block_delta"] - stops = [e for e in events if e.type == "content_block_stop"] - - assert len(starts) == 1 - assert starts[0].block_type == "tool_use" - assert starts[0].tool_id == "call_1" - assert starts[0].tool_name == "Read" - json_deltas = [d for d in deltas if d.delta_type == "input_json_delta"] - assert "".join(d.text for d in json_deltas) == '{"path": "/x"}' - assert len(stops) == 1 - assert any(e.type == "message_stop" for e in events) - - -async def test_stream_message_text_then_tool_use_closes_text_block(): - """When text was already streaming and a tool_call begins, the - text block must be closed first so the frontend's UI logic sees a - clean handoff.""" - p = _make_provider() - - async def fake_stream(): - yield _FakeAttr( - choices=[_FakeAttr( - delta=_FakeAttr(content="thinking ", tool_calls=None), - finish_reason=None, - )], - usage=None, - ) - yield _FakeAttr( - choices=[_FakeAttr( - delta=_FakeAttr( - content=None, - tool_calls=[_FakeAttr( - index=0, id="call_1", - function=_FakeAttr(name="Read", arguments="{}"), - )], - ), - finish_reason="tool_calls", - )], - usage=None, - ) - - p.client.chat.completions.create = AsyncMock(return_value=fake_stream()) - - events = [] - async for ev in p.stream_message(model="gpt-5.4", system=None, messages=[], tools=[]): - events.append(ev) - - block_types = [e.block_type for e in events if e.type == "content_block_start"] - stops = [e for e in events if e.type == "content_block_stop"] - assert block_types == ["text", "tool_use"] - # Text close fires when tool_call starts; tool_use close fires at finish. - assert len(stops) == 2 - - -async def test_stream_message_includes_usage_options_kwargs(): - """`stream_options.include_usage` MUST be passed so the final - chunk carries token counts.""" - p = _make_provider() - - async def empty_stream(): - if False: - yield None - return - - p.client.chat.completions.create = AsyncMock(return_value=empty_stream()) - - async for _ in p.stream_message(model="gpt-5.4", system=None, messages=[], tools=[]): - pass - - _, kwargs = p.client.chat.completions.create.call_args - assert kwargs["stream"] is True - assert kwargs["stream_options"] == {"include_usage": True} diff --git a/backend/tests/test_providers_registry.py b/backend/tests/test_providers_registry.py deleted file mode 100644 index 3d240835..00000000 --- a/backend/tests/test_providers_registry.py +++ /dev/null @@ -1,616 +0,0 @@ -"""Tests for `backend.apps.agents.providers.registry`. - -The registry is currently 16% covered. This file fills in: - - - `_find_builtin_model` known + unknown - - `get_api_type` over every value in BUILTIN_MODELS + unknown default - - `resolve_model_id_for_sdk` across every routing branch: - - openswarm-pro mode → bare model_id - - direct anthropic_api_key → bare model_id - - explicit `route="cc"` → router_model_id (subscription) - - explicit `route="api"` → bare model_id - - gemini-cli + google_api_key → `gemini/` - - gemini-cli + Antigravity active (mock httpx 200) → `ag/` - - gemini-cli fallthrough → `gc/` - - openai/codex/gemini fallthrough → router_model_id - - unknown short_name → passthrough - - `resolve_aux_model` every priority branch + ValueError fallthrough - - `create_provider` every api_type + 9Router fallback + missing-key raise - - `thinking_params_for(api, level)` full matrix - - `get_available_models` `configured` flag correctness - - `get_context_window` known/custom/default - - `calculate_cost` known + unknown rates + case-insensitive provider - -Live network calls (httpx, 9Router) are fully mocked. -""" - -from __future__ import annotations - -from types import SimpleNamespace -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from backend.apps.agents.providers import registry as reg -from backend.apps.agents.providers.registry import ( - BUILTIN_MODELS, - _find_builtin_model, - _get_api_type, - _has_credentials, - calculate_cost, - create_provider, - get_api_type, - get_available_models, - get_context_window, - resolve_aux_model, - resolve_model_id_for_sdk, - thinking_params_for, -) -from backend.apps.settings.models import AppSettings, CustomProvider - - -# --------------------------------------------------------------------------- -# _find_builtin_model + get_api_type -# --------------------------------------------------------------------------- - - -def test_find_builtin_model_known(): - entry = _find_builtin_model("sonnet") - assert entry is not None - assert entry["api"] == "anthropic" - assert entry["value"] == "sonnet" - - -def test_find_builtin_model_unknown_returns_none(): - assert _find_builtin_model("not-a-real-model") is None - - -def test_get_api_type_unknown_defaults_to_anthropic(): - assert get_api_type("not-a-real-model") == "anthropic" - - -@pytest.mark.parametrize( - "short,expected_api", - [ - ("sonnet", "anthropic"), - ("opus", "anthropic"), - ("haiku", "anthropic"), - ("sonnet-cc", "anthropic"), - ("sonnet-api", "anthropic"), - ("gpt-5.4", "codex"), - ("gpt-5.4-mini", "codex"), - ("gpt-5.4-api", "openai"), - ("gpt-5.3-codex-api", "openai"), - ("gemini-3-pro", "gemini-cli"), - ("gemini-2.5-flash", "gemini-cli"), - ("gemini-3-pro-api", "gemini"), - ("gemini-2.5-flash-api", "gemini"), - ], -) -def test_get_api_type_known_models(short: str, expected_api: str): - assert get_api_type(short) == expected_api - - -# --------------------------------------------------------------------------- -# _get_api_type — provider-name dispatch -# --------------------------------------------------------------------------- - - -@pytest.mark.parametrize( - "name,expected", - [ - ("Anthropic", "anthropic"), - ("OpenAI", "codex"), # the OpenAI tier's first entry uses api=codex - ("Google", "gemini-cli"), - ("anthropic", "anthropic"), # case-insensitive - ("OPENAI", "openai"), - ("google", "gemini"), # lowercase 'google' -> gemini via _API_NAME_MAP - ("openrouter", "openrouter"), - ("UnknownProvider", "openrouter"), # fallthrough default - ], -) -def test_underscore_get_api_type_dispatch(name: str, expected: str): - assert _get_api_type(name) == expected - - -# --------------------------------------------------------------------------- -# resolve_model_id_for_sdk -# --------------------------------------------------------------------------- - - -def test_resolve_unknown_passthrough(): - s = AppSettings() - assert resolve_model_id_for_sdk("not-a-real-model", s) == "not-a-real-model" - - -def test_resolve_route_cc_uses_router_id(): - s = AppSettings() - assert resolve_model_id_for_sdk("sonnet-cc", s) == "cc/claude-sonnet-4-6" - - -def test_resolve_route_api_uses_bare_model_id(): - s = AppSettings() - assert resolve_model_id_for_sdk("sonnet-api", s) == "claude-sonnet-4-6" - assert resolve_model_id_for_sdk("gpt-5.4-api", s) == "gpt-5.4" - - -def test_resolve_anthropic_with_openswarm_pro_returns_bare(): - s = AppSettings(connection_mode="openswarm-pro") - assert resolve_model_id_for_sdk("sonnet", s) == "claude-sonnet-4-6" - - -def test_resolve_anthropic_with_api_key_returns_bare(): - s = AppSettings(anthropic_api_key="sk-test") - assert resolve_model_id_for_sdk("sonnet", s) == "claude-sonnet-4-6" - - -def test_resolve_anthropic_no_creds_returns_router_id(): - """Without anthropic_api_key + own_key mode → 9Router cc/ prefix.""" - s = AppSettings() - assert resolve_model_id_for_sdk("sonnet", s) == "cc/claude-sonnet-4-6" - - -def test_resolve_gemini_cli_with_google_api_key_uses_gemini_prefix(): - """google_api_key set → AI Studio direct path.""" - s = AppSettings(google_api_key="AIza-test") - assert resolve_model_id_for_sdk("gemini-3-pro", s) == "gemini/gemini-3-pro-preview" - assert resolve_model_id_for_sdk("gemini-2.5-pro", s) == "gemini/gemini-2.5-pro" - - -def test_resolve_gemini_cli_antigravity_active_returns_ag_prefix(): - """Without google_api_key but with Antigravity connected on 9Router, - map to ag/.""" - s = AppSettings() - fake_resp = MagicMock() - fake_resp.status_code = 200 - fake_resp.json.return_value = { - "connections": [ - {"provider": "antigravity", "isActive": True}, - ], - } - with patch("httpx.get", return_value=fake_resp): - assert resolve_model_id_for_sdk("gemini-3-pro", s) == "ag/gemini-3.1-pro-high" - assert resolve_model_id_for_sdk("gemini-3-flash", s) == "ag/gemini-3-flash" - - -def test_resolve_gemini_cli_antigravity_active_but_unmapped_falls_through(): - """Even with Antigravity active, models not in the _ANTIGRAVITY_MAP - (gemini-2.5-*) must fall through to gc/.""" - s = AppSettings() - fake_resp = MagicMock() - fake_resp.status_code = 200 - fake_resp.json.return_value = { - "connections": [{"provider": "antigravity", "isActive": True}], - } - with patch("httpx.get", return_value=fake_resp): - assert resolve_model_id_for_sdk("gemini-2.5-pro", s) == "gc/gemini-2.5-pro" - - -def test_resolve_gemini_cli_no_creds_returns_gc(): - """Default fallthrough — no API key, no Antigravity.""" - s = AppSettings() - fake_resp = MagicMock() - fake_resp.status_code = 200 - fake_resp.json.return_value = {"connections": []} - with patch("httpx.get", return_value=fake_resp): - assert resolve_model_id_for_sdk("gemini-3-pro", s) == "gc/gemini-3-pro-preview" - - -def test_resolve_gemini_cli_httpx_exception_falls_through_to_gc(): - """If 9Router probe raises, fail open → gc/ prefix.""" - s = AppSettings() - with patch("httpx.get", side_effect=Exception("boom")): - assert resolve_model_id_for_sdk("gemini-3-pro", s) == "gc/gemini-3-pro-preview" - - -def test_resolve_codex_returns_router_id(): - s = AppSettings() - assert resolve_model_id_for_sdk("gpt-5.4", s) == "cx/gpt-5.4" - - -# --------------------------------------------------------------------------- -# resolve_aux_model -# --------------------------------------------------------------------------- - - -async def test_resolve_aux_model_openswarm_pro_returns_proxy_url(): - s = AppSettings(connection_mode="openswarm-pro", openswarm_proxy_url="https://proxy.test") - model, base_url = await resolve_aux_model(s) - assert "haiku" in model - assert base_url == "https://proxy.test" - - -async def test_resolve_aux_model_openswarm_pro_default_url(): - """If openswarm_proxy_url isn't set, defaults to api.openswarm.com.""" - s = AppSettings(connection_mode="openswarm-pro") - _model, base_url = await resolve_aux_model(s) - assert base_url == "https://api.openswarm.com" - - -async def test_resolve_aux_model_anthropic_api_key_returns_no_base_url(): - s = AppSettings(anthropic_api_key="sk-test") - model, base_url = await resolve_aux_model(s) - assert "haiku" in model - assert base_url is None - - -async def test_resolve_aux_model_sonnet_tier(): - s = AppSettings(anthropic_api_key="sk-test") - model, _ = await resolve_aux_model(s, preferred_tier="sonnet") - assert "sonnet" in model - - -async def test_resolve_aux_model_9router_claude_connection(): - s = AppSettings() - with patch.object(reg, "_9r_running", create=True), \ - patch("backend.apps.nine_router.is_running", return_value=True), \ - patch("backend.apps.nine_router.get_providers", new_callable=AsyncMock, - return_value=[{"provider": "claude", "isActive": True}]): - model, base_url = await resolve_aux_model(s) - assert model.startswith("cc/") - assert base_url == "http://localhost:20128" - - -async def test_resolve_aux_model_9router_codex_connection(): - s = AppSettings() - with patch("backend.apps.nine_router.is_running", return_value=True), \ - patch("backend.apps.nine_router.get_providers", new_callable=AsyncMock, - return_value=[{"provider": "codex", "isActive": True}]): - model, base_url = await resolve_aux_model(s) - assert model == "cx/gpt-5.4-mini" - assert base_url == "http://localhost:20128" - - -async def test_resolve_aux_model_9router_gemini_connection(): - s = AppSettings() - with patch("backend.apps.nine_router.is_running", return_value=True), \ - patch("backend.apps.nine_router.get_providers", new_callable=AsyncMock, - return_value=[{"provider": "gemini-cli", "isActive": True}]): - model, base_url = await resolve_aux_model(s) - assert model == "gc/gemini-2.5-flash" - assert base_url == "http://localhost:20128" - - -async def test_resolve_aux_model_9router_no_connections_raises(): - s = AppSettings() - with patch("backend.apps.nine_router.is_running", return_value=True), \ - patch("backend.apps.nine_router.get_providers", new_callable=AsyncMock, - return_value=[]): - with pytest.raises(ValueError, match="No AI provider connected"): - await resolve_aux_model(s) - - -async def test_resolve_aux_model_9router_not_running_raises(): - s = AppSettings() - with patch("backend.apps.nine_router.is_running", return_value=False): - with pytest.raises(ValueError, match="No AI provider configured"): - await resolve_aux_model(s) - - -# --------------------------------------------------------------------------- -# create_provider -# --------------------------------------------------------------------------- - - -def test_create_provider_anthropic_with_api_key(): - s = AppSettings(anthropic_api_key="sk-test") - p = create_provider("Anthropic", s) - from backend.apps.agents.providers.anthropic import AnthropicProvider - assert isinstance(p, AnthropicProvider) - - -def test_create_provider_anthropic_with_openswarm_pro(): - s = AppSettings( - connection_mode="openswarm-pro", - openswarm_bearer_token="bearer-x", - openswarm_proxy_url="https://proxy.test", - ) - p = create_provider("Anthropic", s) - from backend.apps.agents.providers.anthropic import AnthropicProvider - assert isinstance(p, AnthropicProvider) - - -def test_create_provider_anthropic_falls_back_to_9router(): - s = AppSettings() - with patch.object(reg, "_is_9router_available", return_value=True): - p = create_provider("Anthropic", s) - from backend.apps.agents.providers.openai_compat import OpenAICompatProvider - assert isinstance(p, OpenAICompatProvider) - # The override remaps short names → 9Router prefix - assert p.get_model_id("sonnet") == "cc/claude-sonnet-4-6" - assert p.get_model_id("custom-id") == "cc/custom-id" - assert p.get_model_id("cc/already") == "cc/already" - - -def test_create_provider_anthropic_no_creds_raises(): - s = AppSettings() - with patch.object(reg, "_is_9router_available", return_value=False): - with pytest.raises(ValueError, match="Anthropic API key not configured"): - create_provider("Anthropic", s) - - -def test_create_provider_openai_with_key(): - s = AppSettings(openai_api_key="sk-openai-test") - p = create_provider("OPENAI", s) - from backend.apps.agents.providers.openai_compat import OpenAICompatProvider - assert isinstance(p, OpenAICompatProvider) - - -def test_create_provider_openai_no_key_falls_back_to_9router(): - s = AppSettings() - with patch.object(reg, "_is_9router_available", return_value=True): - p = create_provider("OPENAI", s) - from backend.apps.agents.providers.openai_compat import OpenAICompatProvider - assert isinstance(p, OpenAICompatProvider) - - -def test_create_provider_openai_no_creds_raises(): - s = AppSettings() - with patch.object(reg, "_is_9router_available", return_value=False): - with pytest.raises(ValueError, match="OpenAI API key not configured"): - create_provider("OPENAI", s) - - -def test_create_provider_gemini_branch_imports_gemini_module(): - """The gemini branch imports `backend.apps.agents.providers.gemini`, - which doesn't currently ship in this repo. The branch is therefore - only reachable once that module exists; verify the failure mode is - `ModuleNotFoundError` (not silent), so we'll notice if the module - is added without updating tests.""" - s = AppSettings(google_api_key="AIza-test") - with pytest.raises(ModuleNotFoundError): - create_provider("google", s) - - -def test_create_provider_openrouter_with_key(): - s = AppSettings(openrouter_api_key="sk-or-test") - p = create_provider("openrouter", s) - from backend.apps.agents.providers.openai_compat import OpenAICompatProvider - assert isinstance(p, OpenAICompatProvider) - - -def test_create_provider_openrouter_no_key_falls_back_to_9router(): - s = AppSettings() - with patch.object(reg, "_is_9router_available", return_value=True): - p = create_provider("openrouter", s) - from backend.apps.agents.providers.openai_compat import OpenAICompatProvider - assert isinstance(p, OpenAICompatProvider) - - -def test_create_provider_openrouter_no_creds_raises(): - s = AppSettings() - with patch.object(reg, "_is_9router_available", return_value=False): - with pytest.raises(ValueError, match="OpenRouter API key not configured"): - create_provider("openrouter", s) - - -def test_create_provider_9router_short_circuit(): - """provider_name='9Router' takes the explicit early-return path — - no settings needed.""" - s = AppSettings() - p = create_provider("9Router", s) - from backend.apps.agents.providers.openai_compat import OpenAICompatProvider - assert isinstance(p, OpenAICompatProvider) - - -def test_create_provider_custom_provider_via_provider_config(): - """Inline custom provider definition via the kwarg shortcut. - - Unknown provider names default to api_type='openrouter' — to reach - the `provider_config` branch we patch the api-type lookup to a - value not in the known-api if-chain.""" - s = AppSettings() - with patch.object(reg, "_get_api_type", return_value="custom-other"): - p = create_provider( - "MyCustom", s, - provider_config={"api_key": "k", "base_url": "http://example.invalid/v1"}, - ) - from backend.apps.agents.providers.openai_compat import OpenAICompatProvider - assert isinstance(p, OpenAICompatProvider) - - -def test_create_provider_custom_provider_lookup_in_settings(): - """Same shape as above, but the custom provider lives on settings - and is resolved by name.""" - s = AppSettings(custom_providers=[ - CustomProvider(name="MyCustom", base_url="http://example.invalid/v1", api_key="k"), - ]) - with patch.object(reg, "_get_api_type", return_value="custom-other"): - p = create_provider("MyCustom", s) - from backend.apps.agents.providers.openai_compat import OpenAICompatProvider - assert isinstance(p, OpenAICompatProvider) - - -def test_create_provider_unknown_custom_name_raises(): - """If the provider isn't found among any branch, raise ValueError.""" - s = AppSettings() - with patch.object(reg, "_get_api_type", return_value="custom-other"): - with pytest.raises(ValueError, match="Unknown provider"): - create_provider("NotInSettings", s) - - -def test_create_provider_unknown_provider_raises(): - """No matching api_type, no provider_config, no custom provider → ValueError. - But unknown providers default to api_type='openrouter' so they hit - the openrouter branch first; we set up to reach the final unknown.""" - s = AppSettings(openrouter_api_key="sk-test") - # With openrouter key, "Unknown" provider returns an OpenAI-compat - # adapter via the openrouter branch — not the unknown raise. - p = create_provider("Unknown", s) - from backend.apps.agents.providers.openai_compat import OpenAICompatProvider - assert isinstance(p, OpenAICompatProvider) - - -# --------------------------------------------------------------------------- -# thinking_params_for -# --------------------------------------------------------------------------- - - -@pytest.mark.parametrize( - "api,level,expected", - [ - # auto → adaptive on Claude, defaults elsewhere - ("anthropic", "auto", {"thinking": {"type": "adaptive"}}), - ("codex", "auto", None), - ("gemini-cli", "auto", None), - # off → explicit disable per provider - ("anthropic", "off", {"thinking": {"type": "disabled"}}), - ("codex", "off", {"reasoning": {"effort": "none"}}), - ("gemini-cli", "off", {"thinkingConfig": {"thinkingLevel": "LOW"}}), - # Explicit levels - ("anthropic", "low", {"thinking": {"type": "adaptive"}}), - ("anthropic", "medium", {"thinking": {"type": "adaptive"}}), - ("anthropic", "high", {"thinking": {"type": "adaptive"}}), - ("codex", "low", {"reasoning": {"effort": "low"}}), - ("codex", "medium", {"reasoning": {"effort": "medium"}}), - ("codex", "high", {"reasoning": {"effort": "high"}}), - ("gemini-cli", "low", {"thinkingConfig": {"thinkingLevel": "LOW"}}), - ("gemini-cli", "medium", {"thinkingConfig": {"thinkingLevel": "MEDIUM"}}), - ("gemini-cli", "high", {"thinkingConfig": {"thinkingLevel": "HIGH"}}), - # Unknown api → None - ("openai", "high", None), - ], -) -def test_thinking_params_for(api: str, level: str, expected): - assert thinking_params_for(api, level) == expected - - -# --------------------------------------------------------------------------- -# get_available_models / configured flag / _has_credentials -# --------------------------------------------------------------------------- - - -def test_get_available_models_configured_flag_anthropic(): - """anthropic_api_key set → Anthropic models marked configured.""" - s = AppSettings(anthropic_api_key="sk-test") - out = get_available_models(s) - assert all(m["configured"] for m in out["Anthropic"]) - # Other providers without keys → not configured - assert all(not m["configured"] for m in out["OpenAI"]) - - -def test_get_available_models_configured_flag_openswarm_pro(): - """In openswarm-pro mode, having a bearer token configures Anthropic.""" - s = AppSettings(connection_mode="openswarm-pro", openswarm_bearer_token="bearer-x") - out = get_available_models(s) - assert all(m["configured"] for m in out["Anthropic"]) - - -def test_get_available_models_includes_custom_providers(): - s = AppSettings(custom_providers=[ - CustomProvider( - name="Local", - base_url="http://localhost:8080/v1", - api_key="x", - models=[{"value": "phi-mini", "label": "Phi Mini", "context_window": 32_000}], - ), - ]) - out = get_available_models(s) - assert "Local" in out - assert out["Local"][0]["value"] == "phi-mini" - assert out["Local"][0]["context_window"] == 32_000 - assert out["Local"][0]["configured"] is True - - -def test_has_credentials_unknown_provider_returns_false(): - """Unknown providers default to openrouter api_type, which checks - openrouter_api_key — without it, returns False.""" - s = AppSettings() - assert _has_credentials("Unknown", s) is False - - -# --------------------------------------------------------------------------- -# get_context_window -# --------------------------------------------------------------------------- - - -def test_get_context_window_known_anthropic(): - assert get_context_window("Anthropic", "sonnet") == 1_000_000 - - -def test_get_context_window_known_haiku(): - assert get_context_window("Anthropic", "haiku") == 200_000 - - -def test_get_context_window_unknown_returns_default(): - assert get_context_window("Anthropic", "not-real") == 128_000 - - -def test_get_context_window_custom_provider_lookup(): - s = AppSettings(custom_providers=[ - CustomProvider( - name="L", - base_url="x", - models=[{"value": "m", "context_window": 64_000}], - ), - ]) - assert get_context_window("L", "m", s) == 64_000 - - -def test_get_context_window_custom_provider_via_id_field(): - """Some configs use `id` instead of `value` — both must resolve.""" - s = AppSettings(custom_providers=[ - CustomProvider( - name="L", - base_url="x", - models=[{"id": "m", "context_window": 32_000}], - ), - ]) - assert get_context_window("L", "m", s) == 32_000 - - -# --------------------------------------------------------------------------- -# calculate_cost -# --------------------------------------------------------------------------- - - -def test_calculate_cost_known_rates(): - """Anthropic Sonnet: $3/M input, $15/M output. 1M of each → $18.""" - out = calculate_cost("Anthropic", "sonnet", 1_000_000, 1_000_000) - assert out == 18.0 - - -def test_calculate_cost_case_insensitive_provider(): - out = calculate_cost("anthropic", "sonnet", 1_000_000, 0) - assert out == 3.0 - - -def test_calculate_cost_unknown_returns_zero(): - assert calculate_cost("NobodyKnows", "made-up", 100_000, 50_000) == 0.0 - - -def test_calculate_cost_zero_token_count(): - assert calculate_cost("Anthropic", "sonnet", 0, 0) == 0.0 - - -def test_calculate_cost_subscription_path_zero_cost(): - """Codex / Gemini CLI subscriptions are zero-cost to the user; - confirm calculate_cost surfaces 0 even on heavy usage.""" - assert calculate_cost("OpenAI", "gpt-5.4", 1_000_000, 1_000_000) == 0.0 - assert calculate_cost("Google", "gemini-2.5-pro", 1_000_000, 1_000_000) == 0.0 - - -# --------------------------------------------------------------------------- -# _is_9router_available cache -# --------------------------------------------------------------------------- - - -def test_is_9router_available_caches_for_30s(): - """Two consecutive calls within the 30s window should hit the cache - after the first httpx.get.""" - reg._9router_cache["available"] = None - reg._9router_cache["checked_at"] = 0 - fake_resp = MagicMock(status_code=200) - with patch("httpx.get", return_value=fake_resp) as mock_get: - a = reg._is_9router_available() - b = reg._is_9router_available() - assert a is True and b is True - assert mock_get.call_count == 1 - - -def test_is_9router_available_handles_exception(): - """Network error → cached False so we don't retry on every call.""" - reg._9router_cache["available"] = None - reg._9router_cache["checked_at"] = 0 - with patch("httpx.get", side_effect=Exception("boom")): - assert reg._is_9router_available() is False diff --git a/backend/tests/test_tools_unit.py b/backend/tests/test_tools_unit.py deleted file mode 100644 index 3f132e4c..00000000 --- a/backend/tests/test_tools_unit.py +++ /dev/null @@ -1,696 +0,0 @@ -"""Unit tests for the builtin agent tools. - -These power the native agent loop's tool execution path. Currently 0% -covered because the live CLI uses its own tool implementations. These -tests pin the contract so the native loop can rely on it: - - - `tools/registry`: register/get/get_all/init_tools roster - - `tools/filesystem`: Read (text + image + offset/limit + missing), - Write (creates parent dirs), Edit (exact + multi + replace_all), - Glob (sorted matches + cap), Grep (rg path + Python fallback) - - `tools/system`: Bash (echo + nonzero + timeout), AskUserQuestion - - `tools/web`: WebSearch (mocked DuckDuckGo HTML), WebFetch (mocked - httpx + html stripping + prompt header) -""" - -from __future__ import annotations - -import asyncio -import base64 -import os -from typing import Any -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from backend.apps.agents.tools import registry as registry_mod -from backend.apps.agents.tools.base import BaseTool, ToolContext -from backend.apps.agents.tools.filesystem import ( - EditTool, - GlobTool, - GrepTool, - ReadTool, - WriteTool, - _resolve, -) -from backend.apps.agents.tools.registry import ( - get_all_tool_schemas, - get_all_tools, - get_tool, - init_tools, - register_tool, -) -from backend.apps.agents.tools.system import AskUserQuestionTool, BashTool -from backend.apps.agents.tools.web import WebFetchTool, WebSearchTool - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def _ctx(cwd: str) -> ToolContext: - return ToolContext(cwd=cwd, session_id="test-sess") - - -def _text(blocks: list[dict]) -> str: - """Pull the text content out of a tool result block list.""" - return "".join(b.get("text", "") for b in blocks if b.get("type") == "text") - - -# --------------------------------------------------------------------------- -# tools/registry -# --------------------------------------------------------------------------- - - -def test_registry_init_tools_registers_full_roster(): - """init_tools is run at import time. After import, all builtin - tool names must be present in the registry.""" - init_tools() # idempotent - expected = { - "Read", "Write", "Edit", "Glob", "Grep", - "Bash", "AskUserQuestion", - "WebSearch", "WebFetch", - } - actual = {t.name for t in get_all_tools()} - assert expected.issubset(actual) - - -def test_register_tool_inserts_by_name(): - class FakeTool(BaseTool): - name = "Fake_X" - description = "fake" - - def get_schema(self) -> dict: - return {"type": "object"} - - async def execute(self, input_data, context): - return [{"type": "text", "text": "ok"}] - - register_tool(FakeTool()) - try: - assert get_tool("Fake_X") is not None - assert get_tool("Fake_X").description == "fake" - finally: - registry_mod._TOOLS.pop("Fake_X", None) - - -def test_get_tool_unknown_returns_none(): - assert get_tool("definitely-not-a-tool") is None - - -def test_get_all_tool_schemas_returns_provider_agnostic_shape(): - schemas = get_all_tool_schemas() - assert all(hasattr(s, "name") and hasattr(s, "input_schema") for s in schemas) - by_name = {s.name: s for s in schemas} - # Read tool's schema must require file_path - assert "Read" in by_name - assert by_name["Read"].input_schema["required"] == ["file_path"] - - -# --------------------------------------------------------------------------- -# filesystem._resolve -# --------------------------------------------------------------------------- - - -def test_resolve_relative_path_uses_cwd(tmp_path): - p = _resolve("foo.txt", str(tmp_path)) - assert p == (tmp_path / "foo.txt").resolve() - - -def test_resolve_absolute_path_passthrough(tmp_path): - abs_path = str(tmp_path / "abs.txt") - p = _resolve(abs_path, "/elsewhere") - assert p == (tmp_path / "abs.txt").resolve() - - -# --------------------------------------------------------------------------- -# ReadTool -# --------------------------------------------------------------------------- - - -async def test_read_tool_text_file_returns_numbered_lines(tmp_path): - f = tmp_path / "hello.txt" - f.write_text("line one\nline two\nline three\n") - out = await ReadTool().execute({"file_path": str(f)}, _ctx(str(tmp_path))) - text = _text(out) - assert " 1\tline one" in text - assert " 2\tline two" in text - assert " 3\tline three" in text - - -async def test_read_tool_offset_and_limit(tmp_path): - """offset is 1-based line number; limit caps total lines returned.""" - f = tmp_path / "many.txt" - f.write_text("\n".join(f"row {i}" for i in range(1, 21)) + "\n") - out = await ReadTool().execute( - {"file_path": str(f), "offset": 5, "limit": 3}, - _ctx(str(tmp_path)), - ) - text = _text(out) - lines = [l for l in text.splitlines() if l.strip()] - assert len(lines) == 3 - assert " 5\trow 5" in lines[0] - assert " 7\trow 7" in lines[2] - - -async def test_read_tool_missing_file_returns_error(tmp_path): - out = await ReadTool().execute( - {"file_path": str(tmp_path / "nope.txt")}, - _ctx(str(tmp_path)), - ) - assert "Error: file not found" in _text(out) - - -async def test_read_tool_empty_file_returns_marker(tmp_path): - f = tmp_path / "empty.txt" - f.write_text("") - out = await ReadTool().execute({"file_path": str(f)}, _ctx(str(tmp_path))) - assert "file is empty or offset beyond" in _text(out) - - -async def test_read_tool_directory_path_returns_error(tmp_path): - out = await ReadTool().execute( - {"file_path": str(tmp_path)}, - _ctx(str(tmp_path)), - ) - assert "not a regular file" in _text(out) - - -async def test_read_tool_image_returns_base64_block(tmp_path): - """A PNG-extension file → image content block with base64 data.""" - f = tmp_path / "icon.png" - raw = b"\x89PNG\r\n\x1a\nfake-png-bytes" - f.write_bytes(raw) - - out = await ReadTool().execute({"file_path": str(f)}, _ctx(str(tmp_path))) - assert len(out) == 1 - assert out[0]["type"] == "image" - assert out[0]["source"]["media_type"] == "image/png" - assert out[0]["source"]["data"] == base64.b64encode(raw).decode("ascii") - - -async def test_read_tool_offset_beyond_eof_returns_marker(tmp_path): - f = tmp_path / "short.txt" - f.write_text("only one line\n") - out = await ReadTool().execute( - {"file_path": str(f), "offset": 100}, - _ctx(str(tmp_path)), - ) - assert "file is empty or offset beyond" in _text(out) - - -async def test_read_tool_zero_limit_falls_back_to_default(tmp_path): - """limit<=0 → fall back to default 2000.""" - f = tmp_path / "two.txt" - f.write_text("a\nb\n") - out = await ReadTool().execute( - {"file_path": str(f), "limit": 0}, - _ctx(str(tmp_path)), - ) - text = _text(out) - assert " 1\ta" in text and " 2\tb" in text - - -# --------------------------------------------------------------------------- -# WriteTool -# --------------------------------------------------------------------------- - - -async def test_write_tool_creates_file_and_parent_dirs(tmp_path): - target = tmp_path / "deep" / "nested" / "file.txt" - out = await WriteTool().execute( - {"file_path": str(target), "content": "hello"}, - _ctx(str(tmp_path)), - ) - assert "Successfully wrote 5 bytes" in _text(out) - assert target.read_text() == "hello" - - -async def test_write_tool_overwrites_existing_file(tmp_path): - f = tmp_path / "x.txt" - f.write_text("old") - await WriteTool().execute( - {"file_path": str(f), "content": "new"}, - _ctx(str(tmp_path)), - ) - assert f.read_text() == "new" - - -# --------------------------------------------------------------------------- -# EditTool -# --------------------------------------------------------------------------- - - -async def test_edit_tool_unique_match_replaces(tmp_path): - f = tmp_path / "edit.txt" - f.write_text("hello world") - out = await EditTool().execute( - {"file_path": str(f), "old_string": "world", "new_string": "there"}, - _ctx(str(tmp_path)), - ) - assert "1 replacement" in _text(out) - assert f.read_text() == "hello there" - - -async def test_edit_tool_missing_string_errors(tmp_path): - f = tmp_path / "edit.txt" - f.write_text("nothing") - out = await EditTool().execute( - {"file_path": str(f), "old_string": "missing", "new_string": "x"}, - _ctx(str(tmp_path)), - ) - assert "old_string not found" in _text(out) - - -async def test_edit_tool_multiple_matches_without_replace_all_errors(tmp_path): - f = tmp_path / "edit.txt" - f.write_text("aaaabbbb aaaa") - out = await EditTool().execute( - {"file_path": str(f), "old_string": "aaaa", "new_string": "X"}, - _ctx(str(tmp_path)), - ) - assert "appears 2 times" in _text(out) - # File contents unchanged - assert f.read_text() == "aaaabbbb aaaa" - - -async def test_edit_tool_replace_all_replaces_every_match(tmp_path): - f = tmp_path / "edit.txt" - f.write_text("aaaa-aaaa-aaaa") - out = await EditTool().execute( - { - "file_path": str(f), - "old_string": "aaaa", - "new_string": "X", - "replace_all": True, - }, - _ctx(str(tmp_path)), - ) - assert "3 replacements" in _text(out) - assert f.read_text() == "X-X-X" - - -async def test_edit_tool_missing_file(tmp_path): - out = await EditTool().execute( - {"file_path": str(tmp_path / "nope.txt"), "old_string": "x", "new_string": "y"}, - _ctx(str(tmp_path)), - ) - assert "Error: file not found" in _text(out) - - -# --------------------------------------------------------------------------- -# GlobTool -# --------------------------------------------------------------------------- - - -async def test_glob_tool_matches_files_sorted_by_mtime(tmp_path): - older = tmp_path / "older.py" - older.write_text("a") - newer = tmp_path / "newer.py" - newer.write_text("b") - # Force older to be older than newer - os.utime(older, (1, 1)) - - out = await GlobTool().execute( - {"pattern": "*.py"}, - _ctx(str(tmp_path)), - ) - text = _text(out) - # Newer first - newer_idx = text.find("newer.py") - older_idx = text.find("older.py") - assert newer_idx >= 0 and older_idx >= 0 - assert newer_idx < older_idx - - -async def test_glob_tool_no_matches_returns_marker(tmp_path): - out = await GlobTool().execute( - {"pattern": "*.nonexistent"}, - _ctx(str(tmp_path)), - ) - assert "No files matched" in _text(out) - - -async def test_glob_tool_explicit_path_overrides_cwd(tmp_path): - other = tmp_path / "other-dir" - other.mkdir() - (other / "x.md").write_text("x") - out = await GlobTool().execute( - {"pattern": "*.md", "path": str(other)}, - _ctx(str(tmp_path)), - ) - assert "x.md" in _text(out) - - -async def test_glob_tool_invalid_path_returns_error(tmp_path): - out = await GlobTool().execute( - {"pattern": "*", "path": str(tmp_path / "nope")}, - _ctx(str(tmp_path)), - ) - assert "directory not found" in _text(out) - - -# --------------------------------------------------------------------------- -# GrepTool -# --------------------------------------------------------------------------- - - -async def test_grep_tool_files_with_matches(tmp_path): - a = tmp_path / "a.txt" - a.write_text("the answer is 42") - b = tmp_path / "b.txt" - b.write_text("nothing here") - - out = await GrepTool().execute( - {"pattern": "answer", "path": str(tmp_path)}, - _ctx(str(tmp_path)), - ) - text = _text(out) - assert "a.txt" in text - assert "b.txt" not in text - - -async def test_grep_tool_content_mode_includes_line_numbers(tmp_path): - a = tmp_path / "a.txt" - a.write_text("first line\nthe answer is 42\nthird line\n") - - out = await GrepTool().execute( - {"pattern": "answer", "path": str(tmp_path), "output_mode": "content"}, - _ctx(str(tmp_path)), - ) - text = _text(out) - # rg prints `path:lineno:content`; python fallback uses same shape - assert "answer is 42" in text - - -async def test_grep_tool_count_mode(tmp_path): - a = tmp_path / "a.txt" - a.write_text("answer\nanswer\nnope\nanswer\n") - - out = await GrepTool().execute( - {"pattern": "answer", "path": str(tmp_path), "output_mode": "count"}, - _ctx(str(tmp_path)), - ) - text = _text(out) - assert "3" in text - - -async def test_grep_tool_python_fallback_invalid_regex(tmp_path): - """When ripgrep isn't available and the regex is invalid, the - Python fallback returns a clean error block.""" - # Force the rg attempt to raise FileNotFoundError so we hit fallback. - with patch("asyncio.create_subprocess_exec", side_effect=FileNotFoundError): - out = await GrepTool().execute( - {"pattern": "[unclosed", "path": str(tmp_path)}, - _ctx(str(tmp_path)), - ) - assert "Invalid regex" in _text(out) - - -async def test_grep_tool_python_fallback_no_matches(tmp_path): - a = tmp_path / "x.txt" - a.write_text("nothing relevant") - with patch("asyncio.create_subprocess_exec", side_effect=FileNotFoundError): - out = await GrepTool().execute( - {"pattern": "definitely-not-found", "path": str(tmp_path)}, - _ctx(str(tmp_path)), - ) - assert "No matches found" in _text(out) - - -async def test_grep_tool_python_fallback_path_not_found(tmp_path): - with patch("asyncio.create_subprocess_exec", side_effect=FileNotFoundError): - out = await GrepTool().execute( - {"pattern": "anything", "path": str(tmp_path / "missing")}, - _ctx(str(tmp_path)), - ) - assert "path not found" in _text(out) - - -async def test_grep_tool_python_fallback_glob_filter(tmp_path): - """Glob pattern restricts the file set the fallback scans.""" - (tmp_path / "match.py").write_text("found here") - (tmp_path / "ignored.txt").write_text("found here too") - - with patch("asyncio.create_subprocess_exec", side_effect=FileNotFoundError): - out = await GrepTool().execute( - {"pattern": "found", "path": str(tmp_path), "glob": "*.py"}, - _ctx(str(tmp_path)), - ) - text = _text(out) - assert "match.py" in text - assert "ignored.txt" not in text - - -# --------------------------------------------------------------------------- -# BashTool -# --------------------------------------------------------------------------- - - -async def test_bash_tool_echo_round_trip(tmp_path): - out = await BashTool().execute( - {"command": "echo hello"}, - _ctx(str(tmp_path)), - ) - text = _text(out) - assert "hello" in text - - -async def test_bash_tool_nonzero_exit_includes_code(tmp_path): - out = await BashTool().execute( - {"command": "exit 7"}, - _ctx(str(tmp_path)), - ) - text = _text(out) - assert "Exit code: 7" in text - - -async def test_bash_tool_runs_in_session_cwd(tmp_path): - (tmp_path / "marker.txt").write_text("x") - out = await BashTool().execute( - {"command": "ls"}, - _ctx(str(tmp_path)), - ) - text = _text(out) - assert "marker.txt" in text - - -async def test_bash_tool_timeout_kills_process(tmp_path): - """timeout in milliseconds; passing 50ms forces the timeout path.""" - out = await BashTool().execute( - {"command": "sleep 5", "timeout": 50}, - _ctx(str(tmp_path)), - ) - text = _text(out) - assert "timed out" in text.lower() - - -async def test_bash_tool_empty_output_with_zero_exit_includes_marker(tmp_path): - """Silent commands (e.g. `true`) get a synthetic completion marker.""" - out = await BashTool().execute( - {"command": "true"}, - _ctx(str(tmp_path)), - ) - text = _text(out) - assert "exit code 0" in text - - -def test_bash_tool_truncate_helper_caps_long_output(): - """_truncate adds a marker when the body is >100KB.""" - long = "x" * (101 * 1024) - truncated = BashTool._truncate(long) - assert truncated.endswith("(output truncated)") - - -# --------------------------------------------------------------------------- -# AskUserQuestionTool -# --------------------------------------------------------------------------- - - -async def test_ask_user_question_returns_question_text(): - out = await AskUserQuestionTool().execute( - {"question": "Which file?"}, - _ctx("/tmp"), - ) - assert _text(out) == "Which file?" - - -def test_ask_user_question_schema_requires_question(): - schema = AskUserQuestionTool().get_schema() - assert schema["required"] == ["question"] - - -# --------------------------------------------------------------------------- -# WebSearchTool -# --------------------------------------------------------------------------- - - -def _ddg_html(num: int = 3) -> str: - """Minimal DuckDuckGo HTML result page.""" - blocks = [] - for i in range(num): - blocks.append( - f'' - ) - return "".join(blocks) - - -async def test_web_search_tool_parses_ddg_results(): - fake_resp = MagicMock() - fake_resp.text = _ddg_html(num=2) - fake_resp.raise_for_status = MagicMock() - - fake_client = MagicMock() - fake_client.post = AsyncMock(return_value=fake_resp) - fake_client.__aenter__ = AsyncMock(return_value=fake_client) - fake_client.__aexit__ = AsyncMock(return_value=False) - - with patch("backend.apps.agents.tools.web.httpx.AsyncClient", return_value=fake_client): - out = await WebSearchTool().execute( - {"query": "openswarm"}, - _ctx("/tmp"), - ) - text = _text(out) - assert "[1] Title 0" in text - assert "https://example.com/0" in text - assert "Snippet text 0" in text - - -async def test_web_search_tool_empty_results_returns_marker(): - fake_resp = MagicMock(text="") - fake_resp.raise_for_status = MagicMock() - fake_client = MagicMock() - fake_client.post = AsyncMock(return_value=fake_resp) - fake_client.__aenter__ = AsyncMock(return_value=fake_client) - fake_client.__aexit__ = AsyncMock(return_value=False) - - with patch("backend.apps.agents.tools.web.httpx.AsyncClient", return_value=fake_client): - out = await WebSearchTool().execute( - {"query": "no-such-thing"}, - _ctx("/tmp"), - ) - assert "No search results" in _text(out) - - -async def test_web_search_tool_exception_returns_error(): - fake_client = MagicMock() - fake_client.post = AsyncMock(side_effect=RuntimeError("boom")) - fake_client.__aenter__ = AsyncMock(return_value=fake_client) - fake_client.__aexit__ = AsyncMock(return_value=False) - - with patch("backend.apps.agents.tools.web.httpx.AsyncClient", return_value=fake_client): - out = await WebSearchTool().execute( - {"query": "x"}, - _ctx("/tmp"), - ) - assert "Web search error" in _text(out) - - -async def test_web_search_tool_num_results_caps_returned_entries(): - fake_resp = MagicMock(text=_ddg_html(num=10)) - fake_resp.raise_for_status = MagicMock() - fake_client = MagicMock() - fake_client.post = AsyncMock(return_value=fake_resp) - fake_client.__aenter__ = AsyncMock(return_value=fake_client) - fake_client.__aexit__ = AsyncMock(return_value=False) - - with patch("backend.apps.agents.tools.web.httpx.AsyncClient", return_value=fake_client): - out = await WebSearchTool().execute( - {"query": "x", "num_results": 2}, - _ctx("/tmp"), - ) - text = _text(out) - assert "[1]" in text - assert "[2]" in text - assert "[3]" not in text - - -# --------------------------------------------------------------------------- -# WebFetchTool -# --------------------------------------------------------------------------- - - -async def test_web_fetch_tool_strips_html_to_plain_text(): - fake_resp = MagicMock() - fake_resp.text = "

Hello world

" - fake_resp.headers = {"content-type": "text/html"} - fake_resp.raise_for_status = MagicMock() - - fake_client = MagicMock() - fake_client.get = AsyncMock(return_value=fake_resp) - fake_client.__aenter__ = AsyncMock(return_value=fake_client) - fake_client.__aexit__ = AsyncMock(return_value=False) - - with patch("backend.apps.agents.tools.web.httpx.AsyncClient", return_value=fake_client): - out = await WebFetchTool().execute( - {"url": "https://example.com"}, - _ctx("/tmp"), - ) - text = _text(out) - assert "Contents of https://example.com" in text - assert "Hello" in text - assert "world" in text - assert "