opensquilla--opensquilla
601 行
21 KiB
Python
601 行
21 KiB
Python
"""Gateway attachment history replay e2e tests.
|
|
|
|
These tests exercise the production upload -> sessions.send -> transcript
|
|
material -> SquillaRouter -> TurnRunner history path with deterministic fake
|
|
providers. They intentionally avoid live LLM credentials.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import base64
|
|
import json
|
|
import uuid
|
|
from collections.abc import AsyncIterator
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
import opensquilla.engine.steps.squilla_router as squilla_router_step
|
|
from opensquilla.attachment_refs import transcript_material_path
|
|
from opensquilla.engine import Agent, AgentConfig
|
|
from opensquilla.engine.runtime import TurnRunner
|
|
from opensquilla.gateway import rpc_sessions as _rpc_sessions # noqa: F401
|
|
from opensquilla.gateway.agent_tasks import get_agent_task_registry
|
|
from opensquilla.gateway.app import create_gateway_app
|
|
from opensquilla.gateway.auth import Principal
|
|
from opensquilla.gateway.config import GatewayConfig
|
|
from opensquilla.gateway.rpc import RpcContext, get_dispatcher
|
|
from opensquilla.gateway.uploads import (
|
|
AttachmentNotFoundError,
|
|
UploadStore,
|
|
set_upload_store,
|
|
)
|
|
from opensquilla.gateway.websocket import SubscriptionManager, get_registry
|
|
from opensquilla.provider import ChatConfig, DoneEvent, Message, ModelCapabilities
|
|
from opensquilla.provider.types import ContentBlockImage, ModelInfo, TextDeltaEvent
|
|
from opensquilla.session.manager import SessionManager
|
|
from opensquilla.session.storage import SessionStorage
|
|
|
|
_PNG_BYTES = (
|
|
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01"
|
|
b"\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4"
|
|
b"\x89\x00\x00\x00\nIDATx\x9cc\xf8\x0f\x00\x01\x01"
|
|
b"\x01\x00\x18\xdd\x8d\xb0\x00\x00\x00\x00IEND\xaeB`\x82"
|
|
)
|
|
|
|
_TEXT_MODEL = "test/text"
|
|
_GATE_MODEL = "test/gate"
|
|
_VISION_MODEL = "test/vision"
|
|
_TURN_TERMINAL_EVENT_TIMEOUT_SECONDS = 30.0
|
|
_TURN_TASK_DRAIN_TIMEOUT_SECONDS = 10.0
|
|
|
|
|
|
class _RecordingProvider:
|
|
provider_name = "fake"
|
|
|
|
def __init__(self, text: str = "ok") -> None:
|
|
self.text = text
|
|
self.calls: list[dict[str, Any]] = []
|
|
|
|
async def chat(
|
|
self,
|
|
messages: list[Message],
|
|
tools: list[Any] | None = None,
|
|
config: ChatConfig | None = None,
|
|
) -> AsyncIterator[Any]:
|
|
self.calls.append({"messages": messages, "tools": tools, "config": config})
|
|
yield TextDeltaEvent(text=self.text)
|
|
yield DoneEvent(stop_reason="end_turn", input_tokens=3, output_tokens=1)
|
|
|
|
async def list_models(self) -> list[ModelInfo]:
|
|
return []
|
|
|
|
|
|
class _RecordingSelector:
|
|
active_provider_id = "openrouter"
|
|
|
|
def __init__(
|
|
self,
|
|
providers: dict[str, _RecordingProvider],
|
|
model: str = _TEXT_MODEL,
|
|
) -> None:
|
|
self.providers = providers
|
|
self.model = model
|
|
|
|
def clone(self) -> _RecordingSelector:
|
|
return _RecordingSelector(self.providers, self.model)
|
|
|
|
def override_model(self, model: str) -> None:
|
|
self.model = model
|
|
|
|
def override_model_with_fallback_chain(
|
|
self,
|
|
model: str,
|
|
fallback_chain: list[object], # noqa: ARG002
|
|
) -> None:
|
|
self.override_model(model)
|
|
|
|
def resolve(self) -> _RecordingProvider:
|
|
return self.providers.get(self.model, self.providers[_TEXT_MODEL])
|
|
|
|
async def list_models(self) -> list[dict[str, Any]]:
|
|
return []
|
|
|
|
|
|
class _FakeModelCatalog:
|
|
def resolve_max_tokens(
|
|
self,
|
|
model_id: str, # noqa: ARG002
|
|
*,
|
|
user_override: int = 0,
|
|
provider: str = "openrouter", # noqa: ARG002
|
|
) -> int:
|
|
return user_override if user_override > 0 else 1024
|
|
|
|
def resolve_context_window(
|
|
self,
|
|
model_id: str, # noqa: ARG002
|
|
*,
|
|
provider: str = "openrouter", # noqa: ARG002
|
|
) -> int:
|
|
return 8192
|
|
|
|
def get_capabilities(
|
|
self,
|
|
model_id: str,
|
|
provider_name: str = "openrouter", # noqa: ARG002
|
|
base_url: str = "", # noqa: ARG002
|
|
) -> ModelCapabilities:
|
|
return ModelCapabilities(supports_vision=model_id == _VISION_MODEL)
|
|
|
|
|
|
class _EventSink:
|
|
authenticated = True
|
|
|
|
def __init__(self, conn_id: str) -> None:
|
|
self.conn_id = conn_id
|
|
self.events: list[tuple[str, dict[str, Any]]] = []
|
|
|
|
async def send_event(
|
|
self,
|
|
event: str,
|
|
payload: Any = None,
|
|
meta: dict[str, Any] | None = None, # noqa: ARG002
|
|
) -> None:
|
|
self.events.append((event, dict(payload or {})))
|
|
|
|
|
|
class _TextTierStrategy:
|
|
async def classify(
|
|
self,
|
|
message: str, # noqa: ARG002
|
|
valid_tiers: list[str],
|
|
routing_history: list[dict] | None = None, # noqa: ARG002
|
|
**kwargs: object, # noqa: ARG002
|
|
) -> tuple[str, float, str, dict[str, Any]]:
|
|
tier = "c1" if "c1" in valid_tiers else valid_tiers[0]
|
|
return (
|
|
tier,
|
|
0.87,
|
|
"test_text_route",
|
|
{
|
|
"route_class": "R1",
|
|
"thinking_mode": "T1",
|
|
"prompt_policy": "P0",
|
|
},
|
|
)
|
|
|
|
|
|
def _configure_gateway(tmp_path: Path) -> GatewayConfig:
|
|
config = GatewayConfig()
|
|
config.state_dir = str(tmp_path / "state")
|
|
config.workspace_dir = str(tmp_path / "workspace")
|
|
config.attachments.media_root = str(tmp_path / "media")
|
|
config.squilla_router.enabled = True
|
|
config.squilla_router.rollout_phase = "full"
|
|
config.squilla_router.require_router_runtime = False
|
|
config.squilla_router.vision_history_lookback_turns = 8
|
|
config.squilla_router.vision_history_candidate_turns = 8
|
|
config.squilla_router.vision_sticky_followup_turns = 3
|
|
config.squilla_router.vision_followup_gate_tier = "c0"
|
|
config.squilla_router.tiers = {
|
|
"c0": {
|
|
"provider": "openrouter",
|
|
"model": _GATE_MODEL,
|
|
"supports_image": False,
|
|
},
|
|
"c1": {
|
|
"provider": "openrouter",
|
|
"model": _TEXT_MODEL,
|
|
"supports_image": False,
|
|
},
|
|
"image_model": {
|
|
"provider": "openrouter",
|
|
"model": _VISION_MODEL,
|
|
"supports_image": True,
|
|
"image_only": True,
|
|
},
|
|
}
|
|
config.squilla_router.default_tier = "c1"
|
|
config.llm.provider = "openrouter"
|
|
config.llm.model = _TEXT_MODEL
|
|
return config
|
|
|
|
|
|
async def _upload_png(app: Any) -> str:
|
|
transport = httpx.ASGITransport(app=app)
|
|
async with httpx.AsyncClient(
|
|
transport=transport,
|
|
base_url="http://testserver",
|
|
) as client:
|
|
response = await client.post(
|
|
"/api/v1/files/upload",
|
|
files={"file": ("first.png", _PNG_BYTES, "image/png")},
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
payload = response.json()
|
|
file_uuid = payload.get("file_uuid")
|
|
assert isinstance(file_uuid, str) and file_uuid.startswith("u-")
|
|
return file_uuid
|
|
|
|
|
|
async def _send_session_turn(
|
|
*,
|
|
ctx: RpcContext,
|
|
key: str,
|
|
sink: _EventSink,
|
|
message: str,
|
|
attachments: list[dict[str, Any]] | None = None,
|
|
) -> None:
|
|
done_before = sum(1 for event, _payload in sink.events if event == "session.event.done")
|
|
event_count_before = len(sink.events)
|
|
result = await get_dispatcher().dispatch(
|
|
"test",
|
|
"sessions.send",
|
|
{"key": key, "message": message, "attachments": attachments or []},
|
|
ctx,
|
|
)
|
|
assert result.ok, result.error
|
|
|
|
task = get_agent_task_registry().get(key)
|
|
loop = asyncio.get_running_loop()
|
|
deadline = loop.time() + _TURN_TERMINAL_EVENT_TIMEOUT_SECONDS
|
|
while loop.time() < deadline:
|
|
done_count = sum(
|
|
1 for event, _payload in sink.events if event == "session.event.done"
|
|
)
|
|
if done_count > done_before:
|
|
if task is not None:
|
|
try:
|
|
await asyncio.wait_for(
|
|
asyncio.shield(task),
|
|
timeout=_TURN_TASK_DRAIN_TIMEOUT_SECONDS,
|
|
)
|
|
except TimeoutError as exc:
|
|
raise AssertionError(
|
|
"timed out waiting for agent task to finish after done event; "
|
|
f"events={sink.events!r}"
|
|
) from exc
|
|
return
|
|
new_errors = [
|
|
payload
|
|
for event, payload in sink.events[event_count_before:]
|
|
if event == "session.event.error"
|
|
]
|
|
if new_errors:
|
|
raise AssertionError(f"turn emitted error events: {sink.events!r}")
|
|
if task is not None and task.done():
|
|
if task.cancelled():
|
|
raise AssertionError(f"agent task was cancelled; events={sink.events!r}")
|
|
exc = task.exception()
|
|
if exc is not None:
|
|
raise AssertionError(f"agent task failed; events={sink.events!r}") from exc
|
|
raise AssertionError(f"agent task ended without done event; events={sink.events!r}")
|
|
await asyncio.sleep(0.01)
|
|
raise AssertionError(f"timed out waiting for done event; events={sink.events!r}")
|
|
|
|
|
|
def _message_has_image(message: Message) -> bool:
|
|
return isinstance(message.content, list) and any(
|
|
isinstance(block, ContentBlockImage) for block in message.content
|
|
)
|
|
|
|
|
|
def _message_image_blocks(message: Message) -> list[ContentBlockImage]:
|
|
if not isinstance(message.content, list):
|
|
return []
|
|
return [
|
|
block for block in message.content if isinstance(block, ContentBlockImage)
|
|
]
|
|
|
|
|
|
def _event_payloads(sink: _EventSink, event_name: str) -> list[dict[str, Any]]:
|
|
return [payload for event, payload in sink.events if event == event_name]
|
|
|
|
|
|
def _file_uuid_attachment(file_uuid: str) -> dict[str, str]:
|
|
return {"file_uuid": file_uuid, "mime": "image/png", "name": "first.png"}
|
|
|
|
|
|
@pytest.fixture
|
|
async def _e2e_stack(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
|
monkeypatch.setenv("OPENSQUILLA_OPENROUTER_LIVE_PRICING", "0")
|
|
config = _configure_gateway(tmp_path)
|
|
store = UploadStore(marker_dir=tmp_path / "upload-markers")
|
|
set_upload_store(store)
|
|
storage = SessionStorage(str(tmp_path / "sessions.sqlite"))
|
|
await storage.connect()
|
|
manager = SessionManager(
|
|
storage,
|
|
inject_time_prefix=False,
|
|
media_root=config.attachments.media_root,
|
|
)
|
|
text_provider = _RecordingProvider("text ok")
|
|
gate_provider = _RecordingProvider(
|
|
'{"decision":"needs_image","confidence":0.94,"reason":"visual detail"}'
|
|
)
|
|
vision_provider = _RecordingProvider("vision ok")
|
|
selector = _RecordingSelector(
|
|
{
|
|
_TEXT_MODEL: text_provider,
|
|
_GATE_MODEL: gate_provider,
|
|
_VISION_MODEL: vision_provider,
|
|
}
|
|
)
|
|
runner = TurnRunner(
|
|
provider_selector=selector,
|
|
session_manager=manager,
|
|
config=config,
|
|
model_catalog=_FakeModelCatalog(),
|
|
)
|
|
bootstrap_configs: list[AgentConfig] = []
|
|
original_bootstrap_run = runner._agent_bootstrap_stage.run
|
|
|
|
async def _record_bootstrap_config(inp: Any) -> Any:
|
|
outcome = await original_bootstrap_run(inp)
|
|
if not outcome.terminate and outcome.output is not None:
|
|
bootstrap_configs.append(outcome.output.agent_config)
|
|
return outcome
|
|
|
|
runner._agent_bootstrap_stage.run = _record_bootstrap_config # type: ignore[method-assign]
|
|
subscription_manager = SubscriptionManager()
|
|
sink = _EventSink(f"attachment-history-e2e-{uuid.uuid4().hex}")
|
|
get_registry().register(sink) # type: ignore[arg-type]
|
|
ctx = RpcContext(
|
|
conn_id=sink.conn_id,
|
|
principal=Principal(
|
|
role="operator",
|
|
scopes=frozenset(["operator.admin"]),
|
|
is_owner=True,
|
|
authenticated=True,
|
|
),
|
|
session_manager=manager,
|
|
config=config,
|
|
provider_selector=selector,
|
|
subscription_manager=subscription_manager,
|
|
turn_runner=runner,
|
|
)
|
|
app = create_gateway_app(
|
|
config,
|
|
session_manager=manager,
|
|
provider_selector=selector,
|
|
subscription_manager=subscription_manager,
|
|
turn_runner=runner,
|
|
)
|
|
try:
|
|
yield {
|
|
"app": app,
|
|
"bootstrap_configs": bootstrap_configs,
|
|
"config": config,
|
|
"ctx": ctx,
|
|
"gate_provider": gate_provider,
|
|
"manager": manager,
|
|
"runner": runner,
|
|
"sink": sink,
|
|
"storage": storage,
|
|
"store": store,
|
|
"subscription_manager": subscription_manager,
|
|
"text_provider": text_provider,
|
|
"vision_provider": vision_provider,
|
|
}
|
|
finally:
|
|
get_registry().unregister(sink.conn_id)
|
|
set_upload_store(None)
|
|
await storage.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_gateway_upload_history_image_replays_through_squilla_router_gate_history(
|
|
_e2e_stack: dict[str, Any],
|
|
) -> None:
|
|
manager: SessionManager = _e2e_stack["manager"]
|
|
subscription_manager: SubscriptionManager = _e2e_stack["subscription_manager"]
|
|
sink: _EventSink = _e2e_stack["sink"]
|
|
store: UploadStore = _e2e_stack["store"]
|
|
vision_provider: _RecordingProvider = _e2e_stack["vision_provider"]
|
|
gate_provider: _RecordingProvider = _e2e_stack["gate_provider"]
|
|
config: GatewayConfig = _e2e_stack["config"]
|
|
bootstrap_configs: list[AgentConfig] = _e2e_stack["bootstrap_configs"]
|
|
key = "agent:main:attachment-history-e2e"
|
|
session = await manager.create(
|
|
session_key=key,
|
|
agent_id="main",
|
|
display_name="attachment history e2e",
|
|
)
|
|
subscription_manager.subscribe_messages(sink.conn_id, key)
|
|
|
|
file_uuid = await _upload_png(_e2e_stack["app"])
|
|
await _send_session_turn(
|
|
ctx=_e2e_stack["ctx"],
|
|
key=key,
|
|
sink=sink,
|
|
message="Describe this image.",
|
|
attachments=[_file_uuid_attachment(file_uuid)],
|
|
)
|
|
|
|
with pytest.raises(AttachmentNotFoundError):
|
|
await store.get(file_uuid)
|
|
|
|
transcript = await manager.get_transcript(key)
|
|
first_user = transcript[0]
|
|
persisted = json.loads(first_user.content)
|
|
attachment = persisted["attachments"][0]
|
|
assert "file_uuid" not in json.dumps(persisted)
|
|
assert attachment["mime"] == "image/png"
|
|
assert attachment["name"] == "first.png"
|
|
sha = attachment["sha256_ref"]
|
|
assert isinstance(sha, str) and len(sha) == 64
|
|
material_path = transcript_material_path(
|
|
Path(config.attachments.media_root or ""),
|
|
session.session_id,
|
|
sha,
|
|
)
|
|
assert material_path.is_file()
|
|
assert material_path.read_bytes() == _PNG_BYTES
|
|
|
|
await manager.append_message(key, "user", "A text-only turn in between.")
|
|
await manager.append_message(key, "assistant", "Text answer in between.")
|
|
|
|
vision_calls_before = len(vision_provider.calls)
|
|
await _send_session_turn(
|
|
ctx=_e2e_stack["ctx"],
|
|
key=key,
|
|
sink=sink,
|
|
message="What color is the small corner?",
|
|
)
|
|
|
|
assert len(gate_provider.calls) == 1
|
|
assert len(vision_provider.calls) == vision_calls_before + 1
|
|
final_call = vision_provider.calls[-1]
|
|
sent_messages = final_call["messages"]
|
|
image_blocks = [
|
|
block
|
|
for message in sent_messages[:-1]
|
|
for block in _message_image_blocks(message)
|
|
]
|
|
assert image_blocks
|
|
assert base64.b64decode(image_blocks[0].data, validate=True) == _PNG_BYTES
|
|
assert isinstance(sent_messages[-1].content, str)
|
|
assert sent_messages[-1].content.startswith("What color is the small corner?")
|
|
|
|
router_events = _event_payloads(sink, "session.event.router_decision")
|
|
assert router_events[-1]["source"] == "image_route"
|
|
assert router_events[-1]["model"] == _VISION_MODEL
|
|
done_events = _event_payloads(sink, "session.event.done")
|
|
assert done_events[-1]["image_route_reason"] == "gate_history"
|
|
assert done_events[-1]["vision_followup_needs_image"] is True
|
|
assert done_events[-1]["vision_followup_gate_decision"] == "needs_image"
|
|
assert bootstrap_configs[-1].preserve_historical_images is True
|
|
assert (
|
|
bootstrap_configs[-1].max_history_turns
|
|
== config.squilla_router.vision_history_lookback_turns
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_historical_image_material_is_not_replayed_without_vision_support(
|
|
_e2e_stack: dict[str, Any],
|
|
) -> None:
|
|
manager: SessionManager = _e2e_stack["manager"]
|
|
runner: TurnRunner = _e2e_stack["runner"]
|
|
subscription_manager: SubscriptionManager = _e2e_stack["subscription_manager"]
|
|
sink: _EventSink = _e2e_stack["sink"]
|
|
key = "agent:main:attachment-history-no-vision"
|
|
await manager.create(session_key=key, agent_id="main")
|
|
subscription_manager.subscribe_messages(sink.conn_id, key)
|
|
file_uuid = await _upload_png(_e2e_stack["app"])
|
|
await _send_session_turn(
|
|
ctx=_e2e_stack["ctx"],
|
|
key=key,
|
|
sink=sink,
|
|
message="Describe this image.",
|
|
attachments=[_file_uuid_attachment(file_uuid)],
|
|
)
|
|
|
|
provider = _RecordingProvider()
|
|
agent = Agent(
|
|
provider=provider,
|
|
config=AgentConfig(
|
|
model_capabilities=ModelCapabilities(supports_vision=False),
|
|
preserve_historical_images=True,
|
|
),
|
|
)
|
|
await runner._load_history(agent, key)
|
|
events = [event async for event in agent.run_turn("Follow up.")]
|
|
|
|
assert any(getattr(event, "kind", None) == "done" for event in events)
|
|
assert not any(_message_has_image(message) for message in provider.calls[0]["messages"])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_historical_image_material_outside_lookback_is_not_replayed(
|
|
_e2e_stack: dict[str, Any],
|
|
) -> None:
|
|
manager: SessionManager = _e2e_stack["manager"]
|
|
runner: TurnRunner = _e2e_stack["runner"]
|
|
config: GatewayConfig = _e2e_stack["config"]
|
|
subscription_manager: SubscriptionManager = _e2e_stack["subscription_manager"]
|
|
sink: _EventSink = _e2e_stack["sink"]
|
|
key = "agent:main:attachment-history-lookback"
|
|
await manager.create(session_key=key, agent_id="main")
|
|
subscription_manager.subscribe_messages(sink.conn_id, key)
|
|
file_uuid = await _upload_png(_e2e_stack["app"])
|
|
await _send_session_turn(
|
|
ctx=_e2e_stack["ctx"],
|
|
key=key,
|
|
sink=sink,
|
|
message="Describe this image.",
|
|
attachments=[_file_uuid_attachment(file_uuid)],
|
|
)
|
|
await manager.append_message(key, "user", "A later text-only user turn.")
|
|
await manager.append_message(key, "assistant", "A later text-only answer.")
|
|
config.squilla_router.vision_history_lookback_turns = 1
|
|
|
|
provider = _RecordingProvider()
|
|
agent = Agent(
|
|
provider=provider,
|
|
config=AgentConfig(
|
|
model_capabilities=ModelCapabilities(supports_vision=True),
|
|
preserve_historical_images=True,
|
|
),
|
|
)
|
|
await runner._load_history(agent, key, trim_last_user=False)
|
|
events = [event async for event in agent.run_turn("Follow up.")]
|
|
|
|
assert any(getattr(event, "kind", None) == "done" for event in events)
|
|
assert not any(_message_has_image(message) for message in provider.calls[0]["messages"])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_gate_text_only_followup_stays_text_and_does_not_replay_history_image(
|
|
_e2e_stack: dict[str, Any],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setattr(squilla_router_step, "_get_strategy", lambda _cfg: _TextTierStrategy())
|
|
manager: SessionManager = _e2e_stack["manager"]
|
|
subscription_manager: SubscriptionManager = _e2e_stack["subscription_manager"]
|
|
sink: _EventSink = _e2e_stack["sink"]
|
|
gate_provider: _RecordingProvider = _e2e_stack["gate_provider"]
|
|
text_provider: _RecordingProvider = _e2e_stack["text_provider"]
|
|
vision_provider: _RecordingProvider = _e2e_stack["vision_provider"]
|
|
key = "agent:main:attachment-history-text-only"
|
|
await manager.create(session_key=key, agent_id="main")
|
|
subscription_manager.subscribe_messages(sink.conn_id, key)
|
|
|
|
file_uuid = await _upload_png(_e2e_stack["app"])
|
|
await _send_session_turn(
|
|
ctx=_e2e_stack["ctx"],
|
|
key=key,
|
|
sink=sink,
|
|
message="Describe this image.",
|
|
attachments=[_file_uuid_attachment(file_uuid)],
|
|
)
|
|
await manager.append_message(key, "user", "A text-only turn in between.")
|
|
await manager.append_message(key, "assistant", "Text answer in between.")
|
|
gate_provider.text = (
|
|
'{"decision":"text_only","confidence":0.91,"reason":"new coding task"}'
|
|
)
|
|
gate_calls_before = len(gate_provider.calls)
|
|
text_calls_before = len(text_provider.calls)
|
|
vision_calls_before = len(vision_provider.calls)
|
|
|
|
await _send_session_turn(
|
|
ctx=_e2e_stack["ctx"],
|
|
key=key,
|
|
sink=sink,
|
|
message="Write a small Python script.",
|
|
)
|
|
|
|
assert len(gate_provider.calls) == gate_calls_before + 1
|
|
assert len(text_provider.calls) == text_calls_before + 1
|
|
assert len(vision_provider.calls) == vision_calls_before
|
|
sent_messages = text_provider.calls[-1]["messages"]
|
|
assert not any(_message_has_image(message) for message in sent_messages)
|
|
done_events = _event_payloads(sink, "session.event.done")
|
|
assert done_events[-1]["vision_followup_gate_decision"] == "text_only"
|
|
assert done_events[-1]["vision_followup_needs_image"] is False
|
|
assert done_events[-1].get("image_route_reason") is None
|