Spaces:
Running
Running
| """Tests for react_step() MCP tool — single ReAct iteration primitive. | |
| Per ADR-006: MCP tool tests mock the adapter layer. | |
| react_step() calls complete_stream(), so we mock that. | |
| """ | |
| import json | |
| import pytest | |
| from unittest.mock import patch | |
| from prompt_prix.react.schemas import ToolCall, ReActIteration | |
| # ───────────────────────────────────────────────────────────────────── | |
| # HELPERS | |
| # ───────────────────────────────────────────────────────────────────── | |
| def _tool_call_sentinel(name: str, arguments: dict) -> str: | |
| """Build a __TOOL_CALLS__ sentinel string.""" | |
| return f"__TOOL_CALLS__:{json.dumps([{'name': name, 'arguments': json.dumps(arguments)}])}" | |
| def _make_stream(*chunks): | |
| """Create an async generator that yields chunks then a latency sentinel.""" | |
| async def stream(**kwargs): | |
| for chunk in chunks: | |
| yield chunk | |
| yield "__LATENCY_MS__:100" | |
| return stream | |
| # ───────────────────────────────────────────────────────────────────── | |
| # MOCK TOOL DISPATCH TESTS | |
| # ───────────────────────────────────────────────────────────────────── | |
| class TestDispatchMock: | |
| """Test dispatch_mock() resolution logic.""" | |
| def test_exact_args_match(self): | |
| from prompt_prix.mcp.tools.react_step import dispatch_mock | |
| mock_tools = { | |
| "read_file": { | |
| json.dumps({"path": "./1.txt"}, sort_keys=True): "File contents here" | |
| } | |
| } | |
| result = dispatch_mock("read_file", {"path": "./1.txt"}, mock_tools) | |
| assert result == "File contents here" | |
| def test_first_arg_value_match(self): | |
| from prompt_prix.mcp.tools.react_step import dispatch_mock | |
| mock_tools = { | |
| "read_file": { | |
| "./1.txt": "File contents here" | |
| } | |
| } | |
| result = dispatch_mock("read_file", {"path": "./1.txt"}, mock_tools) | |
| assert result == "File contents here" | |
| def test_default_fallback(self): | |
| from prompt_prix.mcp.tools.react_step import dispatch_mock | |
| mock_tools = { | |
| "move_file": { | |
| "_default": "File moved" | |
| } | |
| } | |
| result = dispatch_mock("move_file", {"src": "a.txt", "dst": "b/"}, mock_tools) | |
| assert result == "File moved" | |
| def test_no_match_returns_error(self): | |
| from prompt_prix.mcp.tools.react_step import dispatch_mock | |
| mock_tools = { | |
| "read_file": { | |
| "./known.txt": "Known content" | |
| } | |
| } | |
| result = dispatch_mock("read_file", {"path": "./unknown.txt"}, mock_tools) | |
| assert "Error" in result | |
| assert "read_file" in result | |
| def test_unknown_tool_returns_error(self): | |
| from prompt_prix.mcp.tools.react_step import dispatch_mock | |
| mock_tools = {} | |
| result = dispatch_mock("nonexistent", {"arg": "val"}, mock_tools) | |
| assert "Error" in result | |
| # ───────────────────────────────────────────────────────────────────── | |
| # MESSAGE BUILDING TESTS | |
| # ───────────────────────────────────────────────────────────────────── | |
| class TestBuildMessages: | |
| """Test build_react_messages() trace serialization.""" | |
| def test_empty_trace(self): | |
| from prompt_prix.mcp.tools.react_step import build_react_messages | |
| msgs = build_react_messages("You are helpful.", "Do the thing.", []) | |
| assert len(msgs) == 2 | |
| assert msgs[0]["role"] == "system" | |
| assert msgs[1]["role"] == "user" | |
| def test_trace_produces_assistant_tool_pairs(self): | |
| from prompt_prix.mcp.tools.react_step import build_react_messages | |
| trace = [ | |
| ReActIteration( | |
| iteration=1, | |
| tool_call=ToolCall(id="call_1", name="read_file", args={"path": "x"}), | |
| observation="file data", | |
| success=True, | |
| thought="Let me read this file", | |
| ) | |
| ] | |
| msgs = build_react_messages("sys", "goal", trace) | |
| assert len(msgs) == 4 # system, user, assistant, tool | |
| assert msgs[2]["role"] == "assistant" | |
| assert msgs[2]["tool_calls"][0]["function"]["name"] == "read_file" | |
| assert msgs[3]["role"] == "tool" | |
| assert msgs[3]["content"] == "file data" | |
| assert msgs[3]["tool_call_id"] == "call_1" | |
| # ───────────────────────────────────────────────────────────────────── | |
| # REACT_STEP SINGLE-ITERATION TESTS | |
| # ───────────────────────────────────────────────────────────────────── | |
| class TestReactStep: | |
| """Tests for react_step() — one model call, one result.""" | |
| async def test_model_completes_with_text(self): | |
| """Model responds with text only (no tool calls) → completed.""" | |
| async def mock_stream(**kwargs): | |
| yield "The answer is 42." | |
| yield "__LATENCY_MS__:50" | |
| with patch("prompt_prix.mcp.tools.react_step.complete_stream", side_effect=mock_stream): | |
| from prompt_prix.mcp.tools.react_step import react_step | |
| result = await react_step( | |
| model_id="test-model", | |
| system_prompt="sys", | |
| initial_message="What is the answer?", | |
| trace=[], | |
| mock_tools={}, | |
| tools=[], | |
| ) | |
| assert result["completed"] is True | |
| assert result["final_response"] == "The answer is 42." | |
| assert result["new_iterations"] == [] | |
| assert result["latency_ms"] == 50.0 | |
| async def test_model_makes_tool_call(self): | |
| """Model makes a tool call → returns new iteration, not completed.""" | |
| sentinel = _tool_call_sentinel("read_file", {"path": "./1.txt"}) | |
| async def mock_stream(**kwargs): | |
| yield "I'll read the file." | |
| yield sentinel | |
| yield "__LATENCY_MS__:80" | |
| with patch("prompt_prix.mcp.tools.react_step.complete_stream", side_effect=mock_stream): | |
| from prompt_prix.mcp.tools.react_step import react_step | |
| result = await react_step( | |
| model_id="test-model", | |
| system_prompt="sys", | |
| initial_message="Read the file", | |
| trace=[], | |
| mock_tools={"read_file": {"./1.txt": "File contents here"}}, | |
| tools=[{"type": "function", "function": {"name": "read_file"}}], | |
| ) | |
| assert result["completed"] is False | |
| assert result["final_response"] is None | |
| assert len(result["new_iterations"]) == 1 | |
| iteration = result["new_iterations"][0] | |
| assert isinstance(iteration, ReActIteration) | |
| assert iteration.tool_call.name == "read_file" | |
| assert iteration.observation == "File contents here" | |
| assert iteration.success is True | |
| assert iteration.thought == "I'll read the file." | |
| async def test_garbled_tool_args(self): | |
| """Model produces unparseable tool args → invalid iteration.""" | |
| async def mock_stream(**kwargs): | |
| yield "I'll read the file." | |
| yield '__TOOL_CALLS__:[{"name":"read_file","arguments":"not valid json"}]' | |
| yield "__LATENCY_MS__:50" | |
| with patch("prompt_prix.mcp.tools.react_step.complete_stream", side_effect=mock_stream): | |
| from prompt_prix.mcp.tools.react_step import react_step | |
| result = await react_step( | |
| model_id="test-model", | |
| system_prompt="sys", | |
| initial_message="Read it", | |
| trace=[], | |
| mock_tools={"read_file": {"./1.txt": "data"}}, | |
| tools=[{"type": "function", "function": {"name": "read_file"}}], | |
| ) | |
| assert result["completed"] is False | |
| assert len(result["new_iterations"]) == 1 | |
| assert result["new_iterations"][0].success is False | |
| assert "Error" in result["new_iterations"][0].observation | |
| async def test_call_counter_threads_through(self): | |
| """call_counter increments and returns updated value.""" | |
| sentinel = _tool_call_sentinel("read_file", {"path": "./1.txt"}) | |
| async def mock_stream(**kwargs): | |
| yield sentinel | |
| yield "__LATENCY_MS__:50" | |
| with patch("prompt_prix.mcp.tools.react_step.complete_stream", side_effect=mock_stream): | |
| from prompt_prix.mcp.tools.react_step import react_step | |
| result = await react_step( | |
| model_id="test-model", | |
| system_prompt="sys", | |
| initial_message="Read", | |
| trace=[], | |
| mock_tools={"read_file": {"_default": "data"}}, | |
| tools=[{"type": "function", "function": {"name": "read_file"}}], | |
| call_counter=5, | |
| ) | |
| assert result["call_counter"] == 6 | |
| assert result["new_iterations"][0].tool_call.id == "call_6" | |
| async def test_trace_passed_to_message_builder(self): | |
| """Previous trace entries are included in messages sent to model.""" | |
| existing_trace = [ | |
| ReActIteration( | |
| iteration=1, | |
| tool_call=ToolCall(id="call_1", name="list_dir", args={"path": "."}), | |
| observation="file1.txt\nfile2.txt", | |
| success=True, | |
| ) | |
| ] | |
| async def mock_stream(**kwargs): | |
| # Verify trace was included in messages | |
| messages = kwargs.get("messages", []) | |
| assert len(messages) == 4 # system, user, assistant, tool | |
| assert messages[2]["role"] == "assistant" | |
| assert messages[3]["role"] == "tool" | |
| yield "All done." | |
| yield "__LATENCY_MS__:30" | |
| with patch("prompt_prix.mcp.tools.react_step.complete_stream", side_effect=mock_stream): | |
| from prompt_prix.mcp.tools.react_step import react_step | |
| result = await react_step( | |
| model_id="test-model", | |
| system_prompt="sys", | |
| initial_message="List and report", | |
| trace=existing_trace, | |
| mock_tools={}, | |
| tools=[], | |
| call_counter=1, | |
| ) | |
| assert result["completed"] is True | |
| assert result["final_response"] == "All done." | |