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

898 lines
32 KiB
Python

"""Gateway non-image attachment materialization e2e tests.
These tests exercise the real upload -> sessions.send -> transcript material ->
SquillaRouter -> runtime/provider path. The upstream provider is deterministic
and only captures final request messages; no live LLM credentials are used.
"""
from __future__ import annotations
import asyncio
import base64
import hashlib
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 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 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,
ContentBlockText,
ModelInfo,
TextDeltaEvent,
)
from opensquilla.session.manager import SessionManager
from opensquilla.session.storage import SessionStorage
_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
_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"
)
def _sample_pdf_bytes(text: str = "Machine Learning") -> bytes:
stream = f"BT /F1 24 Tf 72 720 Td ({text}) Tj ET".encode("ascii")
objects = [
b"<< /Type /Catalog /Pages 2 0 R >>",
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
(
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] "
b"/Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>"
),
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
b"<< /Length " + str(len(stream)).encode("ascii") + b" >>\nstream\n"
+ stream + b"\nendstream",
]
body = bytearray(b"%PDF-1.4\n")
offsets = [0]
for idx, obj in enumerate(objects, start=1):
offsets.append(len(body))
body.extend(f"{idx} 0 obj\n".encode("ascii"))
body.extend(obj)
body.extend(b"\nendobj\n")
xref_offset = len(body)
body.extend(f"xref\n0 {len(objects) + 1}\n".encode("ascii"))
body.extend(b"0000000000 65535 f \n")
for offset in offsets[1:]:
body.extend(f"{offset:010d} 00000 n \n".encode("ascii"))
body.extend(
f"trailer\n<< /Root 1 0 R /Size {len(objects) + 1} >>\n"
f"startxref\n{xref_offset}\n%%EOF\n".encode("ascii")
)
return bytes(body)
def _b64(payload: bytes) -> str:
return base64.b64encode(payload).decode("ascii")
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_file(
app: Any,
*,
name: str,
mime: str,
payload: bytes,
) -> 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": (name, payload, mime)},
)
assert response.status_code == 200, response.text
file_uuid = response.json().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 _event_payloads(sink: _EventSink, event_name: str) -> list[dict[str, Any]]:
return [payload for event, payload in sink.events if event == event_name]
def _attachment(file_uuid: str, *, mime: str, name: str) -> dict[str, str]:
return {"file_uuid": file_uuid, "mime": mime, "name": name}
def _inline_attachment(payload: bytes, *, mime: str, name: str) -> dict[str, str]:
return {"type": mime, "mime": mime, "name": name, "data": _b64(payload)}
def _message_text(message: Message) -> str:
if isinstance(message.content, str):
return message.content
if isinstance(message.content, list):
parts: list[str] = []
for block in message.content:
if isinstance(block, ContentBlockText):
parts.append(block.text)
return "\n".join(parts)
return str(message.content)
def _all_provider_text(messages: list[Message]) -> str:
return "\n".join(_message_text(message) for message in messages)
def _message_has_image(message: Message) -> bool:
return isinstance(message.content, list) and any(
isinstance(block, ContentBlockImage) for block in message.content
)
@pytest.fixture
async def _e2e_stack(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv("OPENSQUILLA_OPENROUTER_LIVE_PRICING", "0")
monkeypatch.setattr(squilla_router_step, "_get_strategy", lambda _cfg: _TextTierStrategy())
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"non-image-attachments-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_current_turn_pdf_is_materialized_to_workspace_path(
_e2e_stack: dict[str, Any],
) -> None:
manager: SessionManager = _e2e_stack["manager"]
subscription_manager: SubscriptionManager = _e2e_stack["subscription_manager"]
sink: _EventSink = _e2e_stack["sink"]
text_provider: _RecordingProvider = _e2e_stack["text_provider"]
config: GatewayConfig = _e2e_stack["config"]
key = "agent:main:pdf-materialization-current"
session = await manager.create(session_key=key, agent_id="main")
subscription_manager.subscribe_messages(sink.conn_id, key)
pdf_bytes = _sample_pdf_bytes()
file_uuid = await _upload_file(
_e2e_stack["app"],
name="L11 RL.pdf",
mime="application/pdf",
payload=pdf_bytes,
)
await _send_session_turn(
ctx=_e2e_stack["ctx"],
key=key,
sink=sink,
message="请读取这个 PDF",
attachments=[_attachment(file_uuid, mime="application/pdf", name="L11 RL.pdf")],
)
transcript = await manager.get_transcript(key)
persisted = json.loads(transcript[0].content)
persisted_attachment = persisted["attachments"][0]
assert persisted_attachment["name"] == "L11 RL.pdf"
assert persisted_attachment["mime"] == "application/pdf"
sha = persisted_attachment["sha256_ref"]
assert "file_uuid" not in json.dumps(persisted)
material_path = transcript_material_path(
Path(config.attachments.media_root or ""),
session.session_id,
sha,
)
assert material_path.read_bytes() == pdf_bytes
workspace_paths = list(
(Path(config.workspace_dir or "") / ".opensquilla" / "attachments").glob("**/*.pdf")
)
assert len(workspace_paths) == 1
assert workspace_paths[0].read_bytes() == pdf_bytes
sent_text = _all_provider_text(text_provider.calls[-1]["messages"])
assert "Machine Learning" in sent_text
assert "attachment available: L11 RL.pdf (application/pdf" in sent_text
assert ".opensquilla/attachments/" in sent_text
assert workspace_paths[0].name in sent_text
@pytest.mark.asyncio
async def test_current_turn_inline_pdf_is_materialized_to_workspace_path(
_e2e_stack: dict[str, Any],
) -> None:
manager: SessionManager = _e2e_stack["manager"]
subscription_manager: SubscriptionManager = _e2e_stack["subscription_manager"]
sink: _EventSink = _e2e_stack["sink"]
text_provider: _RecordingProvider = _e2e_stack["text_provider"]
config: GatewayConfig = _e2e_stack["config"]
key = "agent:main:pdf-materialization-current-inline"
await manager.create(session_key=key, agent_id="main")
subscription_manager.subscribe_messages(sink.conn_id, key)
pdf_bytes = _sample_pdf_bytes()
await _send_session_turn(
ctx=_e2e_stack["ctx"],
key=key,
sink=sink,
message="请读取这个 PDF",
attachments=[
_inline_attachment(
pdf_bytes,
mime="application/pdf",
name="L11 RL.pdf",
)
],
)
transcript = await manager.get_transcript(key)
persisted = json.loads(transcript[0].content)
persisted_attachment = persisted["attachments"][0]
assert persisted_attachment["name"] == "L11 RL.pdf"
assert persisted_attachment["type"] == "application/pdf"
assert persisted_attachment["data"] == _b64(pdf_bytes)
assert "sha256_ref" not in persisted_attachment
workspace_paths = list(
(Path(config.workspace_dir or "") / ".opensquilla" / "attachments").glob("**/*.pdf")
)
assert len(workspace_paths) == 1
assert workspace_paths[0].read_bytes() == pdf_bytes
sent_text = _all_provider_text(text_provider.calls[-1]["messages"])
assert "Machine Learning" in sent_text
assert "attachment available: L11 RL.pdf (application/pdf" in sent_text
assert ".opensquilla/attachments/" in sent_text
assert workspace_paths[0].name in sent_text
@pytest.mark.asyncio
async def test_historical_pdf_followup_materializes_path_from_sha256_ref(
_e2e_stack: dict[str, Any],
) -> None:
manager: SessionManager = _e2e_stack["manager"]
subscription_manager: SubscriptionManager = _e2e_stack["subscription_manager"]
sink: _EventSink = _e2e_stack["sink"]
text_provider: _RecordingProvider = _e2e_stack["text_provider"]
config: GatewayConfig = _e2e_stack["config"]
key = "agent:main:pdf-materialization-history"
await manager.create(session_key=key, agent_id="main")
subscription_manager.subscribe_messages(sink.conn_id, key)
pdf_bytes = _sample_pdf_bytes()
file_uuid = await _upload_file(
_e2e_stack["app"],
name="L11 RL.pdf",
mime="application/pdf",
payload=pdf_bytes,
)
await _send_session_turn(
ctx=_e2e_stack["ctx"],
key=key,
sink=sink,
message="请读取这个 PDF",
attachments=[_attachment(file_uuid, mime="application/pdf", name="L11 RL.pdf")],
)
await manager.append_message(key, "user", "中间普通文本。")
await manager.append_message(key, "assistant", "普通回答。")
calls_before = len(text_provider.calls)
await _send_session_turn(
ctx=_e2e_stack["ctx"],
key=key,
sink=sink,
message="把刚才那个 PDF 左下角 Machine Learning 遮住",
)
assert len(text_provider.calls) == calls_before + 1
sent_messages = text_provider.calls[-1]["messages"]
sent_text = _all_provider_text(sent_messages)
assert "historical attachment available: L11 RL.pdf (application/pdf" in sent_text
assert "historical attachment omitted: L11 RL.pdf" not in sent_text
assert ".opensquilla/attachments/" in sent_text
assert isinstance(sent_messages[-1].content, str)
assert sent_messages[-1].content.startswith("把刚才那个 PDF")
workspace_paths = list(
(Path(config.workspace_dir or "") / ".opensquilla" / "attachments").glob("**/*.pdf")
)
assert len(workspace_paths) == 1
assert hashlib.sha256(workspace_paths[0].read_bytes()).digest() == hashlib.sha256(
pdf_bytes
).digest()
@pytest.mark.asyncio
async def test_historical_inline_pdf_followup_materializes_path_from_inline_data(
_e2e_stack: dict[str, Any],
) -> None:
manager: SessionManager = _e2e_stack["manager"]
subscription_manager: SubscriptionManager = _e2e_stack["subscription_manager"]
sink: _EventSink = _e2e_stack["sink"]
text_provider: _RecordingProvider = _e2e_stack["text_provider"]
config: GatewayConfig = _e2e_stack["config"]
key = "agent:main:pdf-materialization-history-inline"
await manager.create(session_key=key, agent_id="main")
subscription_manager.subscribe_messages(sink.conn_id, key)
pdf_bytes = _sample_pdf_bytes()
await _send_session_turn(
ctx=_e2e_stack["ctx"],
key=key,
sink=sink,
message="请读取这个 PDF",
attachments=[
_inline_attachment(
pdf_bytes,
mime="application/pdf",
name="L11 RL.pdf",
)
],
)
await manager.append_message(key, "user", "中间普通文本。")
await manager.append_message(key, "assistant", "普通回答。")
calls_before = len(text_provider.calls)
await _send_session_turn(
ctx=_e2e_stack["ctx"],
key=key,
sink=sink,
message="把刚才那个 PDF 左下角 Machine Learning 遮住",
)
assert len(text_provider.calls) == calls_before + 1
sent_messages = text_provider.calls[-1]["messages"]
sent_text = _all_provider_text(sent_messages)
assert "historical attachment available: L11 RL.pdf (application/pdf" in sent_text
assert "historical attachment omitted: L11 RL.pdf" not in sent_text
assert ".opensquilla/attachments/" in sent_text
assert isinstance(sent_messages[-1].content, str)
assert sent_messages[-1].content.startswith("把刚才那个 PDF")
workspace_paths = list(
(Path(config.workspace_dir or "") / ".opensquilla" / "attachments").glob("**/*.pdf")
)
assert len(workspace_paths) == 1
assert hashlib.sha256(workspace_paths[0].read_bytes()).digest() == hashlib.sha256(
pdf_bytes
).digest()
@pytest.mark.asyncio
async def test_pdf_materialization_does_not_require_image_tier(
_e2e_stack: dict[str, Any],
) -> None:
config: GatewayConfig = _e2e_stack["config"]
config.squilla_router.tiers.pop("image_model", None)
manager: SessionManager = _e2e_stack["manager"]
subscription_manager: SubscriptionManager = _e2e_stack["subscription_manager"]
sink: _EventSink = _e2e_stack["sink"]
text_provider: _RecordingProvider = _e2e_stack["text_provider"]
key = "agent:main:pdf-materialization-no-image-tier"
await manager.create(session_key=key, agent_id="main")
subscription_manager.subscribe_messages(sink.conn_id, key)
file_uuid = await _upload_file(
_e2e_stack["app"],
name="L11 RL.pdf",
mime="application/pdf",
payload=_sample_pdf_bytes(),
)
await _send_session_turn(
ctx=_e2e_stack["ctx"],
key=key,
sink=sink,
message="请读取这个 PDF",
attachments=[_attachment(file_uuid, mime="application/pdf", name="L11 RL.pdf")],
)
sent_text = _all_provider_text(text_provider.calls[-1]["messages"])
assert "attachment available: L11 RL.pdf (application/pdf" in sent_text
done_events = _event_payloads(sink, "session.event.done")
assert done_events[-1].get("image_route_reason") is None
@pytest.mark.asyncio
async def test_mixed_historical_image_and_pdf_followup_replays_image_and_materializes_pdf(
_e2e_stack: dict[str, Any],
) -> None:
manager: SessionManager = _e2e_stack["manager"]
subscription_manager: SubscriptionManager = _e2e_stack["subscription_manager"]
sink: _EventSink = _e2e_stack["sink"]
vision_provider: _RecordingProvider = _e2e_stack["vision_provider"]
key = "agent:main:mixed-image-pdf-history"
await manager.create(session_key=key, agent_id="main")
subscription_manager.subscribe_messages(sink.conn_id, key)
image_uuid = await _upload_file(
_e2e_stack["app"],
name="first.png",
mime="image/png",
payload=_PNG_BYTES,
)
pdf_bytes = _sample_pdf_bytes()
pdf_uuid = await _upload_file(
_e2e_stack["app"],
name="L11 RL.pdf",
mime="application/pdf",
payload=pdf_bytes,
)
await _send_session_turn(
ctx=_e2e_stack["ctx"],
key=key,
sink=sink,
message="请同时看看这张图和 PDF",
attachments=[
_attachment(image_uuid, mime="image/png", name="first.png"),
_attachment(pdf_uuid, mime="application/pdf", name="L11 RL.pdf"),
],
)
await manager.append_message(key, "user", "中间普通文本。")
await manager.append_message(key, "assistant", "普通回答。")
vision_calls_before = len(vision_provider.calls)
await _send_session_turn(
ctx=_e2e_stack["ctx"],
key=key,
sink=sink,
message="刚才那张图右上角是什么?另外 PDF 也保持可编辑。",
)
assert len(vision_provider.calls) == vision_calls_before + 1
sent_messages = vision_provider.calls[-1]["messages"]
assert any(_message_has_image(message) for message in sent_messages[:-1])
sent_text = _all_provider_text(sent_messages)
assert "historical attachment available: L11 RL.pdf (application/pdf" in sent_text
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
@pytest.mark.asyncio
async def test_router_text_only_does_not_replay_image_but_keeps_pdf_path(
_e2e_stack: dict[str, Any],
) -> None:
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:mixed-image-pdf-text-only"
await manager.create(session_key=key, agent_id="main")
subscription_manager.subscribe_messages(sink.conn_id, key)
image_uuid = await _upload_file(
_e2e_stack["app"],
name="first.png",
mime="image/png",
payload=_PNG_BYTES,
)
pdf_uuid = await _upload_file(
_e2e_stack["app"],
name="L11 RL.pdf",
mime="application/pdf",
payload=_sample_pdf_bytes(),
)
await _send_session_turn(
ctx=_e2e_stack["ctx"],
key=key,
sink=sink,
message="请同时看看这张图和 PDF",
attachments=[
_attachment(image_uuid, mime="image/png", name="first.png"),
_attachment(pdf_uuid, mime="application/pdf", name="L11 RL.pdf"),
],
)
await manager.append_message(key, "user", "中间普通文本。")
await manager.append_message(key, "assistant", "普通回答。")
gate_provider.text = (
'{"decision":"text_only","confidence":0.91,"reason":"file edit only"}'
)
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="刚才那个 PDF 的 Machine Learning 文字需要遮住。",
)
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)
sent_text = _all_provider_text(sent_messages)
assert "historical attachment available: L11 RL.pdf (application/pdf" in sent_text
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
@pytest.mark.asyncio
async def test_attachment_filename_traversal_is_sanitized(
_e2e_stack: dict[str, Any],
) -> None:
manager: SessionManager = _e2e_stack["manager"]
subscription_manager: SubscriptionManager = _e2e_stack["subscription_manager"]
sink: _EventSink = _e2e_stack["sink"]
text_provider: _RecordingProvider = _e2e_stack["text_provider"]
config: GatewayConfig = _e2e_stack["config"]
key = "agent:main:pdf-materialization-traversal"
await manager.create(session_key=key, agent_id="main")
subscription_manager.subscribe_messages(sink.conn_id, key)
file_uuid = await _upload_file(
_e2e_stack["app"],
name="../../evil.pdf",
mime="application/pdf",
payload=_sample_pdf_bytes(),
)
await _send_session_turn(
ctx=_e2e_stack["ctx"],
key=key,
sink=sink,
message="请读取这个 PDF",
attachments=[_attachment(file_uuid, mime="application/pdf", name="../../evil.pdf")],
)
workspace_root = Path(config.workspace_dir or "").resolve()
workspace_paths = list((workspace_root / ".opensquilla" / "attachments").glob("**/*.pdf"))
assert len(workspace_paths) == 1
materialized = workspace_paths[0].resolve()
materialized.relative_to(workspace_root)
assert ".." not in materialized.relative_to(workspace_root).as_posix()
assert materialized.name.endswith("evil.pdf")
sent_text = _all_provider_text(text_provider.calls[-1]["messages"])
assert "../" not in sent_text
assert ".opensquilla/attachments/" in sent_text
@pytest.mark.asyncio
async def test_missing_material_emits_unavailable_marker(
_e2e_stack: dict[str, Any],
) -> None:
manager: SessionManager = _e2e_stack["manager"]
subscription_manager: SubscriptionManager = _e2e_stack["subscription_manager"]
sink: _EventSink = _e2e_stack["sink"]
text_provider: _RecordingProvider = _e2e_stack["text_provider"]
config: GatewayConfig = _e2e_stack["config"]
key = "agent:main:pdf-materialization-missing"
session = await manager.create(session_key=key, agent_id="main")
subscription_manager.subscribe_messages(sink.conn_id, key)
file_uuid = await _upload_file(
_e2e_stack["app"],
name="L11 RL.pdf",
mime="application/pdf",
payload=_sample_pdf_bytes(),
)
await _send_session_turn(
ctx=_e2e_stack["ctx"],
key=key,
sink=sink,
message="请读取这个 PDF",
attachments=[_attachment(file_uuid, mime="application/pdf", name="L11 RL.pdf")],
)
transcript = await manager.get_transcript(key)
persisted = json.loads(transcript[0].content)
sha = persisted["attachments"][0]["sha256_ref"]
transcript_material_path(
Path(config.attachments.media_root or ""),
session.session_id,
sha,
).unlink()
calls_before = len(text_provider.calls)
await _send_session_turn(
ctx=_e2e_stack["ctx"],
key=key,
sink=sink,
message="刚才那个 PDF 还能编辑吗?",
)
assert len(text_provider.calls) == calls_before + 1
sent_text = _all_provider_text(text_provider.calls[-1]["messages"])
assert "historical attachment unavailable: L11 RL.pdf (application/pdf)" in sent_text
assert "historical attachment available: L11 RL.pdf" not in sent_text