Files
opensquilla--opensquilla/tests/test_engine/test_agent_invalid_provider_response.py
2026-07-13 13:12:33 +08:00

1513 lines
50 KiB
Python

from __future__ import annotations
import json
import subprocess
from collections.abc import AsyncIterator
from types import SimpleNamespace
from typing import Any
import pytest
import structlog.testing
from opensquilla.engine import Agent, AgentConfig, ThinkingLevel, ToolResult
from opensquilla.engine.runtime import TurnRunner
from opensquilla.engine.usage import UsageTracker
from opensquilla.provider import (
ChatConfig,
Message,
ModelCapabilities,
ToolDefinition,
ToolInputSchema,
)
from opensquilla.provider import DoneEvent as ProviderDone
from opensquilla.provider import TextDeltaEvent as ProviderText
from opensquilla.provider import ToolUseEndEvent as ProviderToolUseEnd
from opensquilla.provider import ToolUseStartEvent as ProviderToolUseStart
from opensquilla.session.manager import SessionManager
from opensquilla.session.storage import SessionStorage
from opensquilla.tools.dispatch import build_tool_handler
from opensquilla.tools.registry import ToolRegistry
from opensquilla.tools.types import CallerKind, ToolContext, ToolSpec
class _SequenceProvider:
provider_name = "fake"
def __init__(self, streams: list[list[Any]]) -> None:
self.streams = streams
self.calls: list[dict[str, Any]] = []
def chat(
self,
messages: list[Message],
tools: list[Any] | None = None,
config: ChatConfig | None = None,
) -> AsyncIterator[Any]:
index = len(self.calls)
self.calls.append({"messages": messages, "tools": tools, "config": config})
events = self.streams[index] if index < len(self.streams) else self.streams[-1]
return self._stream(events)
async def _stream(self, events: list[Any]) -> AsyncIterator[Any]:
for event in events:
yield event
async def list_models(self) -> list[Any]:
return []
class _FallbackSequenceProvider(_SequenceProvider):
def __init__(self, streams: list[list[Any]]) -> None:
super().__init__(streams)
self.fallback_reasons: list[str] = []
def fallback_after_invalid_response(self, reason: str) -> bool:
self.fallback_reasons.append(reason)
return True
def _large_reasoning_only_done() -> ProviderDone:
return ProviderDone(
stop_reason="stop",
input_tokens=35_000,
output_tokens=2,
reasoning_tokens=2,
reasoning_content="internal",
)
class _SelectorClone:
def __init__(self, provider: _SequenceProvider) -> None:
self.provider = provider
self.current_config = SimpleNamespace(model="fake-model")
def resolve(self) -> _SequenceProvider:
return self.provider
def override_model(self, model: str) -> None:
self.current_config.model = model
def next_fallback_after_failure(self, primary_failure: Exception) -> _SequenceProvider:
raise IndexError("No fallback configured")
class _ProviderSelector:
def __init__(self, provider: _SequenceProvider) -> None:
self.provider = provider
def clone(self) -> _SelectorClone:
return _SelectorClone(self.provider)
class _CacheReport:
break_detected = False
def to_log_dict(self) -> dict[str, Any]:
return {}
@pytest.mark.asyncio
async def test_final_done_returns_openrouter_deepseek_reasoning_content() -> None:
provider = _SequenceProvider(
[
[
ProviderText(text="ok"),
ProviderDone(
stop_reason="stop",
input_tokens=10,
output_tokens=1,
reasoning_tokens=4,
reasoning_content="I reasoned through the OpenRouter response.",
model="deepseek/deepseek-v4-flash",
),
]
]
)
agent = Agent(
provider=provider,
config=AgentConfig(
thinking=ThinkingLevel.HIGH,
model_id="deepseek/deepseek-v4-flash",
model_capabilities=ModelCapabilities(
supports_reasoning=True,
supports_tools=True,
reasoning_format="openrouter",
),
),
)
events = [event async for event in agent.run_turn("hello")]
done = next(event for event in events if event.kind == "done")
assert done.text == "ok"
assert done.reasoning_content == "I reasoned through the OpenRouter response."
@pytest.mark.asyncio
async def test_reasoning_only_first_turn_retries_without_disabling_thinking() -> None:
provider = _SequenceProvider(
[
[
ProviderDone(
stop_reason="stop",
input_tokens=10,
output_tokens=5,
reasoning_tokens=5,
reasoning_content="internal reasoning",
model="z-ai/glm-5.1",
)
],
[
ProviderText(text="ok"),
ProviderDone(
stop_reason="stop",
input_tokens=11,
output_tokens=1,
model="z-ai/glm-5.1",
),
],
]
)
usage = UsageTracker()
agent = Agent(
provider=provider,
config=AgentConfig(
thinking=ThinkingLevel.MEDIUM,
retry_base_backoff_ms=0,
retry_max_backoff_ms=0,
),
usage_tracker=usage,
session_key="agent:test:reasoning-only",
)
events = [event async for event in agent.run_turn("hello")]
assert [event.kind for event in events if event.kind == "error"] == []
assert any(
event.kind == "warning" and event.code == "provider_reasoning_only_retry"
for event in events
)
warning = next(
event
for event in events
if event.kind == "warning" and event.code == "provider_reasoning_only_retry"
)
assert "thinking disabled" not in warning.message
done = next(event for event in events if event.kind == "done")
assert done.text == "ok"
assert done.input_tokens == 21
assert done.output_tokens == 6
assert done.reasoning_tokens == 5
assert len(provider.calls) == 2
assert provider.calls[0]["config"].thinking is True
assert provider.calls[1]["config"].thinking is True
assert provider.calls[1]["config"].thinking_level == ThinkingLevel.MEDIUM
assert provider.calls[1]["config"].thinking_budget_tokens > 0
tracked = usage.get("agent:test:reasoning-only")
assert tracked is not None
assert tracked.input_tokens == 21
assert tracked.output_tokens == 6
assistant_messages = [msg for msg in agent._history if msg.role == "assistant"]
assert len(assistant_messages) == 1
assert assistant_messages[0].content[0].text == "ok"
assert assistant_messages[0].reasoning_content is None
@pytest.mark.asyncio
async def test_reasoning_only_prefill_recovery_cleans_synthetic_history(tmp_path) -> None:
provider = _SequenceProvider(
[
[
ProviderDone(
stop_reason="stop",
input_tokens=10,
output_tokens=5,
reasoning_tokens=5,
reasoning_content="internal reasoning",
model="openrouter/test-reasoning",
)
],
[
ProviderText(text="ok"),
ProviderDone(
stop_reason="stop",
input_tokens=11,
output_tokens=1,
model="openrouter/test-reasoning",
),
],
]
)
runtime_events_path = tmp_path / "runtime_events.jsonl"
agent = Agent(
provider=provider,
config=AgentConfig(
thinking=ThinkingLevel.MEDIUM,
model_capabilities=ModelCapabilities(
supports_reasoning=True,
supports_tools=True,
reasoning_format="openrouter",
),
reasoning_prefill_recovery_mode="recover",
runtime_events_path=str(runtime_events_path),
retry_base_backoff_ms=0,
retry_max_backoff_ms=0,
),
)
events = [event async for event in agent.run_turn("hello")]
assert any(
event.kind == "warning" and event.code == "provider_reasoning_prefill_continue"
for event in events
)
assert not any(
event.kind == "warning" and event.code == "provider_reasoning_only_retry"
for event in events
)
assert any(event.kind == "done" and event.text == "ok" for event in events)
assert len(provider.calls) == 2
assert any(
msg.role == "assistant" and msg.reasoning_content == "internal reasoning"
for msg in provider.calls[1]["messages"]
)
assistant_messages = [msg for msg in agent._history if msg.role == "assistant"]
assert len(assistant_messages) == 1
assert assistant_messages[0].content[0].text == "ok"
assert assistant_messages[0].reasoning_content is None
logged = [json.loads(line) for line in runtime_events_path.read_text().splitlines()]
recovery_event = next(
event for event in logged if event.get("mechanism") == "reasoning_prefill_recovery"
)
assert recovery_event["injected_to_model"] is True
@pytest.mark.asyncio
async def test_tool_loop_observer_logs_reasoning_only_runtime_event(tmp_path) -> None:
provider = _SequenceProvider(
[
[
ProviderDone(
stop_reason="stop",
input_tokens=10,
output_tokens=5,
reasoning_tokens=5,
reasoning_content="internal reasoning",
model="z-ai/glm-5.1",
)
],
[
ProviderText(text="ok"),
ProviderDone(
stop_reason="stop",
input_tokens=11,
output_tokens=1,
model="z-ai/glm-5.1",
),
],
]
)
runtime_events_path = tmp_path / "runtime_events.jsonl"
agent = Agent(
provider=provider,
config=AgentConfig(
thinking=ThinkingLevel.MEDIUM,
retry_base_backoff_ms=0,
retry_max_backoff_ms=0,
tool_loop_observer_mode="log",
runtime_events_path=str(runtime_events_path),
),
session_key="agent:test:runtime-observer",
)
events = [event async for event in agent.run_turn("hello")]
assert any(event.kind == "done" for event in events)
logged = [json.loads(line) for line in runtime_events_path.read_text().splitlines()]
observer_events = [
event for event in logged if event.get("mechanism") == "tool_loop_observer"
]
assert [event["reason"] for event in observer_events] == ["reasoning_only"]
assert observer_events[0]["feature"] == "runtime_observer"
assert observer_events[0]["injected_to_model"] is False
assert observer_events[0]["iteration"] == 1
assert observer_events[0]["session_key"] == "agent:test:runtime-observer"
assert observer_events[0]["evidence"]["post_tool_turn"] is False
assert observer_events[0]["details"]["post_tool_turn"] is False
assert observer_events[0]["details"]["reasoning_tokens"] == 5
assert observer_events[0]["read_files"] == []
assert observer_events[0]["changed_files"] == []
assert observer_events[0]["diff_paths"] == []
assert observer_events[0]["verification_commands"] == []
assert observer_events[0]["hint_text_sha256"] is None
assert observer_events[0]["trigger_confidence"] == "observed_runtime_signal"
assert isinstance(observer_events[0]["created_at"], str)
@pytest.mark.asyncio
async def test_post_tool_empty_recovery_nudges_once_and_cleans_history(tmp_path) -> None:
provider = _SequenceProvider(
[
[
ProviderToolUseStart(tool_use_id="tool-1", tool_name="echo"),
ProviderToolUseEnd(
tool_use_id="tool-1",
tool_name="echo",
arguments={"value": "ok"},
),
ProviderDone(stop_reason="tool_use", input_tokens=3, output_tokens=1),
],
[ProviderDone(stop_reason="stop", input_tokens=4, output_tokens=0)],
[
ProviderText(text="done"),
ProviderDone(stop_reason="stop", input_tokens=5, output_tokens=1),
],
]
)
async def tool_handler(call: Any) -> ToolResult:
return ToolResult(
tool_use_id=call.tool_use_id,
tool_name=call.tool_name,
content="tool ok",
)
runtime_events_path = tmp_path / "runtime_events.jsonl"
agent = Agent(
provider=provider,
config=AgentConfig(
max_iterations=3,
post_tool_empty_recovery_mode="warn_model",
runtime_events_path=str(runtime_events_path),
retry_base_backoff_ms=0,
retry_max_backoff_ms=0,
),
tool_definitions=[
ToolDefinition(
name="echo",
description="Echo.",
input_schema=ToolInputSchema(
properties={"value": {"type": "string"}},
required=["value"],
),
)
],
tool_handler=tool_handler,
)
events = [event async for event in agent.run_turn("hello")]
assert any(event.kind == "done" and event.text == "done" for event in events)
assert any(
event.kind == "warning" and event.code == "post_tool_empty_recovery"
for event in events
)
assert len(provider.calls) == 3
assert any(
msg.role == "user"
and isinstance(msg.content, str)
and msg.content.startswith("[Runtime recovery]")
for msg in provider.calls[2]["messages"]
)
assert not any(
msg.role == "user"
and isinstance(msg.content, str)
and msg.content.startswith("[Runtime recovery]")
for msg in agent._history
)
assert not any(
msg.role == "assistant"
and isinstance(msg.content, list)
and len(msg.content) == 1
and getattr(msg.content[0], "type", None) == "text"
and not msg.content[0].text
for msg in agent._history
)
logged = [json.loads(line) for line in runtime_events_path.read_text().splitlines()]
recovery_event = next(
event for event in logged if event.get("mechanism") == "post_tool_empty_recovery"
)
assert recovery_event["injected_to_model"] is True
def test_tool_loop_observer_diff_paths_preserve_status_path_prefix(tmp_path) -> None:
subprocess.run(["git", "init"], cwd=tmp_path, check=True, capture_output=True)
src = tmp_path / "src"
src.mkdir()
file_path = src / "foo.py"
file_path.write_text("print('old')\n", encoding="utf-8")
subprocess.run(["git", "add", "."], cwd=tmp_path, check=True, capture_output=True)
subprocess.run(
["git", "commit", "-m", "initial"],
cwd=tmp_path,
check=True,
capture_output=True,
env={
"GIT_AUTHOR_NAME": "Test",
"GIT_AUTHOR_EMAIL": "test@example.com",
"GIT_COMMITTER_NAME": "Test",
"GIT_COMMITTER_EMAIL": "test@example.com",
},
)
file_path.write_text("print('new')\n", encoding="utf-8")
agent = Agent(
provider=_SequenceProvider([[ProviderText(text="ok"), ProviderDone(stop_reason="stop")]]),
tool_context=ToolContext(workspace_dir=str(tmp_path)),
)
assert agent._workspace_diff_paths_for_runtime_event() == ["src/foo.py"]
@pytest.mark.asyncio
async def test_final_diff_contract_warn_model_reaches_next_provider_request(
tmp_path,
) -> None:
subprocess.run(["git", "init"], cwd=tmp_path, check=True, capture_output=True)
scratch = tmp_path / "debug_case.php"
scratch.write_text("<?php echo 'scratch';\n", encoding="utf-8")
provider = _SequenceProvider(
[
[
ProviderText(text="ready"),
ProviderDone(stop_reason="stop", input_tokens=3, output_tokens=1),
],
[
ProviderText(text="final"),
ProviderDone(stop_reason="stop", input_tokens=4, output_tokens=1),
],
]
)
runtime_events_path = tmp_path / "runtime_events.jsonl"
agent = Agent(
provider=provider,
config=AgentConfig(
max_iterations=2,
final_diff_contract_mode="warn_model",
runtime_events_path=str(runtime_events_path),
retry_base_backoff_ms=0,
retry_max_backoff_ms=0,
),
session_key="agent:test:final-diff-proof",
tool_context=ToolContext(workspace_dir=str(tmp_path)),
)
events = [event async for event in agent.run_turn("finish")]
assert any(event.kind == "done" and event.text == "final" for event in events)
assert any(
event.kind == "warning" and event.code == "final_diff_contract_recovery"
for event in events
)
assert len(provider.calls) == 2
warning_messages = [
msg
for msg in provider.calls[1]["messages"]
if msg.role == "user"
and isinstance(msg.content, str)
and msg.content.startswith("[Runtime final-diff check]")
]
assert len(warning_messages) == 1
assert "debug_case.php" in warning_messages[0].content
assert "repository diff looks suspicious" in warning_messages[0].content
logged = [json.loads(line) for line in runtime_events_path.read_text().splitlines()]
contract_event = next(
event for event in logged if event.get("feature") == "final_diff_contract"
)
assert contract_event["injected_to_model"] is True
assert contract_event["diff_paths"] == ["debug_case.php"]
@pytest.mark.asyncio
async def test_missing_required_shape_guidance_reaches_next_provider_request() -> None:
provider = _SequenceProvider(
[
[
ProviderToolUseStart(tool_use_id="tool-1", tool_name="write_file"),
ProviderToolUseEnd(
tool_use_id="tool-1",
tool_name="write_file",
arguments={"content": "print('ok')\n"},
),
ProviderDone(stop_reason="tool_use", input_tokens=3, output_tokens=1),
],
[
ProviderText(text="fixed"),
ProviderDone(stop_reason="stop", input_tokens=4, output_tokens=1),
],
]
)
registry = ToolRegistry()
async def write_file(path: str, content: str) -> str:
raise AssertionError("missing required argument preflight should stop dispatch")
registry.register(
ToolSpec(
name="write_file",
description="Write a file.",
parameters={
"path": {"type": "string"},
"content": {"type": "string"},
},
required=["path", "content"],
),
write_file,
)
tool_context = ToolContext(missing_required_argument_shape_guidance=True)
agent = Agent(
provider=provider,
config=AgentConfig(
max_iterations=2,
retry_base_backoff_ms=0,
retry_max_backoff_ms=0,
),
tool_definitions=[
ToolDefinition(
name="write_file",
description="Write a file.",
input_schema=ToolInputSchema(
properties={
"path": {"type": "string"},
"content": {"type": "string"},
},
required=["path", "content"],
),
)
],
tool_handler=build_tool_handler(registry, tool_context),
tool_context=tool_context,
)
events = [event async for event in agent.run_turn("write the file")]
assert any(event.kind == "done" and event.text == "fixed" for event in events)
assert len(provider.calls) == 2
tool_result_contents = [
block.content
for msg in provider.calls[1]["messages"]
if msg.role == "user" and isinstance(msg.content, list)
for block in msg.content
if getattr(block, "tool_use_id", "") == "tool-1"
]
assert len(tool_result_contents) == 1
tool_result_payload = json.loads(tool_result_contents[0])
assert tool_result_payload["error_class"] == "InvalidToolArgumentsError"
assert "You supplied argument(s): `content`." in tool_result_payload["user_message"]
assert "Missing argument(s): `path`." in tool_result_payload["user_message"]
assert 'Valid write_file shape: {"path":"...","content":"..."}' in tool_result_payload[
"user_message"
]
@pytest.mark.asyncio
async def test_reasoning_only_post_tool_turn_retries_without_disabling_thinking() -> None:
provider = _SequenceProvider(
[
[
ProviderToolUseStart(tool_use_id="tool-1", tool_name="echo"),
ProviderToolUseEnd(
tool_use_id="tool-1",
tool_name="echo",
arguments={"value": "ok"},
),
ProviderDone(stop_reason="tool_use", input_tokens=3, output_tokens=1),
],
[
ProviderDone(
stop_reason="stop",
input_tokens=4,
output_tokens=2,
reasoning_tokens=2,
reasoning_content="internal reasoning",
)
],
[
ProviderText(text="done"),
ProviderDone(stop_reason="stop", input_tokens=5, output_tokens=1),
],
]
)
async def tool_handler(call: Any) -> ToolResult:
return ToolResult(
tool_use_id=call.tool_use_id,
tool_name=call.tool_name,
content="tool ok",
)
agent = Agent(
provider=provider,
config=AgentConfig(
thinking=ThinkingLevel.MEDIUM,
max_iterations=2,
retry_base_backoff_ms=0,
retry_max_backoff_ms=0,
),
tool_definitions=[
ToolDefinition(
name="echo",
description="Echo.",
input_schema=ToolInputSchema(
properties={"value": {"type": "string"}},
required=["value"],
),
)
],
tool_handler=tool_handler,
)
events = [event async for event in agent.run_turn("hello")]
assert any(event.kind == "done" and event.text == "done" for event in events)
assert any(
event.kind == "warning" and event.code == "provider_reasoning_only_retry"
for event in events
)
warning = next(
event
for event in events
if event.kind == "warning" and event.code == "provider_reasoning_only_retry"
)
assert "thinking disabled" not in warning.message
assert len(provider.calls) == 3
assert provider.calls[1]["config"].thinking is True
assert provider.calls[2]["config"].thinking is True
assert provider.calls[2]["config"].thinking_level == ThinkingLevel.MEDIUM
@pytest.mark.asyncio
async def test_reasoning_only_retry_restores_thinking_after_retry_call() -> None:
provider = _SequenceProvider(
[
[
ProviderToolUseStart(tool_use_id="tool-1", tool_name="echo"),
ProviderToolUseEnd(
tool_use_id="tool-1",
tool_name="echo",
arguments={"value": "first"},
),
ProviderDone(stop_reason="tool_use", input_tokens=3, output_tokens=1),
],
[
ProviderDone(
stop_reason="stop",
input_tokens=35_000,
output_tokens=2,
reasoning_tokens=2,
reasoning_content="internal reasoning",
)
],
[
ProviderToolUseStart(tool_use_id="tool-2", tool_name="echo"),
ProviderToolUseEnd(
tool_use_id="tool-2",
tool_name="echo",
arguments={"value": "second"},
),
ProviderDone(stop_reason="tool_use", input_tokens=5, output_tokens=1),
],
[
ProviderText(text="done"),
ProviderDone(stop_reason="stop", input_tokens=6, output_tokens=1),
],
]
)
async def tool_handler(call: Any) -> ToolResult:
return ToolResult(
tool_use_id=call.tool_use_id,
tool_name=call.tool_name,
content=f"tool ok: {call.arguments['value']}",
)
agent = Agent(
provider=provider,
config=AgentConfig(
thinking=ThinkingLevel.MEDIUM,
max_iterations=3,
retry_base_backoff_ms=0,
retry_max_backoff_ms=0,
),
tool_definitions=[
ToolDefinition(
name="echo",
description="Echo.",
input_schema=ToolInputSchema(
properties={"value": {"type": "string"}},
required=["value"],
),
)
],
tool_handler=tool_handler,
)
events = [event async for event in agent.run_turn("hello")]
assert any(event.kind == "done" and event.text == "done" for event in events)
assert len(provider.calls) == 4
assert provider.calls[0]["config"].thinking is True
assert provider.calls[1]["config"].thinking is True
assert provider.calls[2]["config"].thinking is False
assert provider.calls[3]["config"].thinking is True
@pytest.mark.asyncio
async def test_reasoning_only_with_thinking_disabled_surfaces_empty_response() -> None:
provider = _SequenceProvider(
[
[
ProviderDone(
stop_reason="stop",
input_tokens=4,
output_tokens=2,
reasoning_tokens=2,
reasoning_content="internal reasoning",
)
]
]
)
agent = Agent(
provider=provider,
config=AgentConfig(thinking=False, retry_base_backoff_ms=0, retry_max_backoff_ms=0),
)
events = [event async for event in agent.run_turn("hello")]
assert len(provider.calls) == 1
assert any(event.kind == "error" and event.code == "empty_response" for event in events)
done = next(event for event in events if event.kind == "done")
assert done.input_tokens == 4
assert done.output_tokens == 2
assert done.reasoning_tokens == 2
@pytest.mark.asyncio
async def test_clean_empty_done_retries_once_then_errors() -> None:
provider = _SequenceProvider(
[
[ProviderDone(stop_reason="stop", input_tokens=3, output_tokens=0)],
[ProviderDone(stop_reason="stop", input_tokens=4, output_tokens=0)],
]
)
agent = Agent(
provider=provider,
config=AgentConfig(
max_provider_retries=1,
retry_base_backoff_ms=0,
retry_max_backoff_ms=0,
),
)
events = [event async for event in agent.run_turn("hello")]
assert len(provider.calls) == 2
assert any(event.kind == "warning" and event.code == "provider_empty_retry" for event in events)
assert any(event.kind == "error" and event.code == "empty_response" for event in events)
done = next(event for event in events if event.kind == "done")
assert done.input_tokens == 7
assert done.output_tokens == 0
@pytest.mark.asyncio
async def test_clean_empty_done_can_switch_to_selector_fallback() -> None:
provider = _FallbackSequenceProvider(
[
[ProviderDone(stop_reason="stop", input_tokens=3, output_tokens=0)],
[
ProviderText(text="ok"),
ProviderDone(stop_reason="stop", input_tokens=4, output_tokens=1),
],
]
)
agent = Agent(
provider=provider,
config=AgentConfig(max_provider_retries=0),
)
events = [event async for event in agent.run_turn("hello")]
assert provider.fallback_reasons == ["malformed_empty"]
assert len(provider.calls) == 2
assert any(event.kind == "done" and event.text == "ok" for event in events)
assert not any(event.kind == "error" for event in events)
@pytest.mark.asyncio
async def test_large_reasoning_only_uses_fallback_before_same_model_retry() -> None:
provider = _FallbackSequenceProvider(
[
[_large_reasoning_only_done()],
[
ProviderText(text="ok"),
ProviderDone(stop_reason="stop", input_tokens=4, output_tokens=1),
],
]
)
agent = Agent(
provider=provider,
config=AgentConfig(
thinking=ThinkingLevel.MEDIUM,
retry_base_backoff_ms=0,
retry_max_backoff_ms=0,
),
)
events = [event async for event in agent.run_turn("hello")]
assert provider.fallback_reasons == ["reasoning_only"]
assert len(provider.calls) == 2
assert not any(
event.kind == "warning" and event.code == "provider_reasoning_only_retry"
for event in events
)
assert any(
event.kind == "warning" and event.code == "provider_large_context_fallback"
for event in events
)
assert any(event.kind == "done" and event.text == "ok" for event in events)
@pytest.mark.asyncio
async def test_large_reasoning_only_without_fallback_retries_once_with_thinking_disabled() -> None:
provider = _SequenceProvider(
[
[_large_reasoning_only_done()],
[
ProviderText(text="ok"),
ProviderDone(stop_reason="stop", input_tokens=4, output_tokens=1),
],
]
)
agent = Agent(
provider=provider,
config=AgentConfig(
thinking=ThinkingLevel.MEDIUM,
max_provider_retries=1,
retry_base_backoff_ms=0,
retry_max_backoff_ms=0,
),
)
events = [event async for event in agent.run_turn("hello")]
assert len(provider.calls) == 2
assert provider.calls[0]["config"].thinking is True
assert provider.calls[1]["config"].thinking is False
assert provider.calls[1]["config"].thinking_level is None
assert provider.calls[1]["config"].thinking_budget_tokens == 0
assert any(
event.kind == "warning" and event.code == "provider_large_context_visible_retry"
for event in events
)
assert not any(
event.kind == "warning" and event.code == "provider_reasoning_only_retry"
for event in events
)
assert not any(event.kind == "error" for event in events)
done = next(event for event in events if event.kind == "done")
assert done.text == "ok"
assert done.input_tokens == 35_004
assert done.output_tokens == 3
assert done.reasoning_tokens == 2
@pytest.mark.asyncio
async def test_large_dashscope_reasoning_only_nudges_before_hard_fail(tmp_path) -> None:
provider = _SequenceProvider(
[
[_large_reasoning_only_done()],
[
ProviderText(text="ok"),
ProviderDone(stop_reason="stop", input_tokens=4, output_tokens=1),
],
]
)
runtime_events_path = tmp_path / "runtime_events.jsonl"
agent = Agent(
provider=provider,
config=AgentConfig(
thinking=ThinkingLevel.MEDIUM,
model_capabilities=ModelCapabilities(
supports_reasoning=True,
supports_tools=True,
reasoning_format="dashscope",
),
reasoning_prefill_recovery_mode="recover",
runtime_events_path=str(runtime_events_path),
retry_base_backoff_ms=0,
retry_max_backoff_ms=0,
),
)
events = [event async for event in agent.run_turn("hello")]
assert len(provider.calls) == 2
assert any(
event.kind == "warning" and event.code == "provider_reasoning_continuation"
for event in events
)
assert not any(
event.kind == "warning" and event.code == "provider_reasoning_prefill_continue"
for event in events
)
assert not any(event.kind == "error" for event in events)
assert any(event.kind == "done" and event.text == "ok" for event in events)
assert not any(
msg.reasoning_content == "internal" for msg in provider.calls[1]["messages"]
)
assert any(
msg.role == "user"
and isinstance(msg.content, str)
and "Continue now with the next concrete tool call" in msg.content
for msg in provider.calls[1]["messages"]
)
logged = [json.loads(line) for line in runtime_events_path.read_text().splitlines()]
recovery_event = next(
event for event in logged if event.get("mechanism") == "reasoning_continuation_recovery"
)
assert recovery_event["injected_to_model"] is True
assert recovery_event["details"]["provider_reasoning_format"] == "dashscope"
@pytest.mark.asyncio
async def test_repeated_large_dashscope_reasoning_only_disables_thinking_after_nudge() -> None:
provider = _SequenceProvider(
[
[_large_reasoning_only_done()],
[_large_reasoning_only_done()],
[
ProviderText(text="ok"),
ProviderDone(stop_reason="stop", input_tokens=4, output_tokens=1),
],
]
)
agent = Agent(
provider=provider,
config=AgentConfig(
thinking=ThinkingLevel.MEDIUM,
model_capabilities=ModelCapabilities(
supports_reasoning=True,
supports_tools=True,
reasoning_format="dashscope",
),
reasoning_prefill_recovery_mode="recover",
retry_base_backoff_ms=0,
retry_max_backoff_ms=0,
),
)
events = [event async for event in agent.run_turn("hello")]
assert len(provider.calls) == 3
assert any(
event.kind == "warning" and event.code == "provider_reasoning_continuation"
for event in events
)
assert any(
event.kind == "warning" and event.code == "provider_large_context_visible_retry"
for event in events
)
assert not any(event.kind == "error" for event in events)
assert any(event.kind == "done" and event.text == "ok" for event in events)
assert provider.calls[2]["config"].thinking is False
@pytest.mark.asyncio
async def test_large_empty_response_without_fallback_surfaces_clear_error() -> None:
provider = _SequenceProvider(
[[ProviderDone(stop_reason="stop", input_tokens=35_000, output_tokens=0)]]
)
agent = Agent(
provider=provider,
config=AgentConfig(
max_provider_retries=1,
retry_base_backoff_ms=0,
retry_max_backoff_ms=0,
),
)
events = [event async for event in agent.run_turn("hello")]
assert len(provider.calls) == 1
error = next(event for event in events if event.kind == "error")
assert error.code == "empty_response"
assert "large input" in error.message
assert "attachment" in error.message
assert "summarize" in error.message or "shorten" in error.message
assert "stronger model" in error.message
assert not any(
event.kind == "warning" and event.code == "provider_empty_retry"
for event in events
)
@pytest.mark.asyncio
async def test_incomplete_tool_stream_errors_without_running_tool() -> None:
provider = _SequenceProvider(
[
[
ProviderToolUseStart(tool_use_id="tool-1", tool_name="echo"),
ProviderDone(stop_reason="tool_use", input_tokens=5, output_tokens=1),
]
]
)
called = False
async def tool_handler(call: Any) -> ToolResult:
nonlocal called
called = True
return ToolResult(tool_use_id=call.tool_use_id, tool_name=call.tool_name, content="tool ok")
agent = Agent(
provider=provider,
config=AgentConfig(retry_base_backoff_ms=0, retry_max_backoff_ms=0),
tool_definitions=[
ToolDefinition(
name="echo",
description="Echo.",
input_schema=ToolInputSchema(),
)
],
tool_handler=tool_handler,
)
events = [event async for event in agent.run_turn("hello")]
assert called is False
assert any(event.kind == "tool_use_start" for event in events)
assert any(event.kind == "error" and event.code == "incomplete_tool_stream" for event in events)
done = next(event for event in events if event.kind == "done")
assert done.input_tokens == 5
assert done.output_tokens == 1
assert agent._history == []
@pytest.mark.asyncio
async def test_turn_runner_drops_unpaired_tool_use_from_incomplete_stream_transcript() -> None:
storage = SessionStorage(":memory:")
await storage.connect()
manager = SessionManager(storage)
session_key = "agent:main:incomplete-tool-stream"
await manager.create(session_key)
provider = _SequenceProvider(
[
[
ProviderToolUseStart(tool_use_id="tool-1", tool_name="echo"),
ProviderDone(stop_reason="tool_use", input_tokens=5, output_tokens=1),
]
]
)
runner = TurnRunner(
provider_selector=_ProviderSelector(provider),
session_manager=manager,
)
try:
events = [
event
async for event in runner.run(
"hello",
session_key,
ToolContext(is_owner=True, caller_kind=CallerKind.CLI),
history_has_persisted_user=False,
no_memory_capture=True,
)
]
transcript = await manager.get_transcript(session_key)
finally:
await storage.close()
assert any(event.kind == "error" and event.code == "incomplete_tool_stream" for event in events)
assert all(entry.role != "assistant" for entry in transcript)
assert any(
entry.role == "system"
and "Provider stream ended with an incomplete tool call" in entry.content
for entry in transcript
)
@pytest.mark.asyncio
async def test_turn_runner_persists_no_provider_error_to_transcript() -> None:
storage = SessionStorage(":memory:")
await storage.connect()
manager = SessionManager(storage)
session_key = "agent:main:no-provider"
await manager.create(session_key)
runner = TurnRunner(
provider_selector=_ProviderSelector(None), # type: ignore[arg-type]
session_manager=manager,
)
try:
events = [
event
async for event in runner.run(
"hello",
session_key,
ToolContext(is_owner=True, caller_kind=CallerKind.CLI),
history_has_persisted_user=False,
no_memory_capture=True,
)
]
transcript = await manager.get_transcript(session_key)
finally:
await storage.close()
assert any(event.kind == "error" and event.code == "no_provider" for event in events)
assert any(
entry.role == "system" and entry.content == "Error: No provider available"
for entry in transcript
)
@pytest.mark.asyncio
async def test_no_done_without_visible_output_retries_once_then_errors() -> None:
provider = _SequenceProvider([[], []])
agent = Agent(
provider=provider,
config=AgentConfig(
max_provider_retries=1,
retry_base_backoff_ms=0,
retry_max_backoff_ms=0,
),
)
events = [event async for event in agent.run_turn("hello")]
assert len(provider.calls) == 2
assert any(
event.kind == "error" and event.code == "provider_stream_incomplete"
for event in events
)
assert not any(event.kind == "done" for event in events)
@pytest.mark.asyncio
async def test_no_done_after_text_does_not_retry() -> None:
provider = _SequenceProvider([[ProviderText(text="partial")]])
agent = Agent(
provider=provider,
config=AgentConfig(
max_provider_retries=1,
retry_base_backoff_ms=0,
retry_max_backoff_ms=0,
),
)
events = [event async for event in agent.run_turn("hello")]
assert len(provider.calls) == 1
assert any(event.kind == "text_delta" and event.text == "partial" for event in events)
assert any(
event.kind == "error" and event.code == "provider_stream_incomplete"
for event in events
)
assert not any(event.kind == "done" for event in events)
@pytest.mark.asyncio
async def test_length_capped_visible_text_continues_once_before_terminal() -> None:
provider = _SequenceProvider(
[
[
ProviderText(text="partial answer"),
ProviderDone(stop_reason="length", input_tokens=7, output_tokens=9),
],
[
ProviderText(text=" finished"),
ProviderDone(stop_reason="stop", input_tokens=8, output_tokens=1),
],
]
)
agent = Agent(
provider=provider,
config=AgentConfig(
max_provider_retries=1,
retry_base_backoff_ms=0,
retry_max_backoff_ms=0,
),
)
events = [event async for event in agent.run_turn("hello")]
assert len(provider.calls) == 2
assert any(event.kind == "text_delta" and event.text == "partial answer" for event in events)
assert any(event.kind == "text_delta" and event.text == " finished" for event in events)
assert any(
event.kind == "warning" and event.code == "provider_output_continue"
for event in events
)
assert not any(event.kind == "error" for event in events)
done = next(event for event in events if event.kind == "done")
assert done.text == "partial answer finished"
assert done.input_tokens == 15
assert done.output_tokens == 10
@pytest.mark.asyncio
async def test_length_capped_visible_text_uses_configured_continuation_budget() -> None:
provider = _SequenceProvider(
[
[
ProviderText(text="part one "),
ProviderDone(stop_reason="length", input_tokens=1, output_tokens=2),
],
[
ProviderText(text="part two "),
ProviderDone(stop_reason="length", input_tokens=3, output_tokens=4),
],
[
ProviderText(text="part three "),
ProviderDone(stop_reason="length", input_tokens=5, output_tokens=6),
],
[
ProviderText(text="done"),
ProviderDone(stop_reason="stop", input_tokens=7, output_tokens=8),
],
]
)
agent = Agent(
provider=provider,
config=AgentConfig(
length_capped_continuations=3,
retry_base_backoff_ms=0,
retry_max_backoff_ms=0,
),
)
events = [event async for event in agent.run_turn("hello")]
assert len(provider.calls) == 4
assert sum(
1
for event in events
if event.kind == "warning" and event.code == "provider_output_continue"
) == 3
assert not any(event.kind == "error" for event in events)
done = next(event for event in events if event.kind == "done")
assert done.text == "part one part two part three done"
assert done.input_tokens == 16
assert done.output_tokens == 20
@pytest.mark.asyncio
async def test_length_capped_visible_text_uses_default_three_continuation_budget() -> None:
provider = _SequenceProvider(
[
[
ProviderText(text="part one "),
ProviderDone(stop_reason="length", input_tokens=1, output_tokens=2),
],
[
ProviderText(text="part two "),
ProviderDone(stop_reason="length", input_tokens=3, output_tokens=4),
],
[
ProviderText(text="part three "),
ProviderDone(stop_reason="length", input_tokens=5, output_tokens=6),
],
[
ProviderText(text="done"),
ProviderDone(stop_reason="stop", input_tokens=7, output_tokens=8),
],
]
)
agent = Agent(
provider=provider,
config=AgentConfig(
retry_base_backoff_ms=0,
retry_max_backoff_ms=0,
),
)
events = [event async for event in agent.run_turn("hello")]
assert len(provider.calls) == 4
assert sum(
1
for event in events
if event.kind == "warning" and event.code == "provider_output_continue"
) == 3
assert not any(event.kind == "error" for event in events)
done = next(event for event in events if event.kind == "done")
assert done.text == "part one part two part three done"
assert done.input_tokens == 16
assert done.output_tokens == 20
@pytest.mark.asyncio
async def test_length_capped_reasoning_only_does_not_continue_empty_output() -> None:
provider = _SequenceProvider(
[
[
ProviderDone(
stop_reason="length",
input_tokens=17_000,
output_tokens=32_768,
reasoning_tokens=32_000,
),
]
]
)
agent = Agent(
provider=provider,
config=AgentConfig(
length_capped_continuations=3,
max_provider_retries=0,
retry_base_backoff_ms=0,
retry_max_backoff_ms=0,
),
)
events = [event async for event in agent.run_turn("hello")]
assert len(provider.calls) == 1
assert not any(
event.kind == "warning" and event.code == "provider_output_continue"
for event in events
)
assert any(event.kind == "error" and event.code == "empty_response" for event in events)
@pytest.mark.asyncio
async def test_length_capped_exhaustion_records_partial_diagnostics() -> None:
provider = _SequenceProvider(
[
[
ProviderText(text="first partial "),
ProviderDone(stop_reason="length", input_tokens=1, output_tokens=2),
],
[
ProviderText(text="second partial "),
ProviderDone(stop_reason="length", input_tokens=3, output_tokens=4),
],
]
)
agent = Agent(
provider=provider,
config=AgentConfig(
length_capped_continuations=1,
retry_base_backoff_ms=0,
retry_max_backoff_ms=0,
),
)
with structlog.testing.capture_logs() as captured:
events = [event async for event in agent.run_turn("hello")]
assert len(provider.calls) == 2
assert any(event.kind == "text_delta" and event.text == "first partial " for event in events)
assert any(event.kind == "text_delta" and event.text == "second partial " for event in events)
assert any(
event.kind == "warning" and event.code == "provider_output_continue"
for event in events
)
assert any(
event.kind == "error" and event.code == "provider_output_truncated"
for event in events
)
exhausted = [
event
for event in captured
if event.get("event") == "provider.output_truncated_exhausted"
]
assert exhausted
assert exhausted[-1]["attempt"] == 1
assert exhausted[-1]["budget"] == 1
assert exhausted[-1]["visible_chars"] == len("second partial ")
assert exhausted[-1]["partial_preserved"] is True
@pytest.mark.asyncio
async def test_length_capped_tool_call_is_not_executed() -> None:
provider = _SequenceProvider(
[
[
ProviderToolUseStart(tool_use_id="tool-1", tool_name="echo"),
ProviderToolUseEnd(
tool_use_id="tool-1",
tool_name="echo",
arguments={"value": "x"},
),
ProviderDone(stop_reason="length", input_tokens=7, output_tokens=9),
]
]
)
called = False
async def tool_handler(call: Any) -> ToolResult:
nonlocal called
called = True
return ToolResult(tool_use_id=call.tool_use_id, tool_name=call.tool_name, content="tool ok")
agent = Agent(
provider=provider,
config=AgentConfig(retry_base_backoff_ms=0, retry_max_backoff_ms=0),
tool_definitions=[
ToolDefinition(
name="echo",
description="Echo.",
input_schema=ToolInputSchema(),
)
],
tool_handler=tool_handler,
)
events = [event async for event in agent.run_turn("hello")]
assert called is False
assert any(event.kind == "tool_use_start" for event in events)
assert any(
event.kind == "error" and event.code == "provider_output_truncated"
for event in events
)
assert not any(event.kind == "tool_result" for event in events)
assert agent._history == []
@pytest.mark.asyncio
async def test_discarded_empty_attempt_counts_usage_but_skips_cache_check(
monkeypatch: pytest.MonkeyPatch,
) -> None:
provider = _SequenceProvider(
[
[ProviderDone(stop_reason="stop", input_tokens=3, output_tokens=0)],
[
ProviderText(text="ok"),
ProviderDone(stop_reason="stop", input_tokens=4, output_tokens=1),
],
]
)
cache_checks: list[Any] = []
def fake_cache_check(*args: Any, **kwargs: Any) -> _CacheReport:
cache_checks.append((args, kwargs))
return _CacheReport()
monkeypatch.setattr("opensquilla.engine.agent.check_response_for_cache_break", fake_cache_check)
usage = UsageTracker()
agent = Agent(
provider=provider,
config=AgentConfig(
max_provider_retries=1,
retry_base_backoff_ms=0,
retry_max_backoff_ms=0,
),
usage_tracker=usage,
session_key="agent:test:empty-retry",
)
events = [event async for event in agent.run_turn("hello")]
done = next(event for event in events if event.kind == "done")
assert done.input_tokens == 7
assert done.output_tokens == 1
tracked = usage.get("agent:test:empty-retry")
assert tracked is not None
assert tracked.input_tokens == 7
assert tracked.output_tokens == 1
assert len(cache_checks) == 1
assert len([msg for msg in agent._history if msg.role == "assistant"]) == 1