Files
2026-07-13 13:12:33 +08:00

234 lines
8.2 KiB
Python

"""Cancelled turns persist the same segment timeline a completed turn would."""
from __future__ import annotations
import asyncio
import contextlib
from collections.abc import AsyncIterator
from types import SimpleNamespace
from typing import Any
import pytest
from opensquilla.engine.runtime import TurnRunner
from opensquilla.engine.types import TextDeltaEvent
from opensquilla.gateway.config import AttachmentsConfig, GatewayConfig, SquillaRouterConfig
from opensquilla.provider import DoneEvent as ProviderDone
from opensquilla.provider import Message, ModelInfo
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.registry import ToolRegistry, ToolSpec
from opensquilla.tools.types import CallerKind, ToolContext
PARTIAL_ANSWER = "Based on the lookup, the answer is 42 and the reasoning is as follows"
class _ToolThenHangingTextProvider:
"""Call 1: emits one tool call. Call 2: streams text, then hangs forever."""
provider_name = "test"
def __init__(self) -> None:
self.calls = 0
self.model = "test/model"
def chat(self, messages: list[Message], tools=None, config=None) -> AsyncIterator[Any]:
self.calls += 1
return self._stream(self.calls)
async def _stream(self, call_number: int) -> AsyncIterator[Any]:
if call_number == 1:
yield ProviderToolUseStart(tool_use_id="tool-1", tool_name="lookup")
yield ProviderToolUseEnd(tool_use_id="tool-1", tool_name="lookup", arguments={})
yield ProviderDone(stop_reason="tool_use", input_tokens=1, output_tokens=1)
return
yield ProviderText(text=PARTIAL_ANSWER)
await asyncio.Event().wait()
async def list_models(self) -> list[ModelInfo]:
return []
class _ToolThenCompletedTextProvider(_ToolThenHangingTextProvider):
"""Complete the answer stream so cancellation can happen in finalization."""
async def _stream(self, call_number: int) -> AsyncIterator[Any]:
if call_number == 1:
yield ProviderToolUseStart(tool_use_id="tool-1", tool_name="lookup")
yield ProviderToolUseEnd(tool_use_id="tool-1", tool_name="lookup", arguments={})
yield ProviderDone(stop_reason="tool_use", input_tokens=1, output_tokens=1)
return
yield ProviderText(text=PARTIAL_ANSWER)
yield ProviderDone(stop_reason="end_turn", input_tokens=1, output_tokens=1)
class _SelectorClone:
current_config = SimpleNamespace(model="test/model")
def __init__(self, provider: _ToolThenHangingTextProvider) -> None:
self.provider = provider
def override_model(self, model: str) -> None:
self.current_config = SimpleNamespace(model=model)
self.provider.model = model
def resolve(self) -> _ToolThenHangingTextProvider:
return self.provider
class _ProviderSelector:
def __init__(self, provider: _ToolThenHangingTextProvider) -> None:
self.provider = provider
def clone(self) -> _SelectorClone:
return _SelectorClone(self.provider)
def _registry() -> ToolRegistry:
registry = ToolRegistry()
async def lookup() -> str:
return "lookup-result-payload"
registry.register(
ToolSpec(name="lookup", description="Look something up", parameters={}),
lookup,
)
return registry
@pytest.mark.asyncio
async def test_cancelled_turn_persists_trailing_text_segment(tmp_path) -> None:
storage = SessionStorage(":memory:")
await storage.connect()
manager = SessionManager(storage)
session_key = "agent:main:webchat:cancel-trailing-text"
await manager.create(session_key)
runner = TurnRunner(
provider_selector=_ProviderSelector(_ToolThenHangingTextProvider()),
tool_registry=_registry(),
session_manager=manager,
config=GatewayConfig(
attachments=AttachmentsConfig(media_root=str(tmp_path / "media")),
squilla_router=SquillaRouterConfig(enabled=False),
),
)
tool_context = ToolContext(
is_owner=True,
caller_kind=CallerKind.WEB,
workspace_dir=str(tmp_path),
)
partial_seen = asyncio.Event()
async def _consume() -> None:
async for event in runner.run(
"look it up and explain",
session_key,
tool_context=tool_context,
history_has_persisted_user=False,
no_memory_capture=True,
):
if isinstance(event, TextDeltaEvent) and PARTIAL_ANSWER in (event.text or ""):
partial_seen.set()
task = asyncio.create_task(_consume())
try:
await asyncio.wait_for(partial_seen.wait(), timeout=5.0)
await asyncio.sleep(0)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
transcript = await manager.get_transcript(session_key)
assistants = [entry for entry in transcript if entry.role == "assistant"]
assert assistants
assistant = assistants[-1]
assert PARTIAL_ANSWER in assistant.content
assert "[interrupted]" in assistant.content
segments = assistant.tool_calls or []
segment_types = [str(seg.get("type")) for seg in segments if isinstance(seg, dict)]
assert "tool_use" in segment_types
# Transcript-backed views render from the segment timeline, so the text
# streamed after the last tool boundary must survive as a text segment.
text_segments = [
seg for seg in segments if isinstance(seg, dict) and seg.get("type") == "text"
]
assert any(PARTIAL_ANSWER in str(seg.get("text", "")) for seg in text_segments)
finally:
if not task.done():
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
await storage.close()
@pytest.mark.asyncio
async def test_cancel_during_finalizer_does_not_duplicate_text_segment(
tmp_path, monkeypatch: pytest.MonkeyPatch
) -> None:
storage = SessionStorage(":memory:")
await storage.connect()
manager = SessionManager(storage)
session_key = "agent:main:webchat:cancel-during-finalizer"
await manager.create(session_key)
runner = TurnRunner(
provider_selector=_ProviderSelector(_ToolThenCompletedTextProvider()),
tool_registry=_registry(),
session_manager=manager,
config=GatewayConfig(
attachments=AttachmentsConfig(media_root=str(tmp_path / "media")),
squilla_router=SquillaRouterConfig(enabled=False),
),
)
finalizer_entered = asyncio.Event()
async def _block_finalizer(_input):
finalizer_entered.set()
await asyncio.Event().wait()
monkeypatch.setattr(runner._turn_finalizer_stage, "run", _block_finalizer)
tool_context = ToolContext(
is_owner=True,
caller_kind=CallerKind.WEB,
workspace_dir=str(tmp_path),
)
async def _consume() -> None:
async for _event in runner.run(
"look it up and explain",
session_key,
tool_context=tool_context,
history_has_persisted_user=False,
no_memory_capture=True,
):
pass
task = asyncio.create_task(_consume())
try:
await asyncio.wait_for(finalizer_entered.wait(), timeout=5.0)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
transcript = await manager.get_transcript(session_key)
assistant = [entry for entry in transcript if entry.role == "assistant"][-1]
text_segments = [
segment
for segment in (assistant.tool_calls or [])
if isinstance(segment, dict)
and segment.get("type") == "text"
and PARTIAL_ANSWER in str(segment.get("text", ""))
]
assert len(text_segments) == 1
finally:
if not task.done():
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
await storage.close()