chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from opensquilla.engine import Agent, AgentConfig, ToolResult
|
||||
from opensquilla.engine.types import ToolCall
|
||||
from opensquilla.execution_status import runtime_execution_status
|
||||
from opensquilla.provider import (
|
||||
ChatConfig,
|
||||
ContentBlockToolResult,
|
||||
ContentBlockToolUse,
|
||||
Message,
|
||||
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
|
||||
|
||||
|
||||
class _BoundaryProvider:
|
||||
provider_name = "synthetic"
|
||||
|
||||
def __init__(self, large_local_argument: str) -> None:
|
||||
self.large_local_argument = large_local_argument
|
||||
self.calls: list[list[Message]] = []
|
||||
|
||||
def chat(
|
||||
self,
|
||||
messages: list[Message],
|
||||
tools: list[Any] | None = None,
|
||||
config: ChatConfig | None = None,
|
||||
) -> AsyncIterator[Any]:
|
||||
self.calls.append(messages)
|
||||
return self._stream(len(self.calls))
|
||||
|
||||
async def _stream(self, call_number: int) -> AsyncIterator[Any]:
|
||||
if call_number == 1:
|
||||
calls = [
|
||||
(
|
||||
"local-large-1",
|
||||
"local_context_builder",
|
||||
{"content": self.large_local_argument, "label": "local"},
|
||||
),
|
||||
("fetch-large-0", "web_fetch", {"url": "https://example.com/0"}),
|
||||
("fetch-large-1", "web_fetch", {"url": "https://example.com/1"}),
|
||||
("fetch-error-2", "web_fetch", {"url": "https://example.com/2"}),
|
||||
]
|
||||
for tool_use_id, tool_name, arguments in calls:
|
||||
yield ProviderToolUseStart(
|
||||
tool_use_id=tool_use_id,
|
||||
tool_name=tool_name,
|
||||
)
|
||||
yield ProviderToolUseEnd(
|
||||
tool_use_id=tool_use_id,
|
||||
tool_name=tool_name,
|
||||
arguments=arguments,
|
||||
)
|
||||
yield ProviderDone(stop_reason="tool_use", input_tokens=500, output_tokens=50)
|
||||
return
|
||||
yield ProviderText(text="BOUNDARY_E2E_OK")
|
||||
yield ProviderDone(stop_reason="stop", input_tokens=500, output_tokens=10)
|
||||
|
||||
async def list_models(self) -> list[Any]:
|
||||
return []
|
||||
|
||||
|
||||
def _tool_def(name: str) -> ToolDefinition:
|
||||
return ToolDefinition(
|
||||
name=name,
|
||||
description=f"Synthetic {name}.",
|
||||
input_schema=ToolInputSchema(properties={"content": {}, "url": {}, "label": {}}),
|
||||
)
|
||||
|
||||
|
||||
def _tool_blocks(messages: list[Message]) -> list[ContentBlockToolUse]:
|
||||
blocks: list[ContentBlockToolUse] = []
|
||||
for message in messages:
|
||||
if not isinstance(message.content, list):
|
||||
continue
|
||||
blocks.extend(
|
||||
block for block in message.content if isinstance(block, ContentBlockToolUse)
|
||||
)
|
||||
return blocks
|
||||
|
||||
|
||||
def _tool_result_blocks(messages: list[Message]) -> list[ContentBlockToolResult]:
|
||||
blocks: list[ContentBlockToolResult] = []
|
||||
for message in messages:
|
||||
if not isinstance(message.content, list):
|
||||
continue
|
||||
blocks.extend(
|
||||
block for block in message.content if isinstance(block, ContentBlockToolResult)
|
||||
)
|
||||
return blocks
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_multi_turn_boundary_e2e_keeps_critical_context() -> None:
|
||||
large_local_argument = "LOCAL_ARGUMENT_START\n" + ("x" * 24_000)
|
||||
provider = _BoundaryProvider(large_local_argument)
|
||||
|
||||
async def tool_handler(call: ToolCall) -> ToolResult:
|
||||
if call.tool_use_id == "local-large-1":
|
||||
return ToolResult(
|
||||
tool_use_id=call.tool_use_id,
|
||||
tool_name=call.tool_name,
|
||||
content="local context stored",
|
||||
is_error=False,
|
||||
)
|
||||
if call.tool_use_id == "fetch-error-2":
|
||||
return ToolResult(
|
||||
tool_use_id=call.tool_use_id,
|
||||
tool_name=call.tool_name,
|
||||
content="FETCH_FAILURE_MARKER " + ("e" * 8_000),
|
||||
is_error=True,
|
||||
execution_status=runtime_execution_status(
|
||||
"error",
|
||||
reason="runtime_error",
|
||||
),
|
||||
)
|
||||
suffix = call.tool_use_id.rsplit("-", 1)[-1]
|
||||
return ToolResult(
|
||||
tool_use_id=call.tool_use_id,
|
||||
tool_name=call.tool_name,
|
||||
content=f"FETCH_RESULT_{suffix}_START\n" + ("r" * 48_000),
|
||||
is_error=False,
|
||||
)
|
||||
|
||||
agent = Agent(
|
||||
provider=provider,
|
||||
config=AgentConfig(
|
||||
context_window_tokens=200_000,
|
||||
max_tokens=8192,
|
||||
flush_enabled=False,
|
||||
max_iterations=4,
|
||||
),
|
||||
tool_definitions=[_tool_def("local_context_builder"), _tool_def("web_fetch")],
|
||||
tool_handler=tool_handler,
|
||||
)
|
||||
|
||||
events = [event async for event in agent.run_turn("exercise context boundaries")]
|
||||
|
||||
assert any(event.kind == "done" and event.text == "BOUNDARY_E2E_OK" for event in events)
|
||||
assert len(provider.calls) == 2
|
||||
|
||||
replay = provider.calls[1]
|
||||
tool_uses = _tool_blocks(replay)
|
||||
local_tool_use = next(block for block in tool_uses if block.id == "local-large-1")
|
||||
assert local_tool_use.input["content"] == large_local_argument
|
||||
|
||||
result_content_by_id = {
|
||||
block.tool_use_id: block.content for block in _tool_result_blocks(replay)
|
||||
}
|
||||
assert str(result_content_by_id["fetch-large-0"]).startswith("FETCH_RESULT_0_START")
|
||||
assert "external_tool_result_compacted" not in str(
|
||||
result_content_by_id["fetch-large-0"]
|
||||
)
|
||||
assert "FETCH_FAILURE_MARKER" in str(result_content_by_id["fetch-error-2"])
|
||||
assert all(
|
||||
"opensquilla_compacted" not in str(block.input)
|
||||
and "tool_use_argument_projection" not in str(block.input)
|
||||
for block in tool_uses
|
||||
)
|
||||
@@ -0,0 +1,176 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from collections.abc import AsyncIterator
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from opensquilla.engine import Agent, AgentConfig, ToolResult
|
||||
from opensquilla.engine.types import ToolCall
|
||||
from opensquilla.provider import (
|
||||
ChatConfig,
|
||||
Message,
|
||||
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,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.local_golden
|
||||
|
||||
CASES_DIR = Path(__file__).resolve().parents[1] / "fixtures" / "agent_chains" / "synthetic_cases"
|
||||
|
||||
|
||||
class _SyntheticCaseProvider:
|
||||
provider_name = "synthetic"
|
||||
|
||||
def __init__(self, turns: list[dict[str, Any]]) -> None:
|
||||
self._turns = turns
|
||||
self.calls: list[list[Message]] = []
|
||||
|
||||
def chat(
|
||||
self,
|
||||
messages: list[Message],
|
||||
tools: list[Any] | None = None,
|
||||
config: ChatConfig | None = None,
|
||||
) -> AsyncIterator[Any]:
|
||||
self.calls.append(messages)
|
||||
return self._stream(len(self.calls))
|
||||
|
||||
async def _stream(self, call_number: int) -> AsyncIterator[Any]:
|
||||
turn = self._turns[call_number - 1]
|
||||
tool_calls = turn.get("tool_calls") or []
|
||||
for tool_call in tool_calls:
|
||||
yield ProviderToolUseStart(
|
||||
tool_use_id=tool_call["id"],
|
||||
tool_name=tool_call["name"],
|
||||
)
|
||||
yield ProviderToolUseEnd(
|
||||
tool_use_id=tool_call["id"],
|
||||
tool_name=tool_call["name"],
|
||||
arguments=tool_call["arguments"],
|
||||
)
|
||||
if tool_calls:
|
||||
yield ProviderDone(stop_reason="tool_use", input_tokens=10, output_tokens=2)
|
||||
return
|
||||
|
||||
yield ProviderText(text=turn["final_text"])
|
||||
yield ProviderDone(stop_reason="stop", input_tokens=12, output_tokens=3)
|
||||
|
||||
async def list_models(self) -> list[Any]:
|
||||
return []
|
||||
|
||||
|
||||
def _tool_def(name: str, properties: dict[str, Any]) -> ToolDefinition:
|
||||
return ToolDefinition(
|
||||
name=name,
|
||||
description=f"Synthetic {name}.",
|
||||
input_schema=ToolInputSchema(
|
||||
properties=properties,
|
||||
required=list(properties),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _case_paths() -> list[Path]:
|
||||
return sorted(CASES_DIR.glob("*.json"))
|
||||
|
||||
|
||||
def _load_case(path: Path) -> dict[str, Any]:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _result_content(raw: Any) -> str:
|
||||
if isinstance(raw, str):
|
||||
return raw
|
||||
return json.dumps(raw, sort_keys=True)
|
||||
|
||||
|
||||
def _message_contains_tool_use(messages: list[Message], tool_use_id: str) -> bool:
|
||||
return any(
|
||||
message.role == "assistant"
|
||||
and any(getattr(block, "id", "") == tool_use_id for block in message.content)
|
||||
for message in messages
|
||||
)
|
||||
|
||||
|
||||
def _message_contains_tool_result(messages: list[Message], tool_use_id: str) -> bool:
|
||||
return any(
|
||||
message.role == "user"
|
||||
and any(getattr(block, "tool_use_id", "") == tool_use_id for block in message.content)
|
||||
for message in messages
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("case_path", _case_paths(), ids=lambda path: path.stem)
|
||||
async def test_synthetic_complex_agent_golden(case_path: Path) -> None:
|
||||
if os.environ.get("OPENSQUILLA_RUN_LOCAL_GOLDENS") != "1":
|
||||
pytest.skip("set OPENSQUILLA_RUN_LOCAL_GOLDENS=1 to run synthetic local goldens")
|
||||
|
||||
case = _load_case(case_path)
|
||||
provider = _SyntheticCaseProvider(case["turns"])
|
||||
handled: list[tuple[str, str, dict[str, Any], bool]] = []
|
||||
|
||||
async def tool_handler(call: ToolCall) -> ToolResult:
|
||||
payload = case["tool_results"][call.tool_use_id]
|
||||
is_error = not bool(payload["ok"])
|
||||
handled.append((call.tool_use_id, call.tool_name, dict(call.arguments), is_error))
|
||||
return ToolResult(
|
||||
tool_use_id=call.tool_use_id,
|
||||
tool_name=call.tool_name,
|
||||
content=_result_content(payload["content"]),
|
||||
is_error=is_error,
|
||||
)
|
||||
|
||||
agent = Agent(
|
||||
provider=provider,
|
||||
config=AgentConfig(max_iterations=int(case["max_iterations"])),
|
||||
tool_definitions=[
|
||||
_tool_def(tool["name"], tool["properties"])
|
||||
for tool in case["tools"]
|
||||
],
|
||||
tool_handler=tool_handler,
|
||||
)
|
||||
|
||||
events = [event async for event in agent.run_turn(case["prompt"])]
|
||||
expect = case["expect"]
|
||||
|
||||
assert [tool_name for _tool_id, tool_name, _args, _is_error in handled] == expect[
|
||||
"tool_order"
|
||||
]
|
||||
assert [tool_id for tool_id, _tool_name, _args, _is_error in handled] == expect["tool_ids"]
|
||||
assert [
|
||||
tool_id for tool_id, _tool_name, _args, is_error in handled if is_error
|
||||
] == expect["error_tool_ids"]
|
||||
assert len(provider.calls) == int(expect["iterations"])
|
||||
assert not any(event.kind == "error" for event in events)
|
||||
assert any(
|
||||
event.kind == "done" and event.text == expect["final_text"]
|
||||
for event in events
|
||||
)
|
||||
|
||||
tool_ids_by_turn = [
|
||||
[tool_call["id"] for tool_call in turn.get("tool_calls") or []]
|
||||
for turn in case["turns"]
|
||||
]
|
||||
for turn_index, tool_ids in enumerate(tool_ids_by_turn):
|
||||
if not tool_ids:
|
||||
continue
|
||||
replay_call = provider.calls[turn_index + 1]
|
||||
for tool_id in tool_ids:
|
||||
assert _message_contains_tool_use(replay_call, tool_id)
|
||||
assert _message_contains_tool_result(replay_call, tool_id)
|
||||
@@ -0,0 +1,600 @@
|
||||
"""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
|
||||
@@ -0,0 +1,372 @@
|
||||
"""Opt-in gateway/WebSocket compaction regression tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import socket
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from opensquilla.cli.gateway_client import GatewayClient
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
return int(sock.getsockname()[1])
|
||||
|
||||
|
||||
def _wait_for_health(port: int, server: subprocess.Popen[str]) -> None:
|
||||
url = f"http://127.0.0.1:{port}/health"
|
||||
deadline = time.monotonic() + 20.0
|
||||
last_error = ""
|
||||
while time.monotonic() < deadline:
|
||||
if server.poll() is not None:
|
||||
stdout = server.stdout.read() if server.stdout else ""
|
||||
stderr = server.stderr.read() if server.stderr else ""
|
||||
raise AssertionError(
|
||||
f"gateway exited early code={server.returncode}\nstdout={stdout}\nstderr={stderr}"
|
||||
)
|
||||
try:
|
||||
response = httpx.get(url, timeout=1.0)
|
||||
if response.status_code == 200 and response.json().get("ok") is True:
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001 - surfaced on timeout.
|
||||
last_error = str(exc)
|
||||
time.sleep(0.1)
|
||||
raise AssertionError(f"gateway did not become healthy: {last_error}")
|
||||
|
||||
|
||||
def _stop_process(server: subprocess.Popen[str]) -> None:
|
||||
if server.poll() is not None:
|
||||
return
|
||||
server.terminate()
|
||||
try:
|
||||
server.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
server.kill()
|
||||
server.wait(timeout=10)
|
||||
|
||||
|
||||
def _kill_process(server: subprocess.Popen[str]) -> None:
|
||||
if server.poll() is not None:
|
||||
return
|
||||
server.kill()
|
||||
server.wait(timeout=10)
|
||||
|
||||
|
||||
def _write_slow_compaction_server(
|
||||
path: Path,
|
||||
*,
|
||||
port: int,
|
||||
db_path: Path,
|
||||
session_key: str,
|
||||
) -> None:
|
||||
path.write_text(
|
||||
textwrap.dedent(
|
||||
f"""
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from opensquilla.gateway.boot import start_gateway_server
|
||||
from opensquilla.gateway.config import AuthConfig, GatewayConfig
|
||||
from opensquilla.gateway.websocket import SubscriptionManager
|
||||
from opensquilla.session.manager import SessionManager
|
||||
from opensquilla.session.storage import SessionStorage
|
||||
|
||||
SESSION_KEY = {session_key!r}
|
||||
|
||||
|
||||
class SlowCompactionSessionManager(SessionManager):
|
||||
async def compact_with_result(self, *args, **kwargs):
|
||||
await asyncio.sleep(1.2)
|
||||
return await super().compact_with_result(*args, **kwargs)
|
||||
|
||||
|
||||
async def seed(manager):
|
||||
existing = await manager.get_session(SESSION_KEY)
|
||||
if existing is not None:
|
||||
return
|
||||
node = await manager.create(
|
||||
session_key=SESSION_KEY,
|
||||
agent_id="main",
|
||||
display_name="slow compaction e2e",
|
||||
)
|
||||
for idx in range(11):
|
||||
await manager.append_message(
|
||||
node.session_key,
|
||||
role="user" if idx % 2 == 0 else "assistant",
|
||||
content=("seed transcript block %02d " % idx) + ("alpha beta gamma " * 80),
|
||||
token_count=320,
|
||||
)
|
||||
|
||||
|
||||
async def main():
|
||||
os.makedirs(os.environ["OPENSQUILLA_STATE_DIR"], exist_ok=True)
|
||||
storage = SessionStorage({str(db_path)!r})
|
||||
await storage.connect()
|
||||
manager = SlowCompactionSessionManager(storage, inject_time_prefix=False)
|
||||
await seed(manager)
|
||||
config = GatewayConfig(
|
||||
host="127.0.0.1",
|
||||
port={port},
|
||||
auth=AuthConfig(mode="none"),
|
||||
)
|
||||
config.state_dir = os.environ["OPENSQUILLA_STATE_DIR"]
|
||||
await start_gateway_server(
|
||||
config=config,
|
||||
session_manager=manager,
|
||||
subscription_manager=SubscriptionManager(),
|
||||
run=True,
|
||||
)
|
||||
await asyncio.Event().wait()
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
"""
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
async def _manual_compact_over_websocket(port: int, session_key: str) -> dict[str, Any]:
|
||||
client = GatewayClient()
|
||||
await client.connect(f"ws://127.0.0.1:{port}/ws")
|
||||
try:
|
||||
await client.call("sessions.subscribe")
|
||||
await client.call("sessions.messages.subscribe", {"key": session_key})
|
||||
compact_task = asyncio.create_task(
|
||||
client.call(
|
||||
"sessions.contextCompact",
|
||||
{"key": session_key, "contextWindowTokens": 1000},
|
||||
)
|
||||
)
|
||||
|
||||
statuses: list[str] = []
|
||||
all_statuses: list[str] = []
|
||||
while True:
|
||||
if compact_task.done() and "completed" in all_statuses:
|
||||
break
|
||||
frame = await asyncio.wait_for(client._recv_queue.get(), timeout=45.0) # noqa: SLF001
|
||||
if frame.get("event") != "session.event.compaction":
|
||||
continue
|
||||
payload = frame.get("payload") or {}
|
||||
status = str(payload.get("status") or "")
|
||||
all_statuses.append(status)
|
||||
if status:
|
||||
statuses.append(status)
|
||||
if status == "started":
|
||||
assert compact_task.done() is False
|
||||
if status in {"completed", "failed", "skipped", "cancelled"}:
|
||||
break
|
||||
|
||||
result = await asyncio.wait_for(compact_task, timeout=15.0)
|
||||
return {
|
||||
"statuses": statuses,
|
||||
"all_statuses": all_statuses,
|
||||
"result": result,
|
||||
}
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
async def _start_manual_compact_and_wait_for_started(
|
||||
port: int, session_key: str
|
||||
) -> tuple[GatewayClient, asyncio.Task[Any], dict[str, Any]]:
|
||||
client = GatewayClient()
|
||||
await client.connect(f"ws://127.0.0.1:{port}/ws")
|
||||
await client.call("sessions.subscribe")
|
||||
await client.call("sessions.messages.subscribe", {"key": session_key})
|
||||
compact_task = asyncio.create_task(
|
||||
client.call(
|
||||
"sessions.contextCompact",
|
||||
{"key": session_key, "contextWindowTokens": 1000},
|
||||
)
|
||||
)
|
||||
|
||||
while True:
|
||||
frame = await asyncio.wait_for(client._recv_queue.get(), timeout=15.0) # noqa: SLF001
|
||||
if frame.get("event") != "session.event.compaction":
|
||||
continue
|
||||
payload = frame.get("payload") or {}
|
||||
if payload.get("status") == "started":
|
||||
assert compact_task.done() is False
|
||||
return client, compact_task, payload
|
||||
|
||||
|
||||
def _read_compaction_db_state(db_path: Path) -> dict[str, int]:
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
session = conn.execute(
|
||||
"select session_id, compaction_count from sessions limit 1"
|
||||
).fetchone()
|
||||
assert session is not None
|
||||
session_id = session["session_id"]
|
||||
summary_count = conn.execute(
|
||||
"select count(*) from session_summaries where session_id = ?",
|
||||
(session_id,),
|
||||
).fetchone()[0]
|
||||
entry_count = conn.execute(
|
||||
"select count(*) from transcript_entries where session_id = ?",
|
||||
(session_id,),
|
||||
).fetchone()[0]
|
||||
running_tasks = conn.execute(
|
||||
"select count(*) from agent_tasks where status in ('queued', 'running')"
|
||||
).fetchone()[0]
|
||||
return {
|
||||
"compaction_count": int(session["compaction_count"] or 0),
|
||||
"summary_count": int(summary_count),
|
||||
"entry_count": int(entry_count),
|
||||
"running_tasks": int(running_tasks),
|
||||
}
|
||||
|
||||
|
||||
def _start_slow_compaction_gateway(
|
||||
tmp_path: Path,
|
||||
*,
|
||||
port: int,
|
||||
db_path: Path,
|
||||
session_key: str,
|
||||
env: dict[str, str],
|
||||
) -> subprocess.Popen[str]:
|
||||
server_script = tmp_path / "slow_compaction_gateway.py"
|
||||
_write_slow_compaction_server(
|
||||
server_script,
|
||||
port=port,
|
||||
db_path=db_path,
|
||||
session_key=session_key,
|
||||
)
|
||||
return subprocess.Popen(
|
||||
[sys.executable, str(server_script)],
|
||||
cwd=Path.cwd(),
|
||||
env=env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gateway_websocket_slow_manual_compaction_rewrites_db(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
if os.environ.get("OPENSQUILLA_GATEWAY_COMPACTION_E2E") != "1":
|
||||
pytest.skip("set OPENSQUILLA_GATEWAY_COMPACTION_E2E=1 to run compaction e2e")
|
||||
|
||||
port = _free_port()
|
||||
session_key = "agent:main:webchat:slowcompact"
|
||||
state_dir = tmp_path / "state"
|
||||
log_dir = tmp_path / "logs"
|
||||
db_path = state_dir / "sessions.db"
|
||||
|
||||
env = os.environ.copy()
|
||||
env["OPENSQUILLA_STATE_DIR"] = str(state_dir)
|
||||
env["OPENSQUILLA_LOG_DIR"] = str(log_dir)
|
||||
server = _start_slow_compaction_gateway(
|
||||
tmp_path,
|
||||
port=port,
|
||||
db_path=db_path,
|
||||
session_key=session_key,
|
||||
env=env,
|
||||
)
|
||||
try:
|
||||
_wait_for_health(port, server)
|
||||
observed = await asyncio.wait_for(
|
||||
_manual_compact_over_websocket(port, session_key),
|
||||
timeout=90.0,
|
||||
)
|
||||
finally:
|
||||
_stop_process(server)
|
||||
|
||||
result = observed["result"]
|
||||
db_state = _read_compaction_db_state(db_path)
|
||||
assert observed["statuses"] == ["started", "observed", "observed", "completed"]
|
||||
assert result["compacted"] is True
|
||||
assert result["tokens_before"] == 3520
|
||||
assert result["tokens_after"] < result["tokens_before"]
|
||||
assert db_state["compaction_count"] == 1
|
||||
assert db_state["summary_count"] == 1
|
||||
assert db_state["entry_count"] == result["kept_count"]
|
||||
assert db_state["running_tasks"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gateway_restart_during_slow_manual_compaction_leaves_db_recoverable(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
if os.environ.get("OPENSQUILLA_GATEWAY_COMPACTION_E2E") != "1":
|
||||
pytest.skip("set OPENSQUILLA_GATEWAY_COMPACTION_E2E=1 to run compaction e2e")
|
||||
|
||||
port = _free_port()
|
||||
session_key = "agent:main:webchat:slowcompact"
|
||||
state_dir = tmp_path / "state"
|
||||
log_dir = tmp_path / "logs"
|
||||
db_path = state_dir / "sessions.db"
|
||||
env = os.environ.copy()
|
||||
env["OPENSQUILLA_STATE_DIR"] = str(state_dir)
|
||||
env["OPENSQUILLA_LOG_DIR"] = str(log_dir)
|
||||
|
||||
server = _start_slow_compaction_gateway(
|
||||
tmp_path,
|
||||
port=port,
|
||||
db_path=db_path,
|
||||
session_key=session_key,
|
||||
env=env,
|
||||
)
|
||||
client: GatewayClient | None = None
|
||||
compact_task: asyncio.Task[Any] | None = None
|
||||
try:
|
||||
_wait_for_health(port, server)
|
||||
client, compact_task, _payload = await _start_manual_compact_and_wait_for_started(
|
||||
port,
|
||||
session_key,
|
||||
)
|
||||
_kill_process(server)
|
||||
with pytest.raises(Exception):
|
||||
await asyncio.wait_for(compact_task, timeout=5.0)
|
||||
finally:
|
||||
if client is not None:
|
||||
await client.close()
|
||||
_stop_process(server)
|
||||
|
||||
interrupted_state = _read_compaction_db_state(db_path)
|
||||
assert interrupted_state == {
|
||||
"compaction_count": 0,
|
||||
"summary_count": 0,
|
||||
"entry_count": 11,
|
||||
"running_tasks": 0,
|
||||
}
|
||||
|
||||
restarted = _start_slow_compaction_gateway(
|
||||
tmp_path,
|
||||
port=port,
|
||||
db_path=db_path,
|
||||
session_key=session_key,
|
||||
env=env,
|
||||
)
|
||||
try:
|
||||
_wait_for_health(port, restarted)
|
||||
observed = await asyncio.wait_for(
|
||||
_manual_compact_over_websocket(port, session_key),
|
||||
timeout=90.0,
|
||||
)
|
||||
finally:
|
||||
_stop_process(restarted)
|
||||
|
||||
assert observed["statuses"] == ["started", "observed", "observed", "completed"]
|
||||
assert observed["result"]["compacted"] is True
|
||||
recovered_state = _read_compaction_db_state(db_path)
|
||||
assert recovered_state["compaction_count"] == 1
|
||||
assert recovered_state["summary_count"] == 1
|
||||
assert recovered_state["running_tasks"] == 0
|
||||
@@ -0,0 +1,219 @@
|
||||
"""Opt-in gateway-level live LLM e2e.
|
||||
|
||||
Runs a real gateway, creates a session over the public WebSocket client, sends
|
||||
one prompt through the normal session path, and verifies the provider response.
|
||||
It skips unless explicitly enabled and credentialed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from opensquilla.cli.gateway_client import GatewayClient
|
||||
|
||||
pytestmark = [pytest.mark.llm, pytest.mark.llm_smoke, pytest.mark.llm_gateway]
|
||||
|
||||
_EXPECTED_TOKEN = "opensquilla-gateway-live-ok"
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
with socket.socket() as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
return int(sock.getsockname()[1])
|
||||
|
||||
|
||||
def _wait_for_health(port: int, server: subprocess.Popen[str]) -> None:
|
||||
url = f"http://127.0.0.1:{port}/health"
|
||||
deadline = time.monotonic() + 45.0
|
||||
last_error = ""
|
||||
while time.monotonic() < deadline:
|
||||
if server.poll() is not None:
|
||||
stdout = server.stdout.read() if server.stdout else ""
|
||||
stderr = server.stderr.read() if server.stderr else ""
|
||||
raise AssertionError(
|
||||
f"gateway exited early code={server.returncode}\nstdout={stdout}\nstderr={stderr}"
|
||||
)
|
||||
try:
|
||||
response = httpx.get(url, timeout=1.0)
|
||||
if response.status_code == 200 and response.json().get("ok") is True:
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001 - surfaced on timeout.
|
||||
last_error = str(exc)
|
||||
time.sleep(0.2)
|
||||
raise AssertionError(f"gateway did not become healthy: {last_error}")
|
||||
|
||||
|
||||
def _stop_process(process: subprocess.Popen[str]) -> None:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait(timeout=10)
|
||||
|
||||
|
||||
def _toml_string(value: str | Path) -> str:
|
||||
return json.dumps(str(value))
|
||||
|
||||
|
||||
def _write_gateway_config(
|
||||
path: Path,
|
||||
*,
|
||||
port: int,
|
||||
state_dir: Path,
|
||||
workspace_dir: Path,
|
||||
) -> None:
|
||||
path.write_text(
|
||||
textwrap.dedent(
|
||||
f"""
|
||||
host = "127.0.0.1"
|
||||
port = {port}
|
||||
state_dir = {_toml_string(state_dir)}
|
||||
workspace_dir = {_toml_string(workspace_dir)}
|
||||
|
||||
[auth]
|
||||
mode = "none"
|
||||
"""
|
||||
).lstrip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _write_gateway_server_script(path: Path) -> None:
|
||||
path.write_text(
|
||||
textwrap.dedent(
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from opensquilla.gateway.boot import start_gateway_server
|
||||
from opensquilla.gateway.config import GatewayConfig, LlmProviderConfig
|
||||
from opensquilla.gateway.websocket import SubscriptionManager
|
||||
|
||||
config = GatewayConfig.load(os.environ["OPENSQUILLA_GATEWAY_CONFIG_PATH"])
|
||||
config.llm = LlmProviderConfig(
|
||||
provider="openrouter",
|
||||
model=os.environ.get("LLM_TEST_MODEL", "deepseek/deepseek-v4-flash"),
|
||||
api_key=os.environ["OPENROUTER_API_KEY"],
|
||||
base_url=os.environ.get(
|
||||
"OPENROUTER_BASE_URL",
|
||||
"https://openrouter.ai/api/v1",
|
||||
),
|
||||
)
|
||||
|
||||
async def main():
|
||||
await start_gateway_server(
|
||||
config=config,
|
||||
subscription_manager=SubscriptionManager(),
|
||||
run=True,
|
||||
)
|
||||
await asyncio.Event().wait()
|
||||
|
||||
asyncio.run(main())
|
||||
"""
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
async def _send_live_prompt(port: int) -> list[dict]:
|
||||
client = GatewayClient()
|
||||
await client.connect(f"ws://127.0.0.1:{port}/ws")
|
||||
try:
|
||||
session_key = await client.create_session(display_name="live-gateway-llm")
|
||||
return [
|
||||
event
|
||||
async for event in client.send_message(
|
||||
session_key,
|
||||
f"Reply with exactly {_EXPECTED_TOKEN}.",
|
||||
)
|
||||
]
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
def _event_text(events: list[dict]) -> str:
|
||||
chunks: list[str] = []
|
||||
for event in events:
|
||||
for key in ("text", "delta", "message", "content"):
|
||||
value = event.get(key)
|
||||
if isinstance(value, str):
|
||||
chunks.append(value)
|
||||
return "\n".join(chunks).lower()
|
||||
|
||||
|
||||
def test_gateway_llm_e2e_uses_explicit_temp_gateway_config(tmp_path: Path) -> None:
|
||||
port = 18891
|
||||
config_path = tmp_path / "gateway.toml"
|
||||
server_script = tmp_path / "gateway_llm_server.py"
|
||||
state_dir = tmp_path / "state"
|
||||
workspace_dir = tmp_path / "workspace"
|
||||
|
||||
_write_gateway_config(
|
||||
config_path,
|
||||
port=port,
|
||||
state_dir=state_dir,
|
||||
workspace_dir=workspace_dir,
|
||||
)
|
||||
_write_gateway_server_script(server_script)
|
||||
|
||||
config_source = config_path.read_text(encoding="utf-8")
|
||||
script_source = server_script.read_text(encoding="utf-8")
|
||||
|
||||
assert f"state_dir = {_toml_string(state_dir)}" in config_source
|
||||
assert f"workspace_dir = {_toml_string(workspace_dir)}" in config_source
|
||||
assert 'GatewayConfig.load(os.environ["OPENSQUILLA_GATEWAY_CONFIG_PATH"])' in script_source
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gateway_session_send_reaches_live_llm(tmp_path: Path) -> None:
|
||||
if os.environ.get("OPENSQUILLA_GATEWAY_LLM_E2E") != "1":
|
||||
pytest.skip("set OPENSQUILLA_GATEWAY_LLM_E2E=1 to run gateway LLM e2e")
|
||||
if not os.environ.get("OPENROUTER_API_KEY"):
|
||||
pytest.skip("OPENROUTER_API_KEY not set")
|
||||
|
||||
port = _free_port()
|
||||
config_path = tmp_path / "gateway.toml"
|
||||
server_script = tmp_path / "gateway_llm_server.py"
|
||||
state_dir = tmp_path / "state"
|
||||
workspace_dir = tmp_path / "workspace"
|
||||
_write_gateway_config(
|
||||
config_path,
|
||||
port=port,
|
||||
state_dir=state_dir,
|
||||
workspace_dir=workspace_dir,
|
||||
)
|
||||
_write_gateway_server_script(server_script)
|
||||
env = os.environ.copy()
|
||||
env["OPENSQUILLA_GATEWAY_CONFIG_PATH"] = str(config_path)
|
||||
env["OPENSQUILLA_STATE_DIR"] = str(state_dir)
|
||||
env["OPENSQUILLA_LOG_DIR"] = str(tmp_path / "logs")
|
||||
env["OPENSQUILLA_TURN_CALL_LOG"] = "0"
|
||||
server = subprocess.Popen(
|
||||
[sys.executable, str(server_script)],
|
||||
cwd=Path.cwd(),
|
||||
env=env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
try:
|
||||
_wait_for_health(port, server)
|
||||
events = await asyncio.wait_for(_send_live_prompt(port), timeout=120)
|
||||
finally:
|
||||
_stop_process(server)
|
||||
|
||||
assert _EXPECTED_TOKEN in _event_text(events)
|
||||
@@ -0,0 +1,897 @@
|
||||
"""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
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Opt-in live agent context-boundary smoke.
|
||||
|
||||
This test uses public synthetic text only and skips unless explicitly enabled
|
||||
with local credentials.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from opensquilla.engine import Agent, AgentConfig
|
||||
from opensquilla.provider import Message
|
||||
from opensquilla.provider.openai import OpenAIProvider
|
||||
|
||||
pytestmark = [pytest.mark.llm, pytest.mark.llm_smoke, pytest.mark.agent_context_boundary]
|
||||
|
||||
_EXPECTED_TOKEN = "opensquilla-agent-boundary-live-ok"
|
||||
_INTERNAL_MARKERS = (
|
||||
"opensquilla_compacted",
|
||||
"tool_use_argument_projection",
|
||||
"provider_request_compacted",
|
||||
"invalid_provider_context_projection",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_live_openrouter_agent_boundary_smoke() -> None:
|
||||
if os.environ.get("OPENSQUILLA_AGENT_CONTEXT_BOUNDARY_LIVE") != "1":
|
||||
pytest.skip("set OPENSQUILLA_AGENT_CONTEXT_BOUNDARY_LIVE=1 to run live smoke")
|
||||
api_key = os.environ.get("OPENROUTER_API_KEY")
|
||||
if not api_key:
|
||||
pytest.skip("OPENROUTER_API_KEY not set")
|
||||
|
||||
provider = OpenAIProvider(
|
||||
api_key=api_key,
|
||||
model=os.environ.get("LLM_TEST_MODEL", "openai/gpt-4o-mini"),
|
||||
base_url=os.environ.get("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1"),
|
||||
provider_kind="openrouter",
|
||||
)
|
||||
agent = Agent(
|
||||
provider=provider,
|
||||
config=AgentConfig(
|
||||
context_window_tokens=200_000,
|
||||
max_tokens=64,
|
||||
provider_request_proof_max_chars=8_000,
|
||||
flush_enabled=False,
|
||||
max_iterations=1,
|
||||
request_timeout=45.0,
|
||||
),
|
||||
)
|
||||
agent.set_history(
|
||||
[
|
||||
Message(
|
||||
role="user",
|
||||
content="Public dummy archive context.\n" + ("alpha beta gamma\n" * 2000),
|
||||
),
|
||||
Message(
|
||||
role="assistant",
|
||||
content="Public dummy previous answer.\n" + ("delta epsilon\n" * 2000),
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
events = [
|
||||
event
|
||||
async for event in agent.run_turn(f"Reply with exactly {_EXPECTED_TOKEN}.")
|
||||
]
|
||||
text = "\n".join(str(getattr(event, "text", "")) for event in events).lower()
|
||||
|
||||
assert _EXPECTED_TOKEN in text
|
||||
assert not any(marker in text for marker in _INTERNAL_MARKERS)
|
||||
@@ -0,0 +1,580 @@
|
||||
"""Opt-in live Feishu platform smoke.
|
||||
|
||||
This maintainer-only gate hits a real Feishu tenant when explicitly enabled.
|
||||
Credentials come from environment variables or the local OpenSquilla config;
|
||||
do not store them in fixtures or repository files.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
import pytest
|
||||
|
||||
from opensquilla.channels.feishu import FeishuChannel, FeishuChannelConfig
|
||||
from opensquilla.tools.builtin.feishu_platform import (
|
||||
_platform_json,
|
||||
clear_feishu_channels,
|
||||
feishu_doc_create,
|
||||
feishu_doc_list_blocks,
|
||||
feishu_doc_read_raw,
|
||||
feishu_drive_search,
|
||||
feishu_drive_upload_artifact,
|
||||
feishu_perm_grant_member,
|
||||
feishu_scopes_status,
|
||||
feishu_wiki_get_node,
|
||||
feishu_wiki_list_nodes,
|
||||
feishu_wiki_list_spaces,
|
||||
register_feishu_channel,
|
||||
)
|
||||
from opensquilla.tools.builtin.file_authoring import create_csv
|
||||
from opensquilla.tools.types import CallerKind, ToolContext, current_tool_context
|
||||
|
||||
pytestmark = pytest.mark.live_channel
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LiveFeishuCredentials:
|
||||
app_id: str
|
||||
app_secret: str
|
||||
channel_name: str
|
||||
api_base: str
|
||||
domain: Literal["feishu", "lark"]
|
||||
source: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class LiveCheck:
|
||||
name: str
|
||||
status: str
|
||||
detail: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
def _require_live_credentials() -> LiveFeishuCredentials:
|
||||
if os.environ.get("OPENSQUILLA_FEISHU_LIVE") != "1":
|
||||
pytest.skip("set OPENSQUILLA_FEISHU_LIVE=1 to run live Feishu platform smoke")
|
||||
app_id = os.environ.get("OPENSQUILLA_FEISHU_APP_ID", "").strip()
|
||||
app_secret = os.environ.get("OPENSQUILLA_FEISHU_APP_SECRET", "").strip()
|
||||
if app_id or app_secret:
|
||||
if not app_id or not app_secret:
|
||||
pytest.skip("set both OPENSQUILLA_FEISHU_APP_ID and OPENSQUILLA_FEISHU_APP_SECRET")
|
||||
return LiveFeishuCredentials(
|
||||
app_id=app_id,
|
||||
app_secret=app_secret,
|
||||
channel_name=os.environ.get("OPENSQUILLA_FEISHU_CHANNEL", "env"),
|
||||
api_base=os.environ.get(
|
||||
"OPENSQUILLA_FEISHU_API_BASE",
|
||||
"https://open.feishu.cn/open-apis",
|
||||
),
|
||||
domain="lark" if os.environ.get("OPENSQUILLA_FEISHU_DOMAIN") == "lark" else "feishu",
|
||||
source="env",
|
||||
)
|
||||
credentials = _load_live_config_credentials()
|
||||
if credentials is None:
|
||||
pytest.skip(
|
||||
"set OPENSQUILLA_FEISHU_APP_ID/OPENSQUILLA_FEISHU_APP_SECRET or configure a Feishu "
|
||||
"channel in OPENSQUILLA_FEISHU_CONFIG_PATH, OPENSQUILLA_GATEWAY_CONFIG_PATH, or "
|
||||
"~/.opensquilla/config.toml"
|
||||
)
|
||||
return credentials
|
||||
|
||||
|
||||
def _load_live_config_credentials() -> LiveFeishuCredentials | None:
|
||||
from opensquilla.gateway.config import GatewayConfig
|
||||
|
||||
config_path = (
|
||||
os.environ.get("OPENSQUILLA_FEISHU_CONFIG_PATH")
|
||||
or os.environ.get("OPENSQUILLA_GATEWAY_CONFIG_PATH")
|
||||
or None
|
||||
)
|
||||
candidates: list[str | Path | None] = [config_path]
|
||||
if config_path is None:
|
||||
userprofile = os.environ.get("USERPROFILE", "").strip()
|
||||
if userprofile:
|
||||
userprofile_config = Path(userprofile) / ".opensquilla" / "config.toml"
|
||||
if userprofile_config.is_file():
|
||||
candidates.append(userprofile_config)
|
||||
|
||||
for candidate in candidates:
|
||||
config = GatewayConfig.load(candidate)
|
||||
credentials = _credentials_from_gateway_config(config)
|
||||
if credentials is not None:
|
||||
return credentials
|
||||
return None
|
||||
|
||||
|
||||
def _credentials_from_gateway_config(config: Any) -> LiveFeishuCredentials | None:
|
||||
feishu_entries = [
|
||||
entry for entry in config.channels.channels if getattr(entry, "type", None) == "feishu"
|
||||
]
|
||||
if not feishu_entries:
|
||||
return None
|
||||
|
||||
preferred_name = os.environ.get("OPENSQUILLA_FEISHU_CHANNEL", "").strip()
|
||||
if preferred_name:
|
||||
selected = next((entry for entry in feishu_entries if entry.name == preferred_name), None)
|
||||
else:
|
||||
selected = next((entry for entry in feishu_entries if entry.enabled), feishu_entries[0])
|
||||
if selected is None:
|
||||
return None
|
||||
|
||||
app_id = getattr(selected, "app_id", "").strip()
|
||||
app_secret = getattr(selected, "app_secret", "").strip()
|
||||
if not app_id or not app_secret:
|
||||
return None
|
||||
return LiveFeishuCredentials(
|
||||
app_id=app_id,
|
||||
app_secret=app_secret,
|
||||
channel_name=selected.name,
|
||||
api_base=selected.api_base,
|
||||
domain=selected.domain,
|
||||
source="config",
|
||||
)
|
||||
|
||||
|
||||
def test_live_feishu_credentials_prefer_explicit_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("OPENSQUILLA_FEISHU_LIVE", "1")
|
||||
monkeypatch.setenv("OPENSQUILLA_FEISHU_APP_ID", "cli_from_env")
|
||||
monkeypatch.setenv("OPENSQUILLA_FEISHU_APP_SECRET", "secret_from_env")
|
||||
|
||||
credentials = _require_live_credentials()
|
||||
|
||||
assert credentials.app_id == "cli_from_env"
|
||||
assert credentials.app_secret == "secret_from_env"
|
||||
assert credentials.source == "env"
|
||||
|
||||
|
||||
def test_live_feishu_credentials_fall_back_to_enabled_config_channel(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
config_path = tmp_path / "config.toml"
|
||||
config_path.write_text(
|
||||
"""
|
||||
[[channels.channels]]
|
||||
name = "old-feishu"
|
||||
type = "feishu"
|
||||
enabled = false
|
||||
app_id = "cli_old"
|
||||
app_secret = "secret_old"
|
||||
|
||||
[[channels.channels]]
|
||||
name = "feishu"
|
||||
type = "feishu"
|
||||
enabled = true
|
||||
app_id = "cli_from_config"
|
||||
app_secret = "secret_from_config"
|
||||
connection_mode = "websocket"
|
||||
api_base = "https://open.feishu.cn/open-apis"
|
||||
domain = "feishu"
|
||||
""".lstrip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("OPENSQUILLA_FEISHU_LIVE", "1")
|
||||
monkeypatch.delenv("OPENSQUILLA_FEISHU_APP_ID", raising=False)
|
||||
monkeypatch.delenv("OPENSQUILLA_FEISHU_APP_SECRET", raising=False)
|
||||
monkeypatch.setenv("OPENSQUILLA_FEISHU_CONFIG_PATH", str(config_path))
|
||||
|
||||
credentials = _require_live_credentials()
|
||||
|
||||
assert credentials.app_id == "cli_from_config"
|
||||
assert credentials.app_secret == "secret_from_config"
|
||||
assert credentials.channel_name == "feishu"
|
||||
assert credentials.source == "config"
|
||||
|
||||
|
||||
def test_live_feishu_credentials_fall_back_to_windows_userprofile(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
wrong_home = tmp_path / "wrong-home"
|
||||
userprofile = tmp_path / "windows-user"
|
||||
config_dir = userprofile / ".opensquilla"
|
||||
config_dir.mkdir(parents=True)
|
||||
(config_dir / "config.toml").write_text(
|
||||
"""
|
||||
[[channels.channels]]
|
||||
name = "feishu"
|
||||
type = "feishu"
|
||||
enabled = true
|
||||
app_id = "cli_from_userprofile"
|
||||
app_secret = "secret_from_userprofile"
|
||||
""".lstrip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("OPENSQUILLA_FEISHU_LIVE", "1")
|
||||
monkeypatch.delenv("OPENSQUILLA_FEISHU_APP_ID", raising=False)
|
||||
monkeypatch.delenv("OPENSQUILLA_FEISHU_APP_SECRET", raising=False)
|
||||
monkeypatch.delenv("OPENSQUILLA_FEISHU_CONFIG_PATH", raising=False)
|
||||
monkeypatch.delenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", raising=False)
|
||||
monkeypatch.setenv("HOME", str(wrong_home))
|
||||
monkeypatch.setenv("USERPROFILE", str(userprofile))
|
||||
|
||||
credentials = _load_live_config_credentials()
|
||||
|
||||
assert credentials is not None
|
||||
assert credentials.app_id == "cli_from_userprofile"
|
||||
assert credentials.app_secret == "secret_from_userprofile"
|
||||
assert credentials.source == "config"
|
||||
|
||||
|
||||
async def _json_result(awaitable: Awaitable[str]) -> dict[str, Any]:
|
||||
payload = json.loads(await awaitable)
|
||||
if not isinstance(payload, dict):
|
||||
raise AssertionError(f"expected JSON object, got: {payload!r}")
|
||||
return payload
|
||||
|
||||
|
||||
def _check_success(name: str, payload: dict[str, Any], **detail: Any) -> LiveCheck:
|
||||
assert payload.get("status") != "error", payload
|
||||
return LiveCheck(name=name, status="PASS", detail=detail or _compact(payload))
|
||||
|
||||
|
||||
def _check_missing_scope(name: str, payload: dict[str, Any]) -> LiveCheck:
|
||||
if payload.get("status") == "error" and payload.get("error_type") == "missing_scope":
|
||||
diagnostic = payload.get("diagnostic")
|
||||
assert isinstance(diagnostic, dict)
|
||||
scopes = diagnostic.get("required_scopes")
|
||||
assert isinstance(scopes, list) and scopes, payload
|
||||
return LiveCheck(
|
||||
name=name,
|
||||
status="EXPECTED_MISSING_SCOPE",
|
||||
detail={
|
||||
"feature": diagnostic.get("feature"),
|
||||
"code": diagnostic.get("code"),
|
||||
"required_scopes": scopes,
|
||||
},
|
||||
)
|
||||
return _check_success(name, payload)
|
||||
|
||||
|
||||
def _compact(value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
result: dict[str, Any] = {}
|
||||
for key, item in value.items():
|
||||
if key == "content" and isinstance(item, str):
|
||||
result[key] = {"length": len(item), "preview": item[:80]}
|
||||
elif key == "grant_url":
|
||||
result[key] = "<redacted>"
|
||||
elif key == "features" and isinstance(item, dict):
|
||||
result[key] = sorted(item)
|
||||
else:
|
||||
result[key] = _compact(item)
|
||||
return result
|
||||
if isinstance(value, list):
|
||||
return [_compact(item) for item in value[:5]]
|
||||
return value
|
||||
|
||||
|
||||
def _document_id(payload: dict[str, Any]) -> str:
|
||||
document = payload.get("document")
|
||||
assert isinstance(document, dict), payload
|
||||
value = document.get("document_id")
|
||||
assert isinstance(value, str) and value, payload
|
||||
return value
|
||||
|
||||
|
||||
async def _record(
|
||||
checks: list[LiveCheck],
|
||||
name: str,
|
||||
func: Callable[[], Awaitable[LiveCheck]],
|
||||
) -> LiveCheck:
|
||||
try:
|
||||
check = await func()
|
||||
except Exception as exc:
|
||||
check = LiveCheck(
|
||||
name=name,
|
||||
status="FAIL",
|
||||
detail={"type": type(exc).__name__, "message": str(exc)},
|
||||
)
|
||||
checks.append(check)
|
||||
return check
|
||||
|
||||
|
||||
def _assert_no_failures(checks: list[LiveCheck]) -> None:
|
||||
failures = [check for check in checks if check.status == "FAIL"]
|
||||
assert not failures, [check.__dict__ for check in failures]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_feishu_platform_live_smoke() -> None:
|
||||
credentials = _require_live_credentials()
|
||||
marker = f"opensquilla-live-smoke-{int(time.time())}"
|
||||
checks: list[LiveCheck] = []
|
||||
channel = FeishuChannel(
|
||||
FeishuChannelConfig(
|
||||
app_id=credentials.app_id,
|
||||
app_secret=credentials.app_secret,
|
||||
connection_mode="webhook",
|
||||
api_base=credentials.api_base,
|
||||
domain=credentials.domain,
|
||||
)
|
||||
)
|
||||
register_feishu_channel("live", channel)
|
||||
|
||||
try:
|
||||
await _record(
|
||||
checks,
|
||||
"tenant_access_token",
|
||||
lambda: _tenant_access_token_check(channel),
|
||||
)
|
||||
await _record(checks, "bot_info", lambda: _bot_info_check(channel))
|
||||
await _record(
|
||||
checks,
|
||||
"scopes_status",
|
||||
lambda: _scopes_status_check(),
|
||||
)
|
||||
|
||||
doc_check = await _record(
|
||||
checks,
|
||||
"doc_create",
|
||||
lambda: _doc_create_check(marker),
|
||||
)
|
||||
document_id = str(doc_check.detail.get("document_id") or "")
|
||||
if document_id:
|
||||
await _record(
|
||||
checks,
|
||||
"doc_read_raw",
|
||||
lambda: _doc_read_raw_check(document_id),
|
||||
)
|
||||
await _record(
|
||||
checks,
|
||||
"doc_list_blocks",
|
||||
lambda: _doc_list_blocks_check(document_id),
|
||||
)
|
||||
else:
|
||||
checks.append(
|
||||
LiveCheck("doc_read_raw", "SKIP", {"reason": "doc_create returned no document_id"})
|
||||
)
|
||||
checks.append(
|
||||
LiveCheck(
|
||||
"doc_list_blocks",
|
||||
"SKIP",
|
||||
{"reason": "doc_create returned no document_id"},
|
||||
)
|
||||
)
|
||||
|
||||
root_token = await _root_folder_token(channel, checks)
|
||||
if root_token:
|
||||
await _record(
|
||||
checks,
|
||||
"drive_upload_csv",
|
||||
lambda: _drive_upload_csv_check(root_token, marker),
|
||||
)
|
||||
else:
|
||||
checks.append(
|
||||
LiveCheck(
|
||||
"drive_upload_csv",
|
||||
"SKIP",
|
||||
{"reason": "drive root folder token unavailable"},
|
||||
)
|
||||
)
|
||||
|
||||
await _record(checks, "drive_search", lambda: _drive_search_check(marker))
|
||||
await _run_wiki_checks(checks)
|
||||
await _record(
|
||||
checks,
|
||||
"permission_grant_member_dry_run",
|
||||
lambda: _permission_dry_run_check(document_id),
|
||||
)
|
||||
if (
|
||||
os.environ.get("OPENSQUILLA_FEISHU_LIVE_MUTATE_PERM") == "1"
|
||||
and os.environ.get("OPENSQUILLA_FEISHU_TEST_OPEN_ID")
|
||||
and document_id
|
||||
):
|
||||
await _record(
|
||||
checks,
|
||||
"permission_grant_member_mutation",
|
||||
lambda: _permission_mutation_check(document_id),
|
||||
)
|
||||
else:
|
||||
checks.append(
|
||||
LiveCheck(
|
||||
"permission_grant_member_mutation",
|
||||
"SKIP",
|
||||
{
|
||||
"reason": (
|
||||
"requires OPENSQUILLA_FEISHU_TEST_OPEN_ID, "
|
||||
"OPENSQUILLA_FEISHU_LIVE_MUTATE_PERM=1, and doc_create success"
|
||||
)
|
||||
},
|
||||
)
|
||||
)
|
||||
finally:
|
||||
await channel.stop()
|
||||
clear_feishu_channels()
|
||||
|
||||
_assert_no_failures(checks)
|
||||
|
||||
|
||||
async def _tenant_access_token_check(channel: FeishuChannel) -> LiveCheck:
|
||||
token = await channel._get_token()
|
||||
assert token
|
||||
return LiveCheck(
|
||||
name="tenant_access_token",
|
||||
status="PASS",
|
||||
detail={"token_received": True, "token_length": len(token)},
|
||||
)
|
||||
|
||||
|
||||
async def _bot_info_check(channel: FeishuChannel) -> LiveCheck:
|
||||
await channel._refresh_bot_identity()
|
||||
assert channel.bot_open_id
|
||||
return LiveCheck(name="bot_info", status="PASS", detail={"bot_open_id_present": True})
|
||||
|
||||
|
||||
async def _scopes_status_check() -> LiveCheck:
|
||||
payload = await _json_result(feishu_scopes_status(channel="live"))
|
||||
return _check_success("scopes_status", payload, features=sorted(payload.get("features", {})))
|
||||
|
||||
|
||||
async def _doc_create_check(marker: str) -> LiveCheck:
|
||||
payload = await _json_result(feishu_doc_create(title=f"{marker} doc", channel="live"))
|
||||
document_id = _document_id(payload)
|
||||
return _check_success("doc_create", payload, document_id=document_id)
|
||||
|
||||
|
||||
async def _doc_read_raw_check(document_id: str) -> LiveCheck:
|
||||
payload = await _json_result(feishu_doc_read_raw(document_id=document_id, channel="live"))
|
||||
assert "content" in payload, payload
|
||||
return _check_success("doc_read_raw", payload, content_length=len(str(payload["content"])))
|
||||
|
||||
|
||||
async def _doc_list_blocks_check(document_id: str) -> LiveCheck:
|
||||
payload = await _json_result(feishu_doc_list_blocks(document_id=document_id, channel="live"))
|
||||
assert isinstance(payload.get("items"), list), payload
|
||||
return _check_success("doc_list_blocks", payload, block_count=len(payload["items"]))
|
||||
|
||||
|
||||
async def _root_folder_token(channel: FeishuChannel, checks: list[LiveCheck]) -> str | None:
|
||||
try:
|
||||
payload = await _platform_json(channel, "GET", "/drive/explorer/v2/root_folder/meta")
|
||||
except Exception as exc:
|
||||
checks.append(
|
||||
LiveCheck(
|
||||
"drive_root_folder_meta",
|
||||
"FAIL",
|
||||
{"type": type(exc).__name__, "message": str(exc)},
|
||||
)
|
||||
)
|
||||
return None
|
||||
token = payload.get("token")
|
||||
status = "PASS" if isinstance(token, str) and token else "FAIL"
|
||||
checks.append(
|
||||
LiveCheck(
|
||||
"drive_root_folder_meta",
|
||||
status,
|
||||
{"token_present": bool(token), "id_present": bool(payload.get("id"))},
|
||||
)
|
||||
)
|
||||
return token if isinstance(token, str) else None
|
||||
|
||||
|
||||
async def _drive_upload_csv_check(root_token: str, marker: str) -> LiveCheck:
|
||||
with tempfile.TemporaryDirectory(prefix="opensquilla-feishu-live-") as tmp_dir:
|
||||
ctx = ToolContext(
|
||||
is_owner=False,
|
||||
caller_kind=CallerKind.CHANNEL,
|
||||
source_name="live",
|
||||
channel_kind="feishu",
|
||||
channel_id="live-drive",
|
||||
sender_id="live-smoke",
|
||||
artifact_media_root=str(Path(tmp_dir) / "media"),
|
||||
artifact_session_id="live-session",
|
||||
session_key=f"agent:main:feishu:live:{marker}",
|
||||
)
|
||||
token = current_tool_context.set(ctx)
|
||||
try:
|
||||
artifact = await _json_result(
|
||||
create_csv(rows=[["kind", "value"], ["live", marker]], name=f"{marker}.csv")
|
||||
)
|
||||
artifact_id = artifact.get("artifact", {}).get("id")
|
||||
assert isinstance(artifact_id, str) and artifact_id, artifact
|
||||
payload = await _json_result(
|
||||
feishu_drive_upload_artifact(artifact_id=artifact_id, parent_node=root_token)
|
||||
)
|
||||
finally:
|
||||
current_tool_context.reset(token)
|
||||
return _check_missing_scope("drive_upload_csv", payload)
|
||||
|
||||
|
||||
async def _drive_search_check(marker: str) -> LiveCheck:
|
||||
payload = await _json_result(feishu_drive_search(query=marker, channel="live"))
|
||||
return _check_missing_scope("drive_search", payload)
|
||||
|
||||
|
||||
async def _run_wiki_checks(checks: list[LiveCheck]) -> None:
|
||||
spaces_payload = await _json_result(feishu_wiki_list_spaces(channel="live"))
|
||||
spaces_check = _check_missing_scope("wiki_list_spaces", spaces_payload)
|
||||
checks.append(spaces_check)
|
||||
if spaces_check.status == "EXPECTED_MISSING_SCOPE":
|
||||
checks.append(LiveCheck("wiki_list_nodes", "SKIP", {"reason": "wiki scopes missing"}))
|
||||
checks.append(LiveCheck("wiki_get_node", "SKIP", {"reason": "wiki scopes missing"}))
|
||||
return
|
||||
|
||||
items = spaces_payload.get("items")
|
||||
if not isinstance(items, list) or not items:
|
||||
checks.append(LiveCheck("wiki_list_nodes", "SKIP", {"reason": "no wiki spaces returned"}))
|
||||
checks.append(LiveCheck("wiki_get_node", "SKIP", {"reason": "no wiki spaces returned"}))
|
||||
return
|
||||
first_space = items[0]
|
||||
assert isinstance(first_space, dict), spaces_payload
|
||||
space_id = first_space.get("space_id")
|
||||
assert isinstance(space_id, str) and space_id, spaces_payload
|
||||
nodes_payload = await _json_result(feishu_wiki_list_nodes(space_id=space_id, channel="live"))
|
||||
nodes_check = _check_missing_scope("wiki_list_nodes", nodes_payload)
|
||||
checks.append(nodes_check)
|
||||
if nodes_check.status == "EXPECTED_MISSING_SCOPE":
|
||||
checks.append(LiveCheck("wiki_get_node", "SKIP", {"reason": "wiki node scopes missing"}))
|
||||
return
|
||||
nodes = nodes_payload.get("items")
|
||||
if not isinstance(nodes, list) or not nodes:
|
||||
checks.append(LiveCheck("wiki_get_node", "SKIP", {"reason": "no wiki nodes returned"}))
|
||||
return
|
||||
first_node = nodes[0]
|
||||
assert isinstance(first_node, dict), nodes_payload
|
||||
node_token = first_node.get("node_token") or first_node.get("token")
|
||||
assert isinstance(node_token, str) and node_token, nodes_payload
|
||||
node_payload = await _json_result(feishu_wiki_get_node(token=node_token, channel="live"))
|
||||
checks.append(_check_missing_scope("wiki_get_node", node_payload))
|
||||
|
||||
|
||||
async def _permission_dry_run_check(document_id: str) -> LiveCheck:
|
||||
payload = await _json_result(
|
||||
feishu_perm_grant_member(
|
||||
token=document_id or "dry_run_token",
|
||||
doc_type="docx" if document_id else "file",
|
||||
member_type="openid",
|
||||
member_id=os.environ.get("OPENSQUILLA_FEISHU_TEST_OPEN_ID", "ou_placeholder"),
|
||||
perm="view",
|
||||
channel="live",
|
||||
)
|
||||
)
|
||||
assert payload.get("status") == "dry_run", payload
|
||||
return LiveCheck(
|
||||
name="permission_grant_member_dry_run",
|
||||
status="PASS",
|
||||
detail={"operation": payload.get("operation")},
|
||||
)
|
||||
|
||||
|
||||
async def _permission_mutation_check(document_id: str) -> LiveCheck:
|
||||
payload = await _json_result(
|
||||
feishu_perm_grant_member(
|
||||
token=document_id,
|
||||
doc_type="docx",
|
||||
member_type="openid",
|
||||
member_id=os.environ["OPENSQUILLA_FEISHU_TEST_OPEN_ID"],
|
||||
perm="view",
|
||||
dry_run=False,
|
||||
channel="live",
|
||||
)
|
||||
)
|
||||
return _check_missing_scope("permission_grant_member_mutation", payload)
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Opt-in live Telegram channel smoke.
|
||||
|
||||
This is a maintainer-only release gate. It sends one real message through the
|
||||
OpenSquilla Telegram adapter when explicitly enabled and credentialed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from opensquilla.channels.telegram import TelegramChannel, TelegramChannelConfig
|
||||
from opensquilla.channels.types import OutgoingMessage
|
||||
|
||||
pytestmark = pytest.mark.live_channel
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_telegram_adapter_can_send_real_message() -> None:
|
||||
if os.environ.get("OPENSQUILLA_LIVE_TELEGRAM_E2E") != "1":
|
||||
pytest.skip("set OPENSQUILLA_LIVE_TELEGRAM_E2E=1 to run live Telegram smoke")
|
||||
token = os.environ.get("OPENSQUILLA_LIVE_TELEGRAM_BOT_TOKEN", "").strip()
|
||||
chat_id = os.environ.get("OPENSQUILLA_LIVE_TELEGRAM_CHAT_ID", "").strip()
|
||||
if not token or not chat_id:
|
||||
pytest.skip("Telegram token/chat id not set")
|
||||
|
||||
marker = f"opensquilla live channel smoke {int(time.time())}"
|
||||
channel = TelegramChannel(
|
||||
TelegramChannelConfig(
|
||||
token=token,
|
||||
default_chat_id=chat_id,
|
||||
drop_pending_updates=True,
|
||||
)
|
||||
)
|
||||
try:
|
||||
result = await channel.send(OutgoingMessage(content=marker))
|
||||
finally:
|
||||
await channel.stop()
|
||||
|
||||
assert isinstance(result.get("message_id"), int)
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Opt-in live OpenRouter compaction smoke.
|
||||
|
||||
Uses only public synthetic text and skips unless explicitly enabled with local
|
||||
credentials. The goal is to prove the compaction path can complete with a real
|
||||
LLM call without making normal test runs credentialed or costful.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from opensquilla.session.compaction import CompactionConfig
|
||||
from opensquilla.session.manager import SessionManager
|
||||
from opensquilla.session.storage import SessionStorage
|
||||
|
||||
pytestmark = [pytest.mark.llm, pytest.mark.llm_smoke, pytest.mark.agent_context_boundary]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_live_openrouter_session_compaction_succeeds(tmp_path: Path) -> None:
|
||||
if os.environ.get("OPENSQUILLA_LIVE_COMPACTION_E2E") != "1":
|
||||
pytest.skip("set OPENSQUILLA_LIVE_COMPACTION_E2E=1 to run live compaction smoke")
|
||||
api_key = os.environ.get("OPENROUTER_API_KEY")
|
||||
if not api_key:
|
||||
pytest.skip("OPENROUTER_API_KEY not set")
|
||||
assert api_key is not None
|
||||
|
||||
storage = SessionStorage(str(tmp_path / "sessions.db"))
|
||||
await storage.connect()
|
||||
manager = SessionManager(storage)
|
||||
session_key = "agent:main:live-compaction"
|
||||
try:
|
||||
await manager.create(session_key)
|
||||
for index in range(12):
|
||||
await manager.append_message(
|
||||
session_key,
|
||||
"user" if index % 2 == 0 else "assistant",
|
||||
(
|
||||
f"Synthetic compaction fact {index}: "
|
||||
"the public dummy project uses alpha, beta, and gamma markers. "
|
||||
"Keep the current goal, completed steps, failures, and next action. " * 20
|
||||
),
|
||||
token_count=450,
|
||||
)
|
||||
|
||||
result = await manager.compact_with_result(
|
||||
session_key,
|
||||
context_window_tokens=2_000,
|
||||
config=CompactionConfig(
|
||||
model=os.environ.get("LLM_TEST_MODEL", "openai/gpt-4o-mini"),
|
||||
api_key=api_key,
|
||||
base_url=os.environ.get(
|
||||
"OPENROUTER_BASE_URL",
|
||||
"https://openrouter.ai/api/v1",
|
||||
),
|
||||
timeout_seconds=90,
|
||||
),
|
||||
)
|
||||
|
||||
assert result.summary
|
||||
assert result.summary_source == "llm"
|
||||
assert result.removed_count > 0
|
||||
assert result.chunks_processed >= 1
|
||||
assert result.tokens_after < result.tokens_before
|
||||
|
||||
node = await manager._storage.get_session(session_key)
|
||||
assert node is not None
|
||||
assert node.compaction_count == 1
|
||||
assert await manager.get_transcript(session_key)
|
||||
summaries = await manager.get_summaries(session_key)
|
||||
assert [summary.summary_text for summary in summaries] == [result.summary]
|
||||
finally:
|
||||
await storage.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_live_openrouter_coding_profile_preserves_recent_tail(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
if os.environ.get("OPENSQUILLA_LIVE_COMPACTION_E2E") != "1":
|
||||
pytest.skip("set OPENSQUILLA_LIVE_COMPACTION_E2E=1 to run live compaction smoke")
|
||||
api_key = os.environ.get("OPENROUTER_API_KEY")
|
||||
if not api_key:
|
||||
pytest.skip("OPENROUTER_API_KEY not set")
|
||||
assert api_key is not None
|
||||
|
||||
storage = SessionStorage(str(tmp_path / "sessions.db"))
|
||||
await storage.connect()
|
||||
manager = SessionManager(storage)
|
||||
session_key = "agent:main:live-coding-profile-compaction"
|
||||
protected_tail = [
|
||||
(
|
||||
"user",
|
||||
"Current task: keep the beta migration open and verify the next shell check.",
|
||||
),
|
||||
("assistant", "Acknowledged current task and pending verification."),
|
||||
("user", "Do not lose marker PROFILE-LIVE-TAIL-001."),
|
||||
("assistant", "Marker PROFILE-LIVE-TAIL-001 is still active."),
|
||||
]
|
||||
try:
|
||||
await manager.create(session_key)
|
||||
for index in range(14):
|
||||
await manager.append_message(
|
||||
session_key,
|
||||
"user" if index % 2 == 0 else "assistant",
|
||||
(
|
||||
f"Synthetic coding history {index}: "
|
||||
"completed setup, reviewed logs, and kept neutral public markers. "
|
||||
"Summarize this older context without private data. " * 20
|
||||
),
|
||||
token_count=450,
|
||||
)
|
||||
for role, content in protected_tail:
|
||||
await manager.append_message(
|
||||
session_key,
|
||||
role,
|
||||
content,
|
||||
token_count=20,
|
||||
)
|
||||
|
||||
result = await manager.compact_with_result(
|
||||
session_key,
|
||||
context_window_tokens=2_000,
|
||||
config=CompactionConfig(
|
||||
model=os.environ.get("LLM_TEST_MODEL", "openai/gpt-4o-mini"),
|
||||
api_key=api_key,
|
||||
base_url=os.environ.get(
|
||||
"OPENROUTER_BASE_URL",
|
||||
"https://openrouter.ai/api/v1",
|
||||
),
|
||||
timeout_seconds=90,
|
||||
compaction_profile="coding",
|
||||
protected_recent_messages=len(protected_tail),
|
||||
),
|
||||
)
|
||||
|
||||
assert result.summary
|
||||
assert result.summary_source == "llm"
|
||||
assert result.removed_count > 0
|
||||
assert result.quality_report["profile"] == "coding"
|
||||
assert result.quality_report["protected_tail_preserved"] is True
|
||||
assert result.quality_report["fits_context_window"] is True
|
||||
assert result.quality_report["passes_structural_gate"] is True
|
||||
|
||||
transcript = await manager.get_transcript(session_key)
|
||||
tail_contents = [
|
||||
entry.content or ""
|
||||
for entry in transcript[-len(protected_tail) :]
|
||||
]
|
||||
assert all(
|
||||
expected_content in actual_content
|
||||
for actual_content, (_, expected_content) in zip(
|
||||
tail_contents,
|
||||
protected_tail,
|
||||
strict=True,
|
||||
)
|
||||
)
|
||||
finally:
|
||||
await storage.close()
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Opt-in live LLM smoke.
|
||||
|
||||
This is intentionally the only live LLM smoke restored for open-source
|
||||
readiness. It skips unless credentials are explicitly present.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from opensquilla.provider.openai import OpenAIProvider
|
||||
from opensquilla.provider.types import ChatConfig, DoneEvent, ErrorEvent, Message, TextDeltaEvent
|
||||
|
||||
pytestmark = [pytest.mark.llm, pytest.mark.llm_smoke]
|
||||
|
||||
_EXPECTED_TOKEN = "opensquilla-live-smoke-ok"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openrouter_live_smoke_returns_expected_token() -> None:
|
||||
api_key = os.environ.get("OPENROUTER_API_KEY")
|
||||
if not api_key:
|
||||
pytest.skip("OPENROUTER_API_KEY not set")
|
||||
|
||||
provider = OpenAIProvider(
|
||||
api_key=api_key,
|
||||
model=os.environ.get("LLM_TEST_MODEL", "openai/gpt-4o-mini"),
|
||||
base_url=os.environ.get("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1"),
|
||||
provider_kind="openrouter",
|
||||
)
|
||||
text_parts: list[str] = []
|
||||
done = False
|
||||
|
||||
async for event in provider.chat(
|
||||
[Message(role="user", content=f"Reply with exactly {_EXPECTED_TOKEN}.")],
|
||||
config=ChatConfig(max_tokens=32, temperature=0.0, timeout=45.0),
|
||||
):
|
||||
if isinstance(event, ErrorEvent):
|
||||
pytest.fail(f"live LLM smoke failed: {event.code} {event.message}")
|
||||
if isinstance(event, TextDeltaEvent):
|
||||
text_parts.append(event.text)
|
||||
if isinstance(event, DoneEvent):
|
||||
done = True
|
||||
|
||||
assert done is True
|
||||
assert _EXPECTED_TOKEN in "".join(text_parts).strip().lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tokenrhythm_live_smoke_returns_expected_token() -> None:
|
||||
api_key = os.environ.get("TOKENRHYTHM_API_KEY")
|
||||
if not api_key:
|
||||
pytest.skip("TOKENRHYTHM_API_KEY not set")
|
||||
|
||||
provider = OpenAIProvider(
|
||||
api_key=api_key,
|
||||
model=os.environ.get("TOKENRHYTHM_MODEL", "deepseek-v4-flash"),
|
||||
base_url=os.environ.get("TOKENRHYTHM_BASE_URL", "https://tokenrhythm.studio/v1"),
|
||||
provider_kind="tokenrhythm",
|
||||
)
|
||||
text_parts: list[str] = []
|
||||
done = False
|
||||
|
||||
async for event in provider.chat(
|
||||
[Message(role="user", content=f"Reply with exactly {_EXPECTED_TOKEN}.")],
|
||||
# Every TokenRhythm model spends reasoning_content tokens out of
|
||||
# max_tokens before any text; a small budget returns empty content
|
||||
# with finish_reason "length".
|
||||
config=ChatConfig(max_tokens=1024, temperature=0.0, timeout=90.0),
|
||||
):
|
||||
if isinstance(event, ErrorEvent):
|
||||
pytest.fail(f"live LLM smoke failed: {event.code} {event.message}")
|
||||
if isinstance(event, TextDeltaEvent):
|
||||
text_parts.append(event.text)
|
||||
if isinstance(event, DoneEvent):
|
||||
done = True
|
||||
|
||||
assert done is True
|
||||
assert _EXPECTED_TOKEN in "".join(text_parts).strip().lower()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,244 @@
|
||||
"""Opt-in real-browser smoke for the Control UI.
|
||||
|
||||
The default test suite skips this file. Run it with:
|
||||
|
||||
OPENSQUILLA_WEBUI_BROWSER_E2E=1 uv run pytest tests/functional/test_webui_browser_e2e.py -q -s
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.webui_browser
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
with socket.socket() as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
return int(sock.getsockname()[1])
|
||||
|
||||
|
||||
def _npm() -> str:
|
||||
return "npm.cmd" if os.name == "nt" else "npm"
|
||||
|
||||
|
||||
def _node() -> str:
|
||||
return "node.exe" if os.name == "nt" else "node"
|
||||
|
||||
|
||||
def _install_playwright(work_dir: Path) -> None:
|
||||
result = subprocess.run(
|
||||
[_npm(), "--prefix", str(work_dir), "install", "playwright"],
|
||||
cwd=Path.cwd(),
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr or result.stdout
|
||||
browser_result = subprocess.run(
|
||||
[_npm(), "--prefix", str(work_dir), "exec", "playwright", "install", "chromium"],
|
||||
cwd=Path.cwd(),
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=180,
|
||||
)
|
||||
assert browser_result.returncode == 0, browser_result.stderr or browser_result.stdout
|
||||
|
||||
|
||||
def _wait_for_health(port: int, server: subprocess.Popen[str]) -> None:
|
||||
url = f"http://127.0.0.1:{port}/health"
|
||||
deadline = time.monotonic() + 20.0
|
||||
last_error = ""
|
||||
while time.monotonic() < deadline:
|
||||
if server.poll() is not None:
|
||||
stdout = server.stdout.read() if server.stdout else ""
|
||||
stderr = server.stderr.read() if server.stderr else ""
|
||||
raise AssertionError(
|
||||
f"gateway exited early code={server.returncode}\nstdout={stdout}\nstderr={stderr}"
|
||||
)
|
||||
try:
|
||||
response = httpx.get(url, timeout=1.0)
|
||||
if response.status_code == 200 and response.json().get("ok") is True:
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001 - included in timeout assertion.
|
||||
last_error = str(exc)
|
||||
time.sleep(0.1)
|
||||
raise AssertionError(f"gateway did not become healthy: {last_error}")
|
||||
|
||||
|
||||
def _stop_process(process: subprocess.Popen[str]) -> None:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=8)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait(timeout=8)
|
||||
|
||||
|
||||
def test_control_ui_loads_in_real_browser(tmp_path: Path) -> None:
|
||||
if os.environ.get("OPENSQUILLA_WEBUI_BROWSER_E2E") != "1":
|
||||
pytest.skip("set OPENSQUILLA_WEBUI_BROWSER_E2E=1 to run browser smoke")
|
||||
|
||||
port = _free_port()
|
||||
server_script = tmp_path / "webui_smoke_server.py"
|
||||
browser_script = tmp_path / "webui_smoke_browser.js"
|
||||
state_dir = tmp_path / "state"
|
||||
proposal_dir = state_dir / "proposals" / "deadbeef"
|
||||
proposal_dir.mkdir(parents=True)
|
||||
(proposal_dir / "SKILL.md").write_text(
|
||||
"---\n"
|
||||
"name: browser-audit-proposal\n"
|
||||
"kind: meta\n"
|
||||
"description: Browser smoke proposal.\n"
|
||||
"triggers: [browser audit proposal]\n"
|
||||
"composition:\n"
|
||||
" steps:\n"
|
||||
" - id: classify\n"
|
||||
" kind: llm_classify\n"
|
||||
" output_choices: [A, B]\n"
|
||||
" with: {text: x}\n"
|
||||
"---\n"
|
||||
"# browser-audit-proposal\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(proposal_dir / "gates.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"auto_enable_eligible": True,
|
||||
"auto_enable": {
|
||||
"status": "skipped",
|
||||
"reason": "risk_too_high",
|
||||
"risk_level": "medium",
|
||||
"max_risk": "low",
|
||||
"details": {
|
||||
"validation_profile": "static-safety-v2",
|
||||
"skills": ["artifact-writer"],
|
||||
"tools": [],
|
||||
"reasons": ["capability:artifact-writer:filesystem-write"],
|
||||
},
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
server_script.write_text(
|
||||
textwrap.dedent(
|
||||
f"""
|
||||
import uvicorn
|
||||
|
||||
from opensquilla.gateway.app import create_gateway_app
|
||||
from opensquilla.gateway.config import AuthConfig, GatewayConfig
|
||||
|
||||
config = GatewayConfig(
|
||||
host="127.0.0.1",
|
||||
port={port},
|
||||
auth=AuthConfig(mode="none"),
|
||||
)
|
||||
app = create_gateway_app(config)
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host="127.0.0.1", port={port}, log_level="warning")
|
||||
"""
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
browser_script.write_text(
|
||||
textwrap.dedent(
|
||||
"""
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage();
|
||||
const errors = [];
|
||||
page.on("pageerror", err => errors.push(String(err)));
|
||||
const response = await page.goto(process.env.TARGET_URL, {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: 30000,
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector("#conn-pill")?.textContent === "Connected",
|
||||
{ timeout: 15000 }
|
||||
);
|
||||
await page.locator('a[data-path="/skills"]').click();
|
||||
await page.waitForSelector('[data-proposal-show="deadbeef"]', {
|
||||
state: "attached",
|
||||
timeout: 15000,
|
||||
});
|
||||
await page.locator('[data-proposal-show="deadbeef"]').click();
|
||||
await page.waitForSelector('dialog[open] .sk-audit-grid', { timeout: 15000 });
|
||||
const auditText = await page.locator('dialog[open]').innerText();
|
||||
const result = {
|
||||
status: response ? response.status() : 0,
|
||||
title: await page.title(),
|
||||
appCount: await page.locator("#app").count(),
|
||||
basePath: await page.locator("#opensquilla-data").getAttribute("data-base-path"),
|
||||
authMode: await page.locator("#opensquilla-data").getAttribute("data-auth-mode"),
|
||||
proposalButtons: await page.locator('[data-proposal-show="deadbeef"]').count(),
|
||||
auditText,
|
||||
pageErrors: errors,
|
||||
};
|
||||
await browser.close();
|
||||
console.log(JSON.stringify(result));
|
||||
})().catch(err => {
|
||||
console.error(err && err.stack ? err.stack : String(err));
|
||||
process.exit(1);
|
||||
});
|
||||
"""
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
env = os.environ.copy()
|
||||
env["OPENSQUILLA_STATE_DIR"] = str(state_dir)
|
||||
server = subprocess.Popen(
|
||||
[sys.executable, str(server_script)],
|
||||
cwd=Path.cwd(),
|
||||
env=env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
try:
|
||||
_wait_for_health(port, server)
|
||||
_install_playwright(tmp_path)
|
||||
browser_env = dict(env, TARGET_URL=f"http://127.0.0.1:{port}/control/")
|
||||
result = subprocess.run(
|
||||
[_node(), str(browser_script)],
|
||||
cwd=tmp_path,
|
||||
env=browser_env,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr or result.stdout
|
||||
payload = json.loads(result.stdout.strip().splitlines()[-1])
|
||||
finally:
|
||||
_stop_process(server)
|
||||
|
||||
assert payload["status"] == 200
|
||||
assert payload["title"] == "OpenSquilla Control"
|
||||
assert payload["appCount"] == 1
|
||||
assert payload["basePath"] == "/control"
|
||||
assert payload["authMode"] == "none"
|
||||
assert payload["proposalButtons"] == 1
|
||||
assert "Auto-enable Audit" in payload["auditText"]
|
||||
assert "static-safety-v2" in payload["auditText"]
|
||||
assert "medium / low" in payload["auditText"]
|
||||
assert "capability:artifact-writer:filesystem-write" in payload["auditText"]
|
||||
assert payload["pageErrors"] == []
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Static-text acceptance: chat.js contains per-turn bubble markers.
|
||||
|
||||
These checks do not require a browser or network. They verify the generated
|
||||
JavaScript source ships the expected per-turn semantics introduced in Phase 3:
|
||||
- The bubble tooltip uses 'Turn — input:' (per-turn label, not session total).
|
||||
- The bubble chip argument is 'u.input_tokens | 0' (per-turn source, not the
|
||||
session accumulator).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
_CHAT_JS = (
|
||||
Path(__file__).parent.parent.parent
|
||||
/ "src"
|
||||
/ "opensquilla"
|
||||
/ "gateway"
|
||||
/ "static"
|
||||
/ "js"
|
||||
/ "views"
|
||||
/ "chat.js"
|
||||
)
|
||||
|
||||
|
||||
def test_chat_js_bubble_tooltip_uses_per_turn_label():
|
||||
"""chat.js must label the bubble tooltip with 'Turn — input:' so the UI
|
||||
clearly shows per-turn semantics to users."""
|
||||
assert _CHAT_JS.exists(), f"chat.js not found at {_CHAT_JS}"
|
||||
source = _CHAT_JS.read_text(encoding="utf-8")
|
||||
assert "Turn — input:" in source, (
|
||||
"Expected per-turn tooltip label 'Turn — input:' not found in chat.js"
|
||||
)
|
||||
|
||||
|
||||
def test_chat_js_bubble_chip_uses_per_turn_argument():
|
||||
"""chat.js must pass 'u.input_tokens | 0' (per-turn field) to the bubble
|
||||
chip, not the session accumulator, ensuring per-bubble token counts reflect
|
||||
only the current turn."""
|
||||
assert _CHAT_JS.exists(), f"chat.js not found at {_CHAT_JS}"
|
||||
source = _CHAT_JS.read_text(encoding="utf-8")
|
||||
assert "u.input_tokens | 0" in source, (
|
||||
"Expected per-turn chip argument 'u.input_tokens | 0' not found in chat.js"
|
||||
)
|
||||
Reference in New Issue
Block a user