From dc0f25c53b432a9174847a5206f40c82a763cfa2 Mon Sep 17 00:00:00 2001 From: Ed_ Date: Thu, 11 Jun 2026 10:43:56 -0400 Subject: [PATCH] test(ai_client): add red tests for run_with_tool_loop shared helper 5 Red tests in tests/test_ai_client_tool_loop.py verify the planned run_with_tool_loop contract (no-tool-call fast path, tool-call dispatch, max-rounds safety, history append, error tolerance). Deviation from plan: tests patch src.ai_client.send_openai_compatible (plan's Task 1.1 had src.tool_loop.send_openai_compatible). The plan predates the AGENTS.md HARD RULE on src/.py files; per the follow-up track's Naming Convention section, run_with_tool_loop lives IN src/ai_client.py. The function body imports send_openai_compatible from src.openai_compatible, so src.ai_client.send_openai_compatible is the correct patch path. state.toml: current_phase 0 -> 1, phase_1 pending -> in_progress, t1_1 pending -> in_progress, blocked_by status phase_6_in_progress -> phase_6_complete (parent's Phase 6 checkpointed at 064cb26). Confirmed red: 5 ImportError against src.ai_client.run_with_tool_loop at collection time. --- .../state.toml | 9 +- tests/test_ai_client_tool_loop.py | 109 ++++++++++++++++++ 2 files changed, 114 insertions(+), 4 deletions(-) create mode 100644 tests/test_ai_client_tool_loop.py diff --git a/conductor/tracks/qwen_llama_grok_followup_20260611/state.toml b/conductor/tracks/qwen_llama_grok_followup_20260611/state.toml index d9a5996c..0631456b 100644 --- a/conductor/tracks/qwen_llama_grok_followup_20260611/state.toml +++ b/conductor/tracks/qwen_llama_grok_followup_20260611/state.toml @@ -5,15 +5,16 @@ track_id = "qwen_llama_grok_followup_20260611" name = "Qwen/Llama/Grok Follow-Up (tool loop, PROVIDERS move, UX adaptations 2-9, local-first, matrix v2, Anthropic/Gemini/DeepSeek migration)" status = "active" -current_phase = 0 +current_phase = 1 last_updated = "2026-06-11" [blocked_by] # This follow-up is blocked on the parent track's Phase 6 (docs) completing. -qwen_llama_grok_integration_20260606 = "phase_6_in_progress" +# Resolved 2026-06-11 (parent Phase 6 checkpoint sha 064cb26). +qwen_llama_grok_integration_20260606 = "phase_6_complete" [phases] -phase_1 = { status = "pending", checkpoint_sha = "", name = "Tool loop lift (run_with_tool_loop helper for 8 vendors)" } +phase_1 = { status = "in_progress", checkpoint_sha = "", name = "Tool loop lift (run_with_tool_loop helper for 8 vendors)" } phase_2 = { status = "pending", checkpoint_sha = "", name = "PROVIDERS move (out of src/models.py)" } phase_3 = { status = "pending", checkpoint_sha = "", name = "UX adaptations 2-9 (8 of 9 deferred from parent Phase 5)" } phase_4 = { status = "pending", checkpoint_sha = "", name = "Local-first + matrix v2 expansion (12 new fields)" } @@ -21,7 +22,7 @@ phase_5 = { status = "pending", checkpoint_sha = "", name = "Anthropic/Gemini/De [tasks] # Phase 1: Tool loop lift -t1_1 = { status = "pending", commit_sha = "", description = "Read tool-loop patterns in _send_minimax + the 4 inline-loop vendors" } +t1_1 = { status = "in_progress", commit_sha = "", description = "Read tool-loop patterns in _send_minimax + the 4 inline-loop vendors" } t1_2 = { status = "pending", commit_sha = "", description = "Design run_with_tool_loop helper signature" } t1_3 = { status = "pending", commit_sha = "", description = "Red: 5 tests for run_with_tool_loop in tests/test_tool_loop.py" } t1_4 = { status = "pending", commit_sha = "", description = "Green: implement run_with_tool_loop in src/ai_client.py" } diff --git a/tests/test_ai_client_tool_loop.py b/tests/test_ai_client_tool_loop.py new file mode 100644 index 00000000..ee1b49c1 --- /dev/null +++ b/tests/test_ai_client_tool_loop.py @@ -0,0 +1,109 @@ +"""Tests for src.ai_client.run_with_tool_loop (shared tool-loop helper). + +5 Red tests. They verify: +1. No-tool-call path: returns immediately after one send. +2. Tool-call dispatch: dispatches via _execute_tool_calls_concurrently and + continues the loop. +3. Max-rounds safety: bails out after MAX_TOOL_ROUNDS + 2 iterations. +4. History append: appends an assistant message to the caller's history. +5. Error tolerance: continues even if a tool errors. + +The helper lives in src.ai_client (per the AGENTS.md HARD RULE: no new +src/.py files). The tests patch src.ai_client.send_openai_compatible +because that's the symbol the function uses internally. +""" +from __future__ import annotations +from typing import Any +from unittest.mock import MagicMock, patch +import pytest +from src.openai_compatible import NormalizedResponse, OpenAICompatibleRequest +from src.ai_client import run_with_tool_loop +from src.vendor_capabilities import VendorCapabilities + +@pytest.fixture +def caps() -> VendorCapabilities: + return VendorCapabilities(vendor="test", model="test-model", tool_calling=True, context_window=8192) + +def _make_normalized_response(text: str = "ok", tool_calls: list[dict[str, Any]] | None = None) -> NormalizedResponse: + return NormalizedResponse( + text=text, tool_calls=tool_calls or [], + usage_input_tokens=10, usage_output_tokens=5, + usage_cache_read_tokens=0, usage_cache_creation_tokens=0, + raw_response=None, + ) + +def test_run_with_tool_loop_no_tool_calls_returns_immediately(caps: VendorCapabilities) -> None: + client = MagicMock() + with patch("src.ai_client.send_openai_compatible", return_value=_make_normalized_response("hello")) as call: + result = run_with_tool_loop( + client, OpenAICompatibleRequest(messages=[{"role": "user", "content": "x"}], model="m"), + capabilities=caps, + pre_tool_callback=None, qa_callback=None, patch_callback=None, + base_dir=".", vendor_name="test", history_lock=None, history=None, + ) + assert result == "hello" + assert call.call_count == 1 + +def test_run_with_tool_loop_dispatches_tool_calls(caps: VendorCapabilities) -> None: + client = MagicMock() + tool_response = _make_normalized_response( + "first response", tool_calls=[{"id": "c1", "type": "function", "function": {"name": "read_file", "arguments": "{}"}}] + ) + final_response = _make_normalized_response("after tool") + with patch("src.ai_client.send_openai_compatible", side_effect=[tool_response, final_response]) as call, \ + patch("src.ai_client._execute_tool_calls_concurrently", return_value=[("read_file", "c1", "result", "")]) as dispatch: + result = run_with_tool_loop( + client, OpenAICompatibleRequest(messages=[{"role": "user", "content": "x"}], model="m"), + capabilities=caps, + pre_tool_callback=None, qa_callback=None, patch_callback=None, + base_dir=".", vendor_name="test", history_lock=None, history=None, + ) + assert result == "after tool" + assert call.call_count == 2 + assert dispatch.call_count == 1 + +def test_run_with_tool_loop_respects_max_rounds(caps: VendorCapabilities) -> None: + client = MagicMock() + infinite_tool_response = _make_normalized_response( + "loop", tool_calls=[{"id": "c1", "type": "function", "function": {"name": "noop", "arguments": "{}"}}] + ) + with patch("src.ai_client.send_openai_compatible", return_value=infinite_tool_response), \ + patch("src.ai_client._execute_tool_calls_concurrently", return_value=[("noop", "c1", "result", "")]): + result = run_with_tool_loop( + client, OpenAICompatibleRequest(messages=[{"role": "user", "content": "x"}], model="m"), + capabilities=caps, + pre_tool_callback=None, qa_callback=None, patch_callback=None, + base_dir=".", vendor_name="test", history_lock=None, history=None, + ) + assert result == "loop" + +def test_run_with_tool_loop_appends_to_history(caps: VendorCapabilities) -> None: + client = MagicMock() + history: list[dict[str, Any]] = [] + history_lock = MagicMock() + history_lock.__enter__ = MagicMock(return_value=history_lock) + history_lock.__exit__ = MagicMock(return_value=False) + with patch("src.ai_client.send_openai_compatible", return_value=_make_normalized_response("hi")): + run_with_tool_loop( + client, OpenAICompatibleRequest(messages=[{"role": "user", "content": "x"}], model="m"), + capabilities=caps, + pre_tool_callback=None, qa_callback=None, patch_callback=None, + base_dir=".", vendor_name="test", history_lock=history_lock, history=history, + ) + assert any(msg.get("role") == "assistant" and msg.get("content") == "hi" for msg in history) + +def test_run_with_tool_loop_does_not_crash_on_tool_error(caps: VendorCapabilities) -> None: + client = MagicMock() + tool_response = _make_normalized_response( + "err", tool_calls=[{"id": "c1", "type": "function", "function": {"name": "fail", "arguments": "{}"}}] + ) + final_response = _make_normalized_response("recovered") + with patch("src.ai_client.send_openai_compatible", side_effect=[tool_response, final_response]), \ + patch("src.ai_client._execute_tool_calls_concurrently", return_value=[("fail", "c1", "", "ToolExecutionError")]): + result = run_with_tool_loop( + client, OpenAICompatibleRequest(messages=[{"role": "user", "content": "x"}], model="m"), + capabilities=caps, + pre_tool_callback=None, qa_callback=None, patch_callback=None, + base_dir=".", vendor_name="test", history_lock=None, history=None, + ) + assert result == "recovered"