2139 lines
71 KiB
Python
2139 lines
71 KiB
Python
"""Tests for chat command — verify CLI interface and routing."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import io
|
|
import json
|
|
from collections.abc import Iterable
|
|
from contextlib import asynccontextmanager
|
|
from types import SimpleNamespace
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
from rich.console import Console
|
|
from typer.testing import CliRunner
|
|
|
|
from opensquilla.cli import chat_cmd
|
|
from opensquilla.cli.main import app
|
|
from opensquilla.cli.repl import commands as repl_commands
|
|
from opensquilla.cli.repl import slash_bridge
|
|
from opensquilla.cli.repl.session_state import ChatSessionState
|
|
from opensquilla.engine.commands import DEFAULT_REGISTRY, Surface
|
|
from opensquilla.engine.types import (
|
|
ArtifactEvent,
|
|
DoneEvent,
|
|
TextDeltaEvent,
|
|
ToolResultEvent,
|
|
ToolUseStartEvent,
|
|
)
|
|
from opensquilla.session.compaction import CompactionConfig
|
|
from opensquilla.tools.types import CallerKind, ToolContext
|
|
|
|
runner = CliRunner()
|
|
|
|
|
|
def _install_fake_inputs(monkeypatch, inputs: Iterable[str]) -> None:
|
|
"""Install a fake TUI surface that yields lines from *inputs*.
|
|
|
|
The chat command reaches input through the selected TUI runtime. Patch the
|
|
TUI surface seam so these command tests stay behind the same boundary as
|
|
production code.
|
|
"""
|
|
|
|
iterator = iter(inputs)
|
|
|
|
class _FakeOutputHandle:
|
|
approval_surface = Surface.CLI_GATEWAY
|
|
|
|
async def write_through(self, payload: str) -> None:
|
|
return None
|
|
|
|
async def send_message(self, message_type: str, payload: dict[str, object]) -> None:
|
|
return None
|
|
|
|
def set_toolbar(self, key: str, value) -> None:
|
|
return None
|
|
|
|
def invalidate(self) -> None:
|
|
return None
|
|
|
|
class _FakeSurface:
|
|
output_handle = _FakeOutputHandle()
|
|
|
|
async def next_line(self) -> str | None:
|
|
try:
|
|
return next(iterator)
|
|
except StopIteration:
|
|
return None
|
|
|
|
def set_cancel_callback(self, cb) -> None:
|
|
return None
|
|
|
|
def set_shutdown_callback(self, cb) -> None:
|
|
return None
|
|
|
|
def emit_eof(self) -> None:
|
|
return None
|
|
|
|
def invalidate(self) -> None:
|
|
return None
|
|
|
|
async def write_through(self, payload: str) -> None:
|
|
return None
|
|
|
|
async def send_message(self, message_type: str, payload: dict[str, object]) -> None:
|
|
return None
|
|
|
|
@property
|
|
def redraw_callback(self):
|
|
return self.output_handle.invalidate
|
|
|
|
@asynccontextmanager
|
|
async def _fake_session(**kwargs):
|
|
yield _FakeSurface()
|
|
|
|
monkeypatch.setattr(
|
|
"opensquilla.cli.tui.opentui.runtime.open_opentui_surface",
|
|
_fake_session,
|
|
)
|
|
monkeypatch.setattr(
|
|
"opensquilla.cli.tui.adapters.native_bridge.open_native_terminal_surface",
|
|
_fake_session,
|
|
)
|
|
|
|
|
|
EXPECTED_GATEWAY_COMMANDS = {
|
|
"/new",
|
|
"/reset",
|
|
"/compact",
|
|
"/cmp",
|
|
"/help",
|
|
"/status",
|
|
"/model",
|
|
"/models",
|
|
"/cost",
|
|
"/usage",
|
|
"/save",
|
|
"/image",
|
|
"/path",
|
|
"/file",
|
|
"/approvals",
|
|
"/permissions",
|
|
"/forget",
|
|
"/sessions",
|
|
"/resume",
|
|
"/delete",
|
|
"/exit",
|
|
}
|
|
|
|
EXPECTED_STANDALONE_COMMANDS = EXPECTED_GATEWAY_COMMANDS - {
|
|
"/approvals",
|
|
"/delete",
|
|
"/file",
|
|
"/forget",
|
|
"/models",
|
|
"/permissions",
|
|
"/resume",
|
|
"/sessions",
|
|
"/usage",
|
|
}
|
|
|
|
|
|
def _handler_words(surface: Surface) -> set[str]:
|
|
return {word for command in DEFAULT_REGISTRY.for_surface(surface) for word in command.words()}
|
|
|
|
|
|
def test_gateway_registry_commands_have_gateway_handlers() -> None:
|
|
gateway_words = _handler_words(Surface.CLI_GATEWAY)
|
|
|
|
assert EXPECTED_GATEWAY_COMMANDS <= gateway_words
|
|
assert gateway_words == chat_cmd.GATEWAY_SLASH_HANDLER_WORDS
|
|
assert "/elevated" in chat_cmd.GATEWAY_SLASH_HANDLER_WORDS
|
|
assert "/clear" in chat_cmd.GATEWAY_SLASH_HANDLER_WORDS
|
|
assert "/quit" in chat_cmd.GATEWAY_SLASH_HANDLER_WORDS
|
|
assert "/usage" in chat_cmd.GATEWAY_SLASH_HANDLER_WORDS
|
|
assert "/file" in chat_cmd.GATEWAY_SLASH_HANDLER_WORDS
|
|
|
|
|
|
def test_standalone_registry_commands_have_standalone_handlers() -> None:
|
|
standalone_words = _handler_words(Surface.CLI_STANDALONE)
|
|
|
|
assert EXPECTED_STANDALONE_COMMANDS <= standalone_words
|
|
assert standalone_words == chat_cmd.STANDALONE_SLASH_HANDLER_WORDS
|
|
assert "/clear" in chat_cmd.STANDALONE_SLASH_HANDLER_WORDS
|
|
assert "/quit" in chat_cmd.STANDALONE_SLASH_HANDLER_WORDS
|
|
assert "/usage" not in chat_cmd.STANDALONE_SLASH_HANDLER_WORDS
|
|
assert "/file" not in chat_cmd.STANDALONE_SLASH_HANDLER_WORDS
|
|
assert "/models" not in chat_cmd.STANDALONE_SLASH_HANDLER_WORDS
|
|
|
|
|
|
def test_usage_is_gateway_only_and_not_standalone_help() -> None:
|
|
assert "/usage" in _handler_words(Surface.CLI_GATEWAY)
|
|
assert "/usage" not in _handler_words(Surface.CLI_STANDALONE)
|
|
assert "/models" in _handler_words(Surface.CLI_GATEWAY)
|
|
assert "/models" not in _handler_words(Surface.CLI_STANDALONE)
|
|
|
|
buffer = io.StringIO()
|
|
console = Console(file=buffer, force_terminal=False, width=100, highlight=False)
|
|
console.print(repl_commands.render_help_table(Surface.CLI_STANDALONE))
|
|
|
|
assert "/usage" not in buffer.getvalue()
|
|
assert "/models" not in buffer.getvalue()
|
|
|
|
|
|
def test_file_is_gateway_only() -> None:
|
|
assert "/file" in _handler_words(Surface.CLI_GATEWAY)
|
|
assert "/file" not in _handler_words(Surface.CLI_STANDALONE)
|
|
|
|
|
|
def test_interactive_chat_clear_screen_only_on_terminal(monkeypatch) -> None:
|
|
calls: list[str] = []
|
|
|
|
class FakeConsole:
|
|
is_terminal = True
|
|
|
|
def clear(self) -> None:
|
|
calls.append("clear")
|
|
|
|
monkeypatch.setattr(chat_cmd._launch_bridge, "console", FakeConsole())
|
|
|
|
chat_cmd._clear_screen_for_interactive_chat()
|
|
|
|
assert calls == ["clear"]
|
|
|
|
|
|
def test_interactive_chat_clear_screen_skips_non_terminal(monkeypatch) -> None:
|
|
calls: list[str] = []
|
|
|
|
class FakeConsole:
|
|
is_terminal = False
|
|
|
|
def clear(self) -> None:
|
|
calls.append("clear")
|
|
|
|
monkeypatch.setattr(chat_cmd._launch_bridge, "console", FakeConsole())
|
|
|
|
chat_cmd._clear_screen_for_interactive_chat()
|
|
|
|
assert calls == []
|
|
|
|
|
|
class TestChatCommand:
|
|
def test_chat_command_builds_typed_launch_request(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
captured: dict[str, object] = {}
|
|
|
|
def fake_launch_chat_command(request, *, overrides=None) -> None:
|
|
captured["request"] = request
|
|
captured["overrides"] = overrides
|
|
|
|
monkeypatch.setattr(
|
|
chat_cmd,
|
|
"_launch_chat_command",
|
|
fake_launch_chat_command,
|
|
raising=False,
|
|
)
|
|
|
|
chat_cmd.run_chat(
|
|
model="openrouter/test",
|
|
session_id="agent:main:existing",
|
|
standalone=True,
|
|
workspace="repo",
|
|
workspace_strict=True,
|
|
timeout=12.5,
|
|
)
|
|
|
|
request = captured["request"]
|
|
assert isinstance(request, chat_cmd._ChatCommandRequest)
|
|
assert request.model == "openrouter/test"
|
|
assert request.session_id == "agent:main:existing"
|
|
assert request.standalone is True
|
|
assert request.workspace == "repo"
|
|
assert request.workspace_strict is True
|
|
assert request.timeout == 12.5
|
|
overrides = captured["overrides"]
|
|
assert isinstance(overrides, chat_cmd._ChatCommandLaunchOverrides)
|
|
assert overrides.launch_chat is None
|
|
assert overrides.standalone_runner is None
|
|
assert overrides.gateway_runner is None
|
|
|
|
def test_chat_help(self) -> None:
|
|
result = runner.invoke(
|
|
app,
|
|
["chat", "--help"],
|
|
env={"COLUMNS": "120", "NO_COLOR": "1", "TERM": "dumb"},
|
|
)
|
|
assert result.exit_code == 0
|
|
assert "--model" in result.output
|
|
assert "--session" in result.output
|
|
|
|
def test_chat_invokes_run_chat(self) -> None:
|
|
"""Default chat calls run_chat with correct defaults."""
|
|
mock_run = MagicMock()
|
|
with patch("opensquilla.cli.chat_cmd.run_chat", mock_run):
|
|
result = runner.invoke(app, ["chat"])
|
|
assert result.exit_code == 0
|
|
mock_run.assert_called_once_with(
|
|
model="",
|
|
session_id="",
|
|
standalone=False,
|
|
workspace="",
|
|
workspace_strict=None,
|
|
timeout=None,
|
|
)
|
|
|
|
def test_chat_model_option_forwarded(self) -> None:
|
|
"""--model option is forwarded to run_chat."""
|
|
mock_run = MagicMock()
|
|
with patch("opensquilla.cli.chat_cmd.run_chat", mock_run):
|
|
result = runner.invoke(app, ["chat", "--model", "ollama/llama3"])
|
|
assert result.exit_code == 0
|
|
mock_run.assert_called_once_with(
|
|
model="ollama/llama3",
|
|
session_id="",
|
|
standalone=False,
|
|
workspace="",
|
|
workspace_strict=None,
|
|
timeout=None,
|
|
)
|
|
|
|
def test_chat_session_option_forwarded(self) -> None:
|
|
"""--session option is forwarded to run_chat."""
|
|
mock_run = MagicMock()
|
|
with patch("opensquilla.cli.chat_cmd.run_chat", mock_run):
|
|
result = runner.invoke(app, ["chat", "--session", "abc123"])
|
|
assert result.exit_code == 0
|
|
mock_run.assert_called_once_with(
|
|
model="",
|
|
session_id="abc123",
|
|
standalone=False,
|
|
workspace="",
|
|
workspace_strict=None,
|
|
timeout=None,
|
|
)
|
|
|
|
def test_chat_rejects_unknown_tui_backend_before_launch(self) -> None:
|
|
result = runner.invoke(
|
|
app,
|
|
["chat"],
|
|
env={
|
|
"OPENSQUILLA_TUI_BACKEND": "bogus",
|
|
"COLUMNS": "120",
|
|
"NO_COLOR": "1",
|
|
"TERM": "dumb",
|
|
},
|
|
)
|
|
|
|
assert result.exit_code == 2
|
|
assert "Unsupported TUI backend" in result.output
|
|
assert "bogus" in result.output
|
|
assert "opentui" in result.output
|
|
|
|
def test_chat_timeout_option_forwarded(self) -> None:
|
|
"""--timeout option is forwarded to run_chat."""
|
|
mock_run = MagicMock()
|
|
with patch("opensquilla.cli.chat_cmd.run_chat", mock_run):
|
|
result = runner.invoke(app, ["chat", "--timeout", "12.5"])
|
|
assert result.exit_code == 0
|
|
mock_run.assert_called_once_with(
|
|
model="",
|
|
session_id="",
|
|
standalone=False,
|
|
workspace="",
|
|
workspace_strict=None,
|
|
timeout=12.5,
|
|
)
|
|
|
|
def test_chat_workspace_options_forwarded(self) -> None:
|
|
mock_run = MagicMock()
|
|
with patch("opensquilla.cli.chat_cmd.run_chat", mock_run):
|
|
result = runner.invoke(
|
|
app,
|
|
["chat", "--workspace", "repo", "--workspace-strict"],
|
|
)
|
|
assert result.exit_code == 0
|
|
mock_run.assert_called_once_with(
|
|
model="",
|
|
session_id="",
|
|
standalone=False,
|
|
workspace="repo",
|
|
workspace_strict=True,
|
|
timeout=None,
|
|
)
|
|
|
|
def test_gateway_chat_workspace_options_warn_without_forwarding(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
buffer = io.StringIO()
|
|
called: dict[str, object] = {}
|
|
|
|
async def fake_gateway_chat(model: str | None, session_id: str | None) -> None:
|
|
called["model"] = model
|
|
called["session_id"] = session_id
|
|
|
|
monkeypatch.setattr(
|
|
chat_cmd._launch_bridge.sys,
|
|
"stdin",
|
|
SimpleNamespace(isatty=lambda: True),
|
|
)
|
|
# The real log-quieting swaps the process-global structlog config to an
|
|
# interactive log file; leaking that into later tests poisons every
|
|
# subsequent structlog emit once another test closes the handle.
|
|
monkeypatch.setattr(
|
|
chat_cmd._launch_bridge,
|
|
"quiet_logs_for_interactive_chat",
|
|
lambda: None,
|
|
)
|
|
monkeypatch.setattr(
|
|
chat_cmd._launch_bridge,
|
|
"console",
|
|
Console(file=buffer, force_terminal=True, color_system=None, no_color=True),
|
|
)
|
|
monkeypatch.setattr(chat_cmd, "_gateway_chat", fake_gateway_chat)
|
|
|
|
chat_cmd.run_chat(
|
|
model="",
|
|
session_id="",
|
|
standalone=False,
|
|
workspace="repo",
|
|
workspace_strict=True,
|
|
timeout=None,
|
|
)
|
|
|
|
assert called == {"model": None, "session_id": None}
|
|
output = buffer.getvalue()
|
|
assert "--workspace only affects --standalone chat" in output
|
|
assert "requires the path to be visible to the gateway runtime" in output
|
|
|
|
def test_chat_rejects_extra_args(self) -> None:
|
|
"""Extra positional args (like 'send Hello') are rejected."""
|
|
result = runner.invoke(app, ["chat", "send", "Hello"])
|
|
assert result.exit_code != 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_standalone_repl_delegates_to_runtime_bridge(monkeypatch) -> None:
|
|
captured: dict[str, object] = {}
|
|
|
|
async def fake_run_standalone_chat(**kwargs) -> None:
|
|
captured.update(kwargs)
|
|
|
|
monkeypatch.setattr(
|
|
chat_cmd._runtime_bridge,
|
|
"run_standalone_chat",
|
|
fake_run_standalone_chat,
|
|
)
|
|
|
|
await chat_cmd._standalone_repl(
|
|
model="openrouter/test",
|
|
session_id="standalone:test",
|
|
workspace="repo",
|
|
workspace_strict=True,
|
|
timeout=7.25,
|
|
)
|
|
|
|
assert captured["model"] == "openrouter/test"
|
|
assert captured["session_id"] == "standalone:test"
|
|
assert captured["workspace"] == "repo"
|
|
assert captured["workspace_strict"] is True
|
|
assert captured["timeout"] == 7.25
|
|
assert "stream_response" not in captured
|
|
assert "image_command_handler" not in captured
|
|
assert "run_concurrent_repl" not in captured
|
|
assert "output_console" not in captured
|
|
assert "error_panel_factory" not in captured
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_gateway_chat_delegates_to_runtime_bridge(monkeypatch) -> None:
|
|
captured: dict[str, object] = {}
|
|
|
|
async def fake_run_gateway_chat(**kwargs) -> None:
|
|
captured.update(kwargs)
|
|
|
|
monkeypatch.setattr(
|
|
chat_cmd._runtime_bridge,
|
|
"run_gateway_chat",
|
|
fake_run_gateway_chat,
|
|
)
|
|
|
|
await chat_cmd._gateway_chat(
|
|
model="anthropic/claude-sonnet-4",
|
|
session_id="agent:main:resumed-key",
|
|
)
|
|
|
|
assert captured["model"] == "anthropic/claude-sonnet-4"
|
|
assert captured["session_id"] == "agent:main:resumed-key"
|
|
assert "stream_response" not in captured
|
|
assert "handle_slash_command" not in captured
|
|
assert "run_concurrent_repl" not in captured
|
|
assert "output_console" not in captured
|
|
assert "error_panel_factory" not in captured
|
|
|
|
|
|
class _FakeSessionManager:
|
|
def __init__(self) -> None:
|
|
self.get_or_create_calls: list[dict[str, str]] = []
|
|
self.compact_calls: list[tuple[str, int, object | None]] = []
|
|
self.truncate_calls: list[tuple[str, int]] = []
|
|
self.transcripts: dict[str, list[object]] = {}
|
|
|
|
async def get_or_create(self, session_key: str, agent_id: str = "main") -> object:
|
|
self.get_or_create_calls.append({"session_key": session_key, "agent_id": agent_id})
|
|
return SimpleNamespace(session_key=session_key, agent_id=agent_id)
|
|
|
|
async def append_message(self, session_key: str, role: str, content: str) -> object:
|
|
entry = SimpleNamespace(role=role, content=content)
|
|
self.transcripts.setdefault(session_key, []).append(entry)
|
|
return entry
|
|
|
|
async def get_transcript(self, session_key: str) -> list[object]:
|
|
return list(self.transcripts.get(session_key, []))
|
|
|
|
async def truncate(self, session_key: str, max_messages: int = 0) -> None:
|
|
self.truncate_calls.append((session_key, max_messages))
|
|
if max_messages <= 0:
|
|
self.transcripts[session_key] = []
|
|
else:
|
|
self.transcripts[session_key] = self.transcripts.get(session_key, [])[-max_messages:]
|
|
|
|
async def compact(self, session_key: str, context_window_tokens: int, config=None) -> str:
|
|
self.compact_calls.append((session_key, context_window_tokens, config))
|
|
return "summary"
|
|
|
|
|
|
class _LegacyCompactSessionManager(_FakeSessionManager):
|
|
async def compact(self, session_key: str, context_window_tokens: int) -> str:
|
|
self.compact_calls.append((session_key, context_window_tokens, None))
|
|
return "summary"
|
|
|
|
|
|
class _FakeCompactionProvider:
|
|
provider_name = "openai"
|
|
|
|
def __init__(self) -> None:
|
|
self._api_key = "cli-provider-key"
|
|
self._model = "provider/model"
|
|
self._base_url = "https://openrouter.ai/api/v1"
|
|
|
|
@property
|
|
def model(self) -> str:
|
|
return self._model
|
|
|
|
|
|
class _FakeProviderSelector:
|
|
def __init__(self, provider: _FakeCompactionProvider | None = None) -> None:
|
|
self.provider = provider or _FakeCompactionProvider()
|
|
|
|
def clone(self) -> _FakeProviderSelector:
|
|
return self
|
|
|
|
def resolve(self) -> _FakeCompactionProvider:
|
|
return self.provider
|
|
|
|
|
|
class _FakeServices:
|
|
def __init__(self) -> None:
|
|
self.memory_sync_managers = {"main": object()}
|
|
self.memory_retrievers = {"main": object()}
|
|
self.turn_capture_services = {"main": object()}
|
|
self.flush_service = None
|
|
self.model_catalog = object()
|
|
self.provider_selector = MagicMock()
|
|
self.tool_registry = None
|
|
self.session_manager = _FakeSessionManager()
|
|
self.skill_loader = None
|
|
self.usage_tracker = None
|
|
self.config = None
|
|
|
|
async def close(self) -> None:
|
|
return None
|
|
|
|
|
|
class _DummyLive:
|
|
def __init__(self, *args, **kwargs) -> None:
|
|
return None
|
|
|
|
def __enter__(self) -> _DummyLive:
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc, tb) -> bool:
|
|
return False
|
|
|
|
def update(self, *args, **kwargs) -> None:
|
|
return None
|
|
|
|
|
|
class _RecordingRenderer:
|
|
instances: list[_RecordingRenderer] = []
|
|
|
|
def __init__(self, *args, **kwargs) -> None:
|
|
self.buffer = ""
|
|
self.pulses = 0
|
|
self.errors: list[str] = []
|
|
self.finalized = False
|
|
self.statuses: list[str] = []
|
|
self.tool_starts: list[tuple[str, object, str | None]] = []
|
|
self.tool_finishes: list[tuple[str | None, bool, object | None]] = []
|
|
_RecordingRenderer.instances.append(self)
|
|
|
|
def __enter__(self) -> _RecordingRenderer:
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc, tb) -> bool:
|
|
return False
|
|
|
|
def append_text(self, delta: str) -> None:
|
|
self.buffer += delta
|
|
|
|
async def aappend_text(self, delta: str) -> None:
|
|
# Async sibling used by production stream paths; mirror sync logic
|
|
# so existing test assertions against `self.buffer` still hold.
|
|
self.buffer += delta
|
|
|
|
def pulse(self) -> None:
|
|
self.pulses += 1
|
|
|
|
def tool_call(self, name: str, args=None) -> None:
|
|
# Legacy shim kept for tests that haven't migrated.
|
|
self.tool_starts.append((name, args, None))
|
|
|
|
def tool_start(self, name: str, args=None, tool_use_id: str | None = None) -> None:
|
|
self.tool_starts.append((name, args, tool_use_id))
|
|
|
|
def tool_finished(
|
|
self,
|
|
tool_use_id: str | None,
|
|
*,
|
|
success: bool,
|
|
elapsed: float | None = None,
|
|
error: str | None = None,
|
|
result: object | None = None,
|
|
) -> None:
|
|
self.tool_finishes.append((tool_use_id, success, result))
|
|
|
|
def status(self, message: str, **_kwargs) -> None:
|
|
self.statuses.append(message)
|
|
|
|
def error(self, message: str) -> None:
|
|
self.errors.append(message)
|
|
|
|
def finalize(
|
|
self,
|
|
usage=None,
|
|
*,
|
|
cancelled: bool = False,
|
|
) -> None:
|
|
self.finalized = True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_standalone_repl_forwards_timeout(monkeypatch) -> None:
|
|
captured: dict[str, object] = {}
|
|
inputs = iter(["hello", "/quit"])
|
|
|
|
class FakeTurnRunner:
|
|
def __init__(self, **kwargs) -> None:
|
|
return None
|
|
|
|
async def run(self, message: str, session_key: str, **kwargs):
|
|
captured["message"] = message
|
|
captured["session_key"] = session_key
|
|
captured["timeout"] = kwargs.get("timeout")
|
|
captured["tool_context"] = kwargs["tool_context"]
|
|
yield DoneEvent()
|
|
|
|
async def fake_prompt_user(prefix: str = "[you] ", **kwargs):
|
|
return next(inputs)
|
|
|
|
async def fake_build_services() -> _FakeServices:
|
|
return _FakeServices()
|
|
|
|
monkeypatch.setattr("opensquilla.engine.runtime.TurnRunner", FakeTurnRunner)
|
|
monkeypatch.setattr("opensquilla.gateway.build_services", fake_build_services)
|
|
_install_fake_inputs(monkeypatch, inputs)
|
|
|
|
await chat_cmd._standalone_repl(
|
|
model="openrouter/test",
|
|
session_id="standalone:test",
|
|
timeout=7.25,
|
|
)
|
|
|
|
assert captured["message"] == "hello"
|
|
assert captured["session_key"] == "standalone:test"
|
|
assert captured["timeout"] == 7.25
|
|
assert captured["tool_context"].channel_kind == "cli"
|
|
assert captured["tool_context"].channel_id == "cli:chat"
|
|
assert captured["tool_context"].sender_id
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_standalone_chat_uses_workspace_in_tool_context(
|
|
monkeypatch,
|
|
tmp_path,
|
|
) -> None:
|
|
captured: dict[str, object] = {}
|
|
inputs = iter(["hello", "/quit"])
|
|
|
|
class FakeTurnRunner:
|
|
def __init__(self, **kwargs) -> None:
|
|
return None
|
|
|
|
async def run(self, message: str, session_key: str, **kwargs):
|
|
captured["tool_context"] = kwargs["tool_context"]
|
|
yield DoneEvent()
|
|
|
|
async def fake_prompt_user(prefix: str = "[you] ", **kwargs):
|
|
return next(inputs)
|
|
|
|
async def fake_build_services() -> _FakeServices:
|
|
return _FakeServices()
|
|
|
|
monkeypatch.setattr("opensquilla.engine.runtime.TurnRunner", FakeTurnRunner)
|
|
monkeypatch.setattr("opensquilla.gateway.build_services", fake_build_services)
|
|
_install_fake_inputs(monkeypatch, inputs)
|
|
|
|
await chat_cmd._standalone_repl(
|
|
model="openrouter/test",
|
|
session_id="standalone:test",
|
|
workspace=str(tmp_path),
|
|
workspace_strict=True,
|
|
)
|
|
|
|
tool_context = captured["tool_context"]
|
|
assert tool_context.workspace_dir == str(tmp_path)
|
|
assert tool_context.workspace_strict is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_standalone_path_command_runs_as_plain_message(
|
|
monkeypatch,
|
|
tmp_path,
|
|
) -> None:
|
|
target = tmp_path / "large.log"
|
|
target.write_text("hello\n", encoding="utf-8")
|
|
captured: dict[str, object] = {}
|
|
inputs = iter([f"/path {target} inspect", "/quit"])
|
|
|
|
class FakeTurnRunner:
|
|
def __init__(self, **kwargs) -> None:
|
|
return None
|
|
|
|
async def run(self, message: str, session_key: str, **kwargs):
|
|
captured["message"] = message
|
|
captured["kwargs"] = kwargs
|
|
yield DoneEvent()
|
|
|
|
async def fake_prompt_user(prefix: str = "[you] ", **kwargs):
|
|
return next(inputs)
|
|
|
|
async def fake_build_services() -> _FakeServices:
|
|
return _FakeServices()
|
|
|
|
monkeypatch.setattr("opensquilla.engine.runtime.TurnRunner", FakeTurnRunner)
|
|
monkeypatch.setattr("opensquilla.gateway.build_services", fake_build_services)
|
|
_install_fake_inputs(monkeypatch, inputs)
|
|
|
|
await chat_cmd._standalone_repl(
|
|
model="openrouter/test",
|
|
session_id="standalone:test",
|
|
)
|
|
|
|
assert "inspect" in captured["message"]
|
|
assert str(target.resolve(strict=False)) in captured["message"]
|
|
assert "attachments" not in captured["kwargs"]
|
|
|
|
|
|
def test_chat_workspace_strict_resolution_matches_agent_precedence(
|
|
monkeypatch,
|
|
tmp_path,
|
|
) -> None:
|
|
from opensquilla.cli.agent_cmd import _resolve_workspace_strict
|
|
|
|
monkeypatch.setenv("OPENSQUILLA_WORKSPACE_STRICT", "false")
|
|
assert (
|
|
_resolve_workspace_strict(
|
|
cli_value=True,
|
|
config_value=False,
|
|
entrypoint_default=bool(tmp_path),
|
|
)
|
|
is True
|
|
)
|
|
assert (
|
|
_resolve_workspace_strict(
|
|
cli_value=None,
|
|
config_value=True,
|
|
entrypoint_default=bool(tmp_path),
|
|
)
|
|
is False
|
|
)
|
|
monkeypatch.delenv("OPENSQUILLA_WORKSPACE_STRICT")
|
|
assert (
|
|
_resolve_workspace_strict(
|
|
cli_value=None,
|
|
config_value=True,
|
|
entrypoint_default=False,
|
|
)
|
|
is True
|
|
)
|
|
assert (
|
|
_resolve_workspace_strict(
|
|
cli_value=None,
|
|
config_value=None,
|
|
entrypoint_default=True,
|
|
)
|
|
is True
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_standalone_repl_wires_memory_services_into_turnrunner(monkeypatch) -> None:
|
|
services = _FakeServices()
|
|
captured: dict[str, object] = {}
|
|
inputs = iter(["/quit"])
|
|
|
|
class FakeTurnRunner:
|
|
def __init__(self, **kwargs) -> None:
|
|
captured.update(kwargs)
|
|
|
|
async def run(self, message: str, session_key: str, **kwargs):
|
|
yield DoneEvent()
|
|
|
|
async def fake_prompt_user(prefix: str = "[you] ", **kwargs):
|
|
return next(inputs)
|
|
|
|
async def fake_build_services() -> _FakeServices:
|
|
return services
|
|
|
|
monkeypatch.setattr("opensquilla.engine.runtime.TurnRunner", FakeTurnRunner)
|
|
monkeypatch.setattr("opensquilla.gateway.build_services", fake_build_services)
|
|
_install_fake_inputs(monkeypatch, inputs)
|
|
|
|
await chat_cmd._standalone_repl(
|
|
model="openrouter/test",
|
|
session_id="standalone:test",
|
|
timeout=7.25,
|
|
)
|
|
|
|
assert captured["memory_sync_managers"] is services.memory_sync_managers
|
|
assert captured["memory_retrievers"] is services.memory_retrievers
|
|
assert captured["turn_capture_services"] is services.turn_capture_services
|
|
assert captured["session_flush_service"] is services.flush_service
|
|
assert captured["model_catalog"] is services.model_catalog
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_standalone_turnrunner_stream_uses_heartbeat_wrapper(monkeypatch) -> None:
|
|
class FakeTurnRunner:
|
|
async def run(self, message: str, session_key: str, **kwargs):
|
|
await asyncio.sleep(0.03)
|
|
yield TextDeltaEvent(text="ok")
|
|
yield DoneEvent()
|
|
|
|
_RecordingRenderer.instances.clear()
|
|
monkeypatch.setattr("opensquilla.engine.runtime.TurnRunner", FakeTurnRunner)
|
|
monkeypatch.setattr(
|
|
"opensquilla.cli.tui.opentui.renderer.OpenTuiStreamRenderer",
|
|
_RecordingRenderer,
|
|
)
|
|
monkeypatch.setattr(
|
|
"opensquilla.cli.tui.adapters.turn_stream_defaults.OpenTuiStreamRenderer",
|
|
_RecordingRenderer,
|
|
)
|
|
# Renderer choice is now backend-aware; pin the OpenTUI backend so the
|
|
# recording renderer (patched above) is the one the bridge selects.
|
|
monkeypatch.setattr(
|
|
"opensquilla.cli.tui.adapters.runtime_bridge.validate_tui_backend_selection",
|
|
lambda env=None: "opentui",
|
|
)
|
|
svc = SimpleNamespace(
|
|
config=SimpleNamespace(
|
|
agent_stream_heartbeat_interval_seconds=0.01,
|
|
agent_stream_idle_timeout_seconds=1.0,
|
|
),
|
|
session_manager=_FakeSessionManager(),
|
|
)
|
|
tool_ctx = ToolContext(caller_kind=CallerKind.CLI, channel_kind="cli", channel_id="cli:chat")
|
|
|
|
result = await chat_cmd._stream_response_turnrunner(
|
|
FakeTurnRunner(),
|
|
"agent:main:standalone:test",
|
|
tool_ctx,
|
|
"hello",
|
|
svc=svc,
|
|
)
|
|
|
|
renderer = _RecordingRenderer.instances[-1]
|
|
assert result.text == "ok"
|
|
assert renderer.pulses >= 1
|
|
assert renderer.finalized is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_standalone_turnrunner_stream_collects_artifacts(monkeypatch) -> None:
|
|
artifact = {
|
|
"id": "art-chat",
|
|
"kind": "artifact_ref",
|
|
"name": "report.txt",
|
|
"mime": "text/plain",
|
|
"size": 4,
|
|
"sha256": "e" * 64,
|
|
"session_id": "session-1",
|
|
"session_key": "agent:main:standalone:test",
|
|
"source": "publish_artifact",
|
|
"created_at": "2026-05-06T12:00:00Z",
|
|
"download_url": "/api/v1/artifacts/art-chat?sessionKey=agent%3Amain%3Astandalone%3Atest",
|
|
"store": "artifacts",
|
|
}
|
|
|
|
class FakeTurnRunner:
|
|
async def run(self, message: str, session_key: str, **kwargs):
|
|
yield ArtifactEvent(**artifact)
|
|
yield TextDeltaEvent(text="ok")
|
|
yield DoneEvent()
|
|
|
|
monkeypatch.setattr("opensquilla.engine.runtime.TurnRunner", FakeTurnRunner)
|
|
monkeypatch.setattr(
|
|
"opensquilla.cli.tui.opentui.renderer.OpenTuiStreamRenderer",
|
|
_RecordingRenderer,
|
|
)
|
|
monkeypatch.setattr(
|
|
"opensquilla.cli.tui.adapters.turn_stream_defaults.OpenTuiStreamRenderer",
|
|
_RecordingRenderer,
|
|
)
|
|
# Renderer choice is now backend-aware; pin the OpenTUI backend so the
|
|
# recording renderer (patched above) is the one the bridge selects.
|
|
monkeypatch.setattr(
|
|
"opensquilla.cli.tui.adapters.runtime_bridge.validate_tui_backend_selection",
|
|
lambda env=None: "opentui",
|
|
)
|
|
svc = SimpleNamespace(
|
|
config=SimpleNamespace(
|
|
agent_stream_heartbeat_interval_seconds=0.0,
|
|
agent_stream_idle_timeout_seconds=1.0,
|
|
),
|
|
session_manager=_FakeSessionManager(),
|
|
)
|
|
tool_ctx = ToolContext(caller_kind=CallerKind.CLI, channel_kind="cli", channel_id="cli:chat")
|
|
|
|
result = await chat_cmd._stream_response_turnrunner(
|
|
FakeTurnRunner(),
|
|
"agent:main:standalone:test",
|
|
tool_ctx,
|
|
"hello",
|
|
svc=svc,
|
|
)
|
|
|
|
assert result.text == "ok"
|
|
assert result.artifacts[0]["download_url"] == "/api/v1/artifacts/art-chat"
|
|
assert "session_key" not in result.artifacts[0]
|
|
assert "sessionKey" not in json.dumps(result.artifacts[0])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_standalone_turnrunner_wires_tool_strip(monkeypatch) -> None:
|
|
"""Tool events must drive ``tool_start``/``tool_finished`` on the renderer.
|
|
|
|
Without this wiring the ToolCallStrip pairing/coalescing never runs against
|
|
real traffic and the 12-call ``execute_code`` flood reappears.
|
|
"""
|
|
|
|
class FakeTurnRunner:
|
|
async def run(self, message: str, session_key: str, **kwargs):
|
|
yield ToolUseStartEvent(tool_use_id="call-1", tool_name="execute_code")
|
|
yield ToolResultEvent(
|
|
tool_use_id="call-1",
|
|
tool_name="execute_code",
|
|
result="2",
|
|
is_error=False,
|
|
)
|
|
yield ToolUseStartEvent(tool_use_id="call-2", tool_name="execute_code")
|
|
yield ToolResultEvent(
|
|
tool_use_id="call-2",
|
|
tool_name="execute_code",
|
|
result="boom",
|
|
is_error=True,
|
|
)
|
|
yield ToolUseStartEvent(tool_use_id="call-3", tool_name="execute_code")
|
|
yield ToolResultEvent(
|
|
tool_use_id="call-3",
|
|
tool_name="execute_code",
|
|
result="approval pending",
|
|
is_error=False,
|
|
execution_status={
|
|
"version": 1,
|
|
"status": "unknown",
|
|
"exit_code": None,
|
|
"timed_out": False,
|
|
"truncated": False,
|
|
"reason": "approval_pending",
|
|
"source": "tool_runtime",
|
|
"preservation_class": "ephemeral",
|
|
},
|
|
)
|
|
yield DoneEvent()
|
|
|
|
_RecordingRenderer.instances.clear()
|
|
monkeypatch.setattr("opensquilla.engine.runtime.TurnRunner", FakeTurnRunner)
|
|
monkeypatch.setattr(
|
|
"opensquilla.cli.tui.opentui.renderer.OpenTuiStreamRenderer",
|
|
_RecordingRenderer,
|
|
)
|
|
monkeypatch.setattr(
|
|
"opensquilla.cli.tui.adapters.turn_stream_defaults.OpenTuiStreamRenderer",
|
|
_RecordingRenderer,
|
|
)
|
|
# Renderer choice is now backend-aware; pin the OpenTUI backend so the
|
|
# recording renderer (patched above) is the one the bridge selects.
|
|
monkeypatch.setattr(
|
|
"opensquilla.cli.tui.adapters.runtime_bridge.validate_tui_backend_selection",
|
|
lambda env=None: "opentui",
|
|
)
|
|
svc = SimpleNamespace(
|
|
config=SimpleNamespace(
|
|
agent_stream_heartbeat_interval_seconds=0.0,
|
|
agent_stream_idle_timeout_seconds=1.0,
|
|
),
|
|
session_manager=_FakeSessionManager(),
|
|
)
|
|
tool_ctx = ToolContext(caller_kind=CallerKind.CLI, channel_kind="cli", channel_id="cli:chat")
|
|
|
|
await chat_cmd._stream_response_turnrunner(
|
|
FakeTurnRunner(),
|
|
"agent:main:standalone:test",
|
|
tool_ctx,
|
|
"hello",
|
|
svc=svc,
|
|
)
|
|
|
|
renderer = _RecordingRenderer.instances[-1]
|
|
assert renderer.tool_starts == [
|
|
("execute_code", None, "call-1"),
|
|
("execute_code", None, "call-2"),
|
|
("execute_code", None, "call-3"),
|
|
]
|
|
assert renderer.tool_finishes == [
|
|
("call-1", True, "2"),
|
|
("call-2", False, "boom"),
|
|
("call-3", False, "approval pending"),
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_standalone_repl_uses_exact_slash_tokens(monkeypatch) -> None:
|
|
services = _FakeServices()
|
|
inputs = iter(["/newer", "/models", "/quit"])
|
|
run_calls: list[str] = []
|
|
|
|
class FakeTurnRunner:
|
|
def __init__(self, **kwargs) -> None:
|
|
return None
|
|
|
|
async def run(self, message: str, session_key: str, **kwargs):
|
|
run_calls.append(message)
|
|
yield DoneEvent()
|
|
|
|
async def fake_prompt_user(prefix: str = "[you] ", **kwargs):
|
|
return next(inputs)
|
|
|
|
async def fake_build_services() -> _FakeServices:
|
|
return services
|
|
|
|
monkeypatch.setattr("opensquilla.engine.runtime.TurnRunner", FakeTurnRunner)
|
|
monkeypatch.setattr("opensquilla.gateway.build_services", fake_build_services)
|
|
_install_fake_inputs(monkeypatch, inputs)
|
|
|
|
await chat_cmd._standalone_repl(
|
|
model="openrouter/test",
|
|
session_id="standalone:test",
|
|
timeout=7.25,
|
|
)
|
|
|
|
assert services.session_manager.get_or_create_calls == [
|
|
{"session_key": "standalone:test", "agent_id": "main"}
|
|
]
|
|
assert run_calls == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_standalone_slash_compact_passes_provider_config(monkeypatch) -> None:
|
|
services = _FakeServices()
|
|
services.provider_selector = _FakeProviderSelector()
|
|
services.config = SimpleNamespace(
|
|
context_budget_tokens=1234,
|
|
compaction=SimpleNamespace(enabled=True, model=None, timeout_seconds=12.5),
|
|
)
|
|
inputs = iter(["/compact", "/quit"])
|
|
|
|
class FakeTurnRunner:
|
|
def __init__(self, **kwargs) -> None:
|
|
return None
|
|
|
|
async def run(self, message: str, session_key: str, **kwargs):
|
|
yield DoneEvent()
|
|
|
|
async def fake_prompt_user(prefix: str = "[you] ", **kwargs):
|
|
return next(inputs)
|
|
|
|
async def fake_build_services() -> _FakeServices:
|
|
return services
|
|
|
|
monkeypatch.setattr("opensquilla.engine.runtime.TurnRunner", FakeTurnRunner)
|
|
monkeypatch.setattr("opensquilla.gateway.build_services", fake_build_services)
|
|
_install_fake_inputs(monkeypatch, inputs)
|
|
|
|
await chat_cmd._standalone_repl(
|
|
model="openrouter/test",
|
|
session_id="standalone:test",
|
|
timeout=7.25,
|
|
)
|
|
|
|
assert len(services.session_manager.compact_calls) == 1
|
|
session_key, context_window, config = services.session_manager.compact_calls[0]
|
|
assert session_key == "standalone:test"
|
|
assert context_window == 1234
|
|
assert isinstance(config, CompactionConfig)
|
|
assert config.api_key == "cli-provider-key"
|
|
assert config.model == "openrouter/test"
|
|
assert config.base_url == "https://openrouter.ai/api/v1"
|
|
assert config.timeout_seconds == 12.5
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_standalone_reset_refuses_non_empty_transcript_without_flush_service(
|
|
monkeypatch,
|
|
) -> None:
|
|
services = _FakeServices()
|
|
services.flush_service = None
|
|
session_key = "standalone:test"
|
|
services.session_manager.transcripts[session_key] = [
|
|
SimpleNamespace(role="user", content="persisted")
|
|
]
|
|
inputs = iter(["/reset", "/quit"])
|
|
|
|
class FakeTurnRunner:
|
|
def __init__(self, **kwargs) -> None:
|
|
return None
|
|
|
|
async def run(self, message: str, session_key: str, **kwargs):
|
|
yield DoneEvent()
|
|
|
|
async def fake_prompt_user(prefix: str = "[you] ", **kwargs):
|
|
return next(inputs)
|
|
|
|
async def fake_build_services() -> _FakeServices:
|
|
return services
|
|
|
|
monkeypatch.setattr("opensquilla.engine.runtime.TurnRunner", FakeTurnRunner)
|
|
monkeypatch.setattr("opensquilla.gateway.build_services", fake_build_services)
|
|
_install_fake_inputs(monkeypatch, inputs)
|
|
|
|
await chat_cmd._standalone_repl(model="openrouter/test", session_id=session_key)
|
|
|
|
assert services.session_manager.truncate_calls == []
|
|
assert await services.session_manager.get_transcript(session_key)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_standalone_compact_missing_flush_service_does_not_block_compaction(
|
|
monkeypatch,
|
|
) -> None:
|
|
services = _FakeServices()
|
|
services.flush_service = None
|
|
session_key = "standalone:test"
|
|
services.session_manager.transcripts[session_key] = [
|
|
SimpleNamespace(role="user", content="persisted")
|
|
]
|
|
services.provider_selector = _FakeProviderSelector()
|
|
services.config = SimpleNamespace(
|
|
context_budget_tokens=1234,
|
|
compaction=SimpleNamespace(enabled=True, model=None, timeout_seconds=12.5),
|
|
)
|
|
inputs = iter(["/compact", "/quit"])
|
|
|
|
class FakeTurnRunner:
|
|
def __init__(self, **kwargs) -> None:
|
|
return None
|
|
|
|
async def run(self, message: str, session_key: str, **kwargs):
|
|
yield DoneEvent()
|
|
|
|
async def fake_prompt_user(prefix: str = "[you] ", **kwargs):
|
|
return next(inputs)
|
|
|
|
async def fake_build_services() -> _FakeServices:
|
|
return services
|
|
|
|
monkeypatch.setattr("opensquilla.engine.runtime.TurnRunner", FakeTurnRunner)
|
|
monkeypatch.setattr("opensquilla.gateway.build_services", fake_build_services)
|
|
_install_fake_inputs(monkeypatch, inputs)
|
|
|
|
await chat_cmd._standalone_repl(model="openrouter/test", session_id=session_key)
|
|
|
|
assert len(services.session_manager.compact_calls) == 1
|
|
|
|
|
|
class _FakeFlushService:
|
|
def __init__(self, receipt: object | None = None, error: Exception | None = None) -> None:
|
|
self.receipt = receipt or SimpleNamespace(
|
|
mode="llm",
|
|
error=None,
|
|
indexed_chunk_count=1,
|
|
integrity_status="ok",
|
|
output_coverage_status="ok",
|
|
invalid_candidate_count=0,
|
|
candidate_missing_ids=[],
|
|
obligation_status="ok",
|
|
obligation_missing_ids=[],
|
|
)
|
|
self.error = error
|
|
self.calls: list[dict[str, object]] = []
|
|
|
|
async def execute(self, transcript: object, session_key: str, **kwargs) -> object:
|
|
self.calls.append({"transcript": transcript, "session_key": session_key, "kwargs": kwargs})
|
|
if self.error is not None:
|
|
raise self.error
|
|
return self.receipt
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_standalone_compact_flushes_before_compacting(monkeypatch) -> None:
|
|
services = _FakeServices()
|
|
session_key = "standalone:test"
|
|
services.session_manager.transcripts[session_key] = [
|
|
SimpleNamespace(role="user", content="persisted")
|
|
]
|
|
services.flush_service = _FakeFlushService()
|
|
services.provider_selector = _FakeProviderSelector()
|
|
services.config = SimpleNamespace(
|
|
context_budget_tokens=1234,
|
|
compaction=SimpleNamespace(enabled=True, model=None, timeout_seconds=12.5),
|
|
)
|
|
inputs = iter(["/compact", "/quit"])
|
|
|
|
class FakeTurnRunner:
|
|
def __init__(self, **kwargs) -> None:
|
|
return None
|
|
|
|
async def run(self, message: str, session_key: str, **kwargs):
|
|
yield DoneEvent()
|
|
|
|
async def fake_prompt_user(prefix: str = "[you] ", **kwargs):
|
|
return next(inputs)
|
|
|
|
async def fake_build_services() -> _FakeServices:
|
|
return services
|
|
|
|
monkeypatch.setattr("opensquilla.engine.runtime.TurnRunner", FakeTurnRunner)
|
|
monkeypatch.setattr("opensquilla.gateway.build_services", fake_build_services)
|
|
_install_fake_inputs(monkeypatch, inputs)
|
|
|
|
await chat_cmd._standalone_repl(model="openrouter/test", session_id=session_key)
|
|
|
|
assert len(services.flush_service.calls) == 1
|
|
assert services.flush_service.calls[0]["session_key"] == session_key
|
|
assert services.flush_service.calls[0]["kwargs"]["message_window"] == 0
|
|
assert services.flush_service.calls[0]["kwargs"]["segment_mode"] == "auto"
|
|
assert len(services.session_manager.compact_calls) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_standalone_compact_continues_when_flush_fails(monkeypatch) -> None:
|
|
services = _FakeServices()
|
|
session_key = "standalone:test"
|
|
services.session_manager.transcripts[session_key] = [
|
|
SimpleNamespace(role="user", content="persisted")
|
|
]
|
|
services.flush_service = _FakeFlushService(
|
|
receipt=SimpleNamespace(mode="error", error="provider down")
|
|
)
|
|
services.provider_selector = _FakeProviderSelector()
|
|
services.config = SimpleNamespace(
|
|
context_budget_tokens=1234,
|
|
compaction=SimpleNamespace(enabled=True, model=None, timeout_seconds=12.5),
|
|
)
|
|
inputs = iter(["/compact", "/quit"])
|
|
|
|
class FakeTurnRunner:
|
|
def __init__(self, **kwargs) -> None:
|
|
return None
|
|
|
|
async def run(self, message: str, session_key: str, **kwargs):
|
|
yield DoneEvent()
|
|
|
|
async def fake_prompt_user(prefix: str = "[you] ", **kwargs):
|
|
return next(inputs)
|
|
|
|
async def fake_build_services() -> _FakeServices:
|
|
return services
|
|
|
|
monkeypatch.setattr("opensquilla.engine.runtime.TurnRunner", FakeTurnRunner)
|
|
monkeypatch.setattr("opensquilla.gateway.build_services", fake_build_services)
|
|
_install_fake_inputs(monkeypatch, inputs)
|
|
|
|
await chat_cmd._standalone_repl(model="openrouter/test", session_id=session_key)
|
|
|
|
assert len(services.flush_service.calls) == 1
|
|
assert len(services.session_manager.compact_calls) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_standalone_slash_compact_keeps_legacy_compact_manager_compatible(
|
|
monkeypatch,
|
|
) -> None:
|
|
services = _FakeServices()
|
|
services.session_manager = _LegacyCompactSessionManager()
|
|
services.provider_selector = _FakeProviderSelector()
|
|
services.config = SimpleNamespace(
|
|
context_budget_tokens=1234,
|
|
compaction=SimpleNamespace(enabled=True, model=None, timeout_seconds=12.5),
|
|
)
|
|
inputs = iter(["/compact", "/quit"])
|
|
|
|
class FakeTurnRunner:
|
|
def __init__(self, **kwargs) -> None:
|
|
return None
|
|
|
|
async def run(self, message: str, session_key: str, **kwargs):
|
|
yield DoneEvent()
|
|
|
|
async def fake_prompt_user(prefix: str = "[you] ", **kwargs):
|
|
return next(inputs)
|
|
|
|
async def fake_build_services() -> _FakeServices:
|
|
return services
|
|
|
|
monkeypatch.setattr("opensquilla.engine.runtime.TurnRunner", FakeTurnRunner)
|
|
monkeypatch.setattr("opensquilla.gateway.build_services", fake_build_services)
|
|
_install_fake_inputs(monkeypatch, inputs)
|
|
|
|
await chat_cmd._standalone_repl(
|
|
model="openrouter/test",
|
|
session_id="standalone:test",
|
|
timeout=7.25,
|
|
)
|
|
|
|
assert services.session_manager.compact_calls == [("standalone:test", 1234, None)]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Gateway-mode flag forwarding
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _FakeGatewayClient:
|
|
"""Fake GatewayClient that records create/send calls and feeds the REPL exit.
|
|
|
|
Patched in place of the real `GatewayClient` class so `_stream_response_gateway`'s
|
|
``isinstance(client, GatewayClient)`` assertion passes. Each instance registers
|
|
itself in a class-level ``instances`` list so tests can grab the one created
|
|
by the function-under-test.
|
|
"""
|
|
|
|
instances: list[_FakeGatewayClient]
|
|
|
|
def __init__(self) -> None:
|
|
self.create_calls: list[dict[str, object]] = []
|
|
self.send_calls: list[dict[str, object]] = []
|
|
self.resolve_calls: list[str] = []
|
|
self.delete_calls: list[list[str]] = []
|
|
self.history_calls: list[dict[str, object]] = []
|
|
self.abort_calls: list[str] = []
|
|
self.reset_calls: list[str] = []
|
|
self.compact_calls: list[dict[str, object]] = []
|
|
self.config_get_calls: list[str | None] = []
|
|
self.config_patch_safe_calls: list[dict[str, object]] = []
|
|
self.config_values: dict[str, object] = {}
|
|
self.usage_status_calls = 0
|
|
self.list_models_calls = 0
|
|
self.delete_result: dict[str, object] = {"deleted": [], "errors": []}
|
|
self.resolved_payload: dict[str, object] = {
|
|
"session_key": "agent:main:resolved",
|
|
"model": "openai/test",
|
|
}
|
|
self.connected = False
|
|
self.closed = False
|
|
type(self).instances.append(self)
|
|
|
|
async def connect(self, url: str, *, token: str | None = None) -> None:
|
|
self.connected = True
|
|
|
|
async def create_session(
|
|
self,
|
|
agent_id: str = "main",
|
|
model: str | None = None,
|
|
display_name: str | None = None,
|
|
) -> str:
|
|
self.create_calls.append(
|
|
{"agent_id": agent_id, "model": model, "display_name": display_name}
|
|
)
|
|
return "agent:main:fake12345"
|
|
|
|
async def resolve_session(self, key: str) -> dict[str, object]:
|
|
self.resolve_calls.append(key)
|
|
return self.resolved_payload
|
|
|
|
async def delete_sessions(self, keys: list[str]) -> dict[str, object]:
|
|
self.delete_calls.append(keys)
|
|
return self.delete_result
|
|
|
|
async def session_history(self, session_key: str, limit: int = 1000) -> dict[str, object]:
|
|
self.history_calls.append({"session_key": session_key, "limit": limit})
|
|
return {
|
|
"messages": [
|
|
{"role": "user", "text": "persisted hello"},
|
|
{"role": "assistant", "text": "persisted reply"},
|
|
]
|
|
}
|
|
|
|
async def list_models(self) -> list[dict[str, object]]:
|
|
self.list_models_calls += 1
|
|
return [{"id": "openai/test", "provider": "openai"}]
|
|
|
|
async def usage_status(self) -> dict[str, object]:
|
|
self.usage_status_calls += 1
|
|
return {"totalTokens": 1234, "totalCostUsd": 0.05678}
|
|
|
|
async def abort_session(self, session_key: str) -> dict[str, object]:
|
|
self.abort_calls.append(session_key)
|
|
return {"aborted": True, "key": session_key}
|
|
|
|
async def reset_session(self, session_key: str) -> dict[str, object]:
|
|
self.reset_calls.append(session_key)
|
|
return {"reset": True, "key": session_key}
|
|
|
|
async def compact_session(self, session_key: str) -> dict[str, object]:
|
|
self.compact_calls.append({"session_key": session_key})
|
|
return {
|
|
"key": session_key,
|
|
"compacted": True,
|
|
"mode": "summary",
|
|
"summary_len": 37,
|
|
}
|
|
|
|
async def get_config(self, path: str | None = None) -> object:
|
|
self.config_get_calls.append(path)
|
|
if path is None:
|
|
return dict(self.config_values)
|
|
return self.config_values.get(path)
|
|
|
|
async def patch_config_safe(self, patches: dict[str, object]) -> dict[str, object]:
|
|
self.config_patch_safe_calls.append(dict(patches))
|
|
self.config_values.update(patches)
|
|
return {"patched": list(patches)}
|
|
|
|
async def send_message(self, session_key, message, attachments=None, elevated=None):
|
|
self.send_calls.append(
|
|
{
|
|
"session_key": session_key,
|
|
"message": message,
|
|
"attachments": attachments,
|
|
"elevated": elevated,
|
|
}
|
|
)
|
|
# Drain immediately — REPL loop sees no events and proceeds to next prompt.
|
|
if False:
|
|
yield {}
|
|
|
|
async def close(self) -> None:
|
|
self.closed = True
|
|
|
|
|
|
_FakeGatewayClient.instances = []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_gateway_chat_forwards_model_to_create_session(monkeypatch) -> None:
|
|
"""`opensquilla chat --model X` (gateway mode) must reach create_session(model='X')."""
|
|
_FakeGatewayClient.instances.clear()
|
|
monkeypatch.setattr("opensquilla.cli.gateway_client.GatewayClient", _FakeGatewayClient)
|
|
inputs = iter(["/quit"])
|
|
|
|
async def fake_prompt_user(prefix: str = "[you] ", **kwargs):
|
|
return next(inputs)
|
|
|
|
_install_fake_inputs(monkeypatch, inputs)
|
|
|
|
await chat_cmd._gateway_chat(model="anthropic/claude-sonnet-4", session_id=None)
|
|
|
|
assert len(_FakeGatewayClient.instances) == 1
|
|
fake = _FakeGatewayClient.instances[-1]
|
|
assert fake.connected is True
|
|
assert fake.closed is True
|
|
assert fake.create_calls == [
|
|
{
|
|
"agent_id": "main",
|
|
"model": "anthropic/claude-sonnet-4",
|
|
"display_name": None,
|
|
}
|
|
]
|
|
assert fake.send_calls == [] # /quit on first prompt — no message sent
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_gateway_chat_session_id_skips_create_session(monkeypatch) -> None:
|
|
"""`opensquilla chat --session abc` (gateway mode) must reuse the key without create."""
|
|
_FakeGatewayClient.instances.clear()
|
|
monkeypatch.setattr("opensquilla.cli.gateway_client.GatewayClient", _FakeGatewayClient)
|
|
inputs = iter(["hi", "/quit"])
|
|
|
|
async def fake_prompt_user(prefix: str = "[you] ", **kwargs):
|
|
return next(inputs)
|
|
|
|
_install_fake_inputs(monkeypatch, inputs)
|
|
|
|
await chat_cmd._gateway_chat(model=None, session_id="agent:main:resumed-key")
|
|
|
|
fake = _FakeGatewayClient.instances[-1]
|
|
assert fake.create_calls == [] # MUST NOT create
|
|
assert len(fake.send_calls) == 1
|
|
assert fake.send_calls[0]["session_key"] == "agent:main:resumed-key"
|
|
assert fake.send_calls[0]["message"] == "hi"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_gateway_slash_new_passes_title_as_display_name(monkeypatch) -> None:
|
|
_FakeGatewayClient.instances.clear()
|
|
monkeypatch.setattr("opensquilla.cli.gateway_client.GatewayClient", _FakeGatewayClient)
|
|
fake = _FakeGatewayClient()
|
|
state = ChatSessionState(session_key="agent:main:old", model="openai/test")
|
|
|
|
handled = await chat_cmd._handle_gateway_slash_command(
|
|
"/new Research Notes", state, fake, {"mode": None}
|
|
)
|
|
|
|
assert handled is True
|
|
assert fake.create_calls == [
|
|
{
|
|
"agent_id": "main",
|
|
"model": "openai/test",
|
|
"display_name": "Research Notes",
|
|
}
|
|
]
|
|
assert state.session_key == "agent:main:fake12345"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_gateway_path_command_sends_prompt_without_attachments_or_upload(
|
|
monkeypatch,
|
|
tmp_path,
|
|
) -> None:
|
|
_FakeGatewayClient.instances.clear()
|
|
monkeypatch.setattr("opensquilla.cli.gateway_client.GatewayClient", _FakeGatewayClient)
|
|
fake = _FakeGatewayClient()
|
|
fake.is_local_gateway = True
|
|
|
|
async def fail_upload_file(*args, **kwargs):
|
|
raise AssertionError("upload_file must not be called for /path")
|
|
|
|
fake.upload_file = fail_upload_file
|
|
target = tmp_path / "large.log"
|
|
target.write_text("hello\n", encoding="utf-8")
|
|
state = ChatSessionState(session_key="agent:main:abc123", model="openai/test")
|
|
|
|
handled = await chat_cmd._handle_gateway_slash_command(
|
|
f"/path {target} summarize", state, fake, {"mode": None}
|
|
)
|
|
|
|
assert handled is True
|
|
assert len(fake.send_calls) == 1
|
|
assert fake.send_calls[0]["attachments"] == []
|
|
assert "summarize" in fake.send_calls[0]["message"]
|
|
assert str(target.resolve(strict=False)) in fake.send_calls[0]["message"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_gateway_path_command_remote_rejects_before_send(
|
|
monkeypatch,
|
|
tmp_path,
|
|
) -> None:
|
|
_FakeGatewayClient.instances.clear()
|
|
monkeypatch.setattr("opensquilla.cli.gateway_client.GatewayClient", _FakeGatewayClient)
|
|
fake = _FakeGatewayClient()
|
|
fake.is_local_gateway = False
|
|
state = ChatSessionState(session_key="agent:main:abc123", model="openai/test")
|
|
buffer = io.StringIO()
|
|
monkeypatch.setattr(
|
|
slash_bridge,
|
|
"console",
|
|
Console(file=buffer, force_terminal=False, width=100, highlight=False),
|
|
)
|
|
nonexistent = tmp_path / "does-not-exist.log"
|
|
|
|
handled = await chat_cmd._handle_gateway_slash_command(
|
|
f"/path {nonexistent} inspect", state, fake, {"mode": None}
|
|
)
|
|
|
|
assert handled is True
|
|
assert fake.send_calls == []
|
|
assert "Use /file to upload from this CLI machine" in buffer.getvalue()
|
|
assert "File not found" not in buffer.getvalue()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_gateway_chat_does_not_forward_workspace_fields() -> None:
|
|
from opensquilla.cli.gateway_client import GatewayClient
|
|
|
|
client = GatewayClient()
|
|
calls: list[tuple[str, dict[str, object]]] = []
|
|
|
|
async def fake_call(method: str, params: dict[str, object]) -> dict[str, object]:
|
|
calls.append((method, params))
|
|
return {}
|
|
|
|
client._call = fake_call # type: ignore[method-assign]
|
|
client._recv_queue.put_nowait({"event": "session.event.done", "payload": {}})
|
|
|
|
events = [
|
|
event
|
|
async for event in client.send_message(
|
|
"agent:main:abc123",
|
|
"hello",
|
|
attachments=[],
|
|
)
|
|
]
|
|
|
|
assert events[-1]["event"] == "session.event.done"
|
|
method, params = calls[1]
|
|
assert method == "sessions.send"
|
|
source = params["_source"]
|
|
assert "workspace_dir" not in source
|
|
assert "workspace_strict" not in source
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_gateway_client_resolve_approval_forwards_choice() -> None:
|
|
from opensquilla.cli.gateway_client import GatewayClient
|
|
|
|
client = GatewayClient()
|
|
calls: list[tuple[str, dict[str, object]]] = []
|
|
|
|
async def fake_call(method: str, params: dict[str, object]) -> dict[str, object]:
|
|
calls.append((method, params))
|
|
return {"status": "resolved"}
|
|
|
|
client._call = fake_call # type: ignore[method-assign]
|
|
|
|
result = await client.resolve_approval(
|
|
"approval-1",
|
|
True,
|
|
choice="allow_same_type",
|
|
)
|
|
|
|
assert result == {"status": "resolved"}
|
|
assert calls == [
|
|
(
|
|
"exec.approval.resolve",
|
|
{
|
|
"id": "approval-1",
|
|
"approved": True,
|
|
"choice": "allow_same_type",
|
|
},
|
|
)
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_gateway_client_follows_background_task_group_until_terminal() -> None:
|
|
from opensquilla.cli.gateway_client import GatewayClient
|
|
|
|
client = GatewayClient()
|
|
calls: list[tuple[str, dict[str, object]]] = []
|
|
|
|
async def fake_call(method: str, params: dict[str, object]) -> dict[str, object]:
|
|
calls.append((method, params))
|
|
return {}
|
|
|
|
client._call = fake_call # type: ignore[method-assign]
|
|
group_id = "subagent:agent:main:abc123:task-parent"
|
|
for frame in (
|
|
{"event": "session.event.task_group.waiting", "payload": {"group_id": group_id}},
|
|
{"event": "session.event.done", "payload": {"reason": "parent_yielded"}},
|
|
{
|
|
"event": "session.event.task_group.synthesizing",
|
|
"payload": {"group_id": group_id, "synthesis_task_id": "task-synth"},
|
|
},
|
|
{"event": "session.event.done", "payload": {"reason": "synthesis_done"}},
|
|
{"event": "task.succeeded", "payload": {"task_id": "task-synth"}},
|
|
{
|
|
"event": "session.event.task_group.done",
|
|
"payload": {"group_id": group_id, "delivery_status": "not_applicable"},
|
|
},
|
|
):
|
|
client._recv_queue.put_nowait(frame)
|
|
|
|
events = [
|
|
event
|
|
async for event in client.send_message(
|
|
"agent:main:abc123",
|
|
"hello",
|
|
attachments=[],
|
|
)
|
|
]
|
|
|
|
assert [event["event"] for event in events] == [
|
|
"session.event.task_group.waiting",
|
|
"session.event.done",
|
|
"session.event.task_group.synthesizing",
|
|
"session.event.done",
|
|
"task.succeeded",
|
|
"session.event.task_group.done",
|
|
]
|
|
assert events[-1]["delivery_status"] == "not_applicable"
|
|
assert calls[0][0] == "sessions.messages.subscribe"
|
|
assert calls[1][0] == "sessions.send"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_gateway_client_does_not_wait_for_late_task_group_after_done() -> None:
|
|
from opensquilla.cli.gateway_client import GatewayClient
|
|
|
|
client = GatewayClient()
|
|
|
|
async def fake_call(method: str, params: dict[str, object]) -> dict[str, object]:
|
|
return {}
|
|
|
|
client._call = fake_call # type: ignore[method-assign]
|
|
client._recv_queue.put_nowait({"event": "session.event.done", "payload": {}})
|
|
client._recv_queue.put_nowait(
|
|
{
|
|
"event": "session.event.task_group.synthesizing",
|
|
"payload": {"group_id": "late-group"},
|
|
}
|
|
)
|
|
|
|
events = [
|
|
event
|
|
async for event in client.send_message(
|
|
"agent:main:abc123",
|
|
"hello",
|
|
attachments=[],
|
|
)
|
|
]
|
|
|
|
assert [event["event"] for event in events] == ["session.event.done"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_gateway_client_does_not_end_on_untracked_task_group_terminal() -> None:
|
|
from opensquilla.cli.gateway_client import GatewayClient
|
|
|
|
client = GatewayClient()
|
|
|
|
async def fake_call(method: str, params: dict[str, object]) -> dict[str, object]:
|
|
return {}
|
|
|
|
client._call = fake_call # type: ignore[method-assign]
|
|
client._recv_queue.put_nowait(
|
|
{
|
|
"event": "session.event.task_group.done",
|
|
"payload": {"group_id": "untracked-group", "delivery_status": "not_applicable"},
|
|
}
|
|
)
|
|
client._recv_queue.put_nowait({"event": "session.event.done", "payload": {}})
|
|
|
|
events = [
|
|
event
|
|
async for event in client.send_message(
|
|
"agent:main:abc123",
|
|
"hello",
|
|
attachments=[],
|
|
)
|
|
]
|
|
|
|
assert [event["event"] for event in events] == [
|
|
"session.event.task_group.done",
|
|
"session.event.done",
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_gateway_slash_clear_resets_session_state(monkeypatch) -> None:
|
|
_FakeGatewayClient.instances.clear()
|
|
monkeypatch.setattr("opensquilla.cli.gateway_client.GatewayClient", _FakeGatewayClient)
|
|
fake = _FakeGatewayClient()
|
|
state = ChatSessionState(session_key="agent:main:abc123", model="openai/test")
|
|
state.transcript.add("user", "hello")
|
|
|
|
handled = await chat_cmd._handle_gateway_slash_command("/clear", state, fake, {"mode": None})
|
|
|
|
assert handled is True
|
|
assert fake.reset_calls == ["agent:main:abc123"]
|
|
assert state.transcript.turns == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_gateway_slash_compact_calls_session_rpc(monkeypatch) -> None:
|
|
_FakeGatewayClient.instances.clear()
|
|
monkeypatch.setattr("opensquilla.cli.gateway_client.GatewayClient", _FakeGatewayClient)
|
|
fake = _FakeGatewayClient()
|
|
state = ChatSessionState(session_key="agent:main:abc123", model="openai/test")
|
|
|
|
handled = await chat_cmd._handle_gateway_slash_command("/compact", state, fake, {"mode": None})
|
|
|
|
assert handled is True
|
|
assert fake.compact_calls == [{"session_key": "agent:main:abc123"}]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_gateway_slash_compact_skipped_uses_context_budget_wording(monkeypatch) -> None:
|
|
_FakeGatewayClient.instances.clear()
|
|
monkeypatch.setattr("opensquilla.cli.gateway_client.GatewayClient", _FakeGatewayClient)
|
|
fake = _FakeGatewayClient()
|
|
|
|
async def compact_skipped(session_key: str) -> dict[str, object]:
|
|
fake.compact_calls.append({"session_key": session_key})
|
|
return {"key": session_key, "compacted": False}
|
|
|
|
fake.compact_session = compact_skipped
|
|
state = ChatSessionState(session_key="agent:main:abc123", model="openai/test")
|
|
buffer = io.StringIO()
|
|
monkeypatch.setattr(
|
|
slash_bridge,
|
|
"console",
|
|
Console(file=buffer, force_terminal=False, width=100, highlight=False),
|
|
)
|
|
|
|
handled = await chat_cmd._handle_gateway_slash_command("/compact", state, fake, {"mode": None})
|
|
|
|
assert handled is True
|
|
assert fake.compact_calls == [{"session_key": "agent:main:abc123"}]
|
|
output = buffer.getvalue()
|
|
assert "compact skipped" in output
|
|
assert "already within context budget; no compact was applied" in output
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_gateway_slash_compact_reports_started_and_failure(monkeypatch) -> None:
|
|
_FakeGatewayClient.instances.clear()
|
|
monkeypatch.setattr("opensquilla.cli.gateway_client.GatewayClient", _FakeGatewayClient)
|
|
fake = _FakeGatewayClient()
|
|
|
|
async def compact_failed(session_key: str) -> dict[str, object]:
|
|
fake.compact_calls.append({"session_key": session_key})
|
|
raise RuntimeError("provider down")
|
|
|
|
fake.compact_session = compact_failed
|
|
state = ChatSessionState(session_key="agent:main:abc123", model="openai/test")
|
|
buffer = io.StringIO()
|
|
monkeypatch.setattr(
|
|
slash_bridge,
|
|
"console",
|
|
Console(file=buffer, force_terminal=False, width=100, highlight=False),
|
|
)
|
|
|
|
handled = await chat_cmd._handle_gateway_slash_command("/compact", state, fake, {"mode": None})
|
|
|
|
assert handled is True
|
|
assert fake.compact_calls == [{"session_key": "agent:main:abc123"}]
|
|
output = buffer.getvalue()
|
|
assert "compacting context" in output
|
|
assert "compact failed: provider down" in output
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_gateway_slash_delete_resolves_and_reports_errors(monkeypatch) -> None:
|
|
_FakeGatewayClient.instances.clear()
|
|
monkeypatch.setattr("opensquilla.cli.gateway_client.GatewayClient", _FakeGatewayClient)
|
|
fake = _FakeGatewayClient()
|
|
fake.resolved_payload = {"session_key": "agent:main:abc123"}
|
|
fake.delete_result = {"deleted": [], "errors": ["agent:main:abc123: locked"]}
|
|
state = ChatSessionState(session_key="agent:main:current", model="openai/test")
|
|
buffer = io.StringIO()
|
|
monkeypatch.setattr(
|
|
slash_bridge,
|
|
"console",
|
|
Console(file=buffer, force_terminal=False, width=100, highlight=False),
|
|
)
|
|
|
|
handled = await chat_cmd._handle_gateway_slash_command(
|
|
"/delete abc", state, fake, {"mode": None}
|
|
)
|
|
|
|
assert handled is True
|
|
assert fake.resolve_calls == ["abc"]
|
|
assert fake.delete_calls == [["agent:main:abc123"]]
|
|
output = buffer.getvalue()
|
|
assert "Delete failed" in output
|
|
assert "locked" in output
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_gateway_slash_save_exports_persisted_history(monkeypatch, tmp_path) -> None:
|
|
_FakeGatewayClient.instances.clear()
|
|
monkeypatch.setattr("opensquilla.cli.gateway_client.GatewayClient", _FakeGatewayClient)
|
|
fake = _FakeGatewayClient()
|
|
state = ChatSessionState(session_key="agent:main:abc123", model="openai/test")
|
|
output = tmp_path / "saved.md"
|
|
|
|
handled = await chat_cmd._handle_gateway_slash_command(
|
|
f"/save {output}", state, fake, {"mode": None}
|
|
)
|
|
|
|
assert handled is True
|
|
assert fake.history_calls == [{"session_key": "agent:main:abc123", "limit": 1000}]
|
|
text = output.read_text(encoding="utf-8")
|
|
assert "## You" in text
|
|
assert "persisted hello" in text
|
|
assert "## Assistant" in text
|
|
assert "persisted reply" in text
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_gateway_slash_models_does_not_hit_model_prefix(monkeypatch) -> None:
|
|
_FakeGatewayClient.instances.clear()
|
|
monkeypatch.setattr("opensquilla.cli.gateway_client.GatewayClient", _FakeGatewayClient)
|
|
fake = _FakeGatewayClient()
|
|
state = ChatSessionState(session_key="agent:main:abc123", model="openai/test")
|
|
|
|
handled = await chat_cmd._handle_gateway_slash_command("/models", state, fake, {"mode": None})
|
|
|
|
assert handled is True
|
|
assert fake.list_models_calls == 1
|
|
assert state.model == "openai/test"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_gateway_slash_usage_calls_usage_status(monkeypatch) -> None:
|
|
_FakeGatewayClient.instances.clear()
|
|
monkeypatch.setattr("opensquilla.cli.gateway_client.GatewayClient", _FakeGatewayClient)
|
|
fake = _FakeGatewayClient()
|
|
state = ChatSessionState(session_key="agent:main:abc123", model="openai/test")
|
|
|
|
handled = await chat_cmd._handle_gateway_slash_command("/usage", state, fake, {"mode": None})
|
|
|
|
assert handled is True
|
|
assert fake.usage_status_calls == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_gateway_slash_unknown_prefix_is_not_handled(monkeypatch) -> None:
|
|
_FakeGatewayClient.instances.clear()
|
|
monkeypatch.setattr("opensquilla.cli.gateway_client.GatewayClient", _FakeGatewayClient)
|
|
fake = _FakeGatewayClient()
|
|
state = ChatSessionState(session_key="agent:main:abc123", model="openai/test")
|
|
|
|
handled = await chat_cmd._handle_gateway_slash_command("/newer", state, fake, {"mode": None})
|
|
|
|
assert handled is False
|
|
assert fake.create_calls == []
|
|
assert state.session_key == "agent:main:abc123"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_gateway_stream_keyboard_interrupt_aborts_turn(monkeypatch) -> None:
|
|
class InterruptingGatewayClient(_FakeGatewayClient):
|
|
async def send_message(self, session_key, message, attachments=None, elevated=None):
|
|
self.send_calls.append(
|
|
{
|
|
"session_key": session_key,
|
|
"message": message,
|
|
"attachments": attachments,
|
|
"elevated": elevated,
|
|
}
|
|
)
|
|
raise KeyboardInterrupt
|
|
yield {}
|
|
|
|
InterruptingGatewayClient.instances = []
|
|
monkeypatch.setattr("opensquilla.cli.gateway_client.GatewayClient", InterruptingGatewayClient)
|
|
fake = InterruptingGatewayClient()
|
|
|
|
result = await chat_cmd._stream_response_gateway(
|
|
fake,
|
|
"agent:main:abc123",
|
|
"hello",
|
|
{"mode": None},
|
|
)
|
|
|
|
assert result.cancelled is True
|
|
assert fake.abort_calls == ["agent:main:abc123"]
|
|
assert fake.send_calls[0]["message"] == "hello"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_gateway_stream_cancelled_error_aborts_turn(monkeypatch) -> None:
|
|
class CancelledGatewayClient(_FakeGatewayClient):
|
|
async def send_message(self, session_key, message, attachments=None, elevated=None):
|
|
self.send_calls.append(
|
|
{
|
|
"session_key": session_key,
|
|
"message": message,
|
|
"attachments": attachments,
|
|
"elevated": elevated,
|
|
}
|
|
)
|
|
raise asyncio.CancelledError
|
|
yield {}
|
|
|
|
CancelledGatewayClient.instances = []
|
|
monkeypatch.setattr("opensquilla.cli.gateway_client.GatewayClient", CancelledGatewayClient)
|
|
fake = CancelledGatewayClient()
|
|
|
|
result = await chat_cmd._stream_response_gateway(
|
|
fake,
|
|
"agent:main:abc123",
|
|
"hello",
|
|
{"mode": None},
|
|
)
|
|
|
|
assert result.cancelled is True
|
|
assert fake.abort_calls == ["agent:main:abc123"]
|
|
assert fake.send_calls[0]["message"] == "hello"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_gateway_stream_renders_task_group_status_without_buffer_pollution(
|
|
monkeypatch,
|
|
) -> None:
|
|
class StatusGatewayClient(_FakeGatewayClient):
|
|
async def send_message(self, session_key, message, attachments=None, elevated=None):
|
|
yield {
|
|
"event": "session.event.task_group.waiting",
|
|
"group_id": "group-1",
|
|
"pending_count": 2,
|
|
}
|
|
yield {
|
|
"event": "session.event.task_group.synthesizing",
|
|
"group_id": "group-1",
|
|
"child_count": 2,
|
|
}
|
|
yield {"event": "session.event.text_delta", "text": "answer"}
|
|
yield {
|
|
"event": "session.event.task_group.done",
|
|
"group_id": "group-1",
|
|
"delivery_status": "not_applicable",
|
|
}
|
|
yield {"event": "session.event.done"}
|
|
|
|
class RecordingRenderer:
|
|
instances: list[RecordingRenderer] = []
|
|
|
|
def __init__(self, *_args, **_kwargs) -> None:
|
|
self.buffer = ""
|
|
self.statuses: list[str] = []
|
|
self.finalized = False
|
|
RecordingRenderer.instances.append(self)
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc, tb) -> bool:
|
|
return False
|
|
|
|
def append_text(self, delta: str) -> None:
|
|
self.buffer += delta
|
|
|
|
async def aappend_text(self, delta: str) -> None:
|
|
# Async sibling used by production stream paths.
|
|
self.buffer += delta
|
|
|
|
def status(self, message: str, **_kwargs) -> None:
|
|
self.statuses.append(message)
|
|
|
|
def tool_call(self, *_args, **_kwargs) -> None:
|
|
return None
|
|
|
|
def error(self, message: str) -> None:
|
|
raise AssertionError(f"unexpected error render: {message}")
|
|
|
|
def finalize(self, *_args, **_kwargs) -> None:
|
|
self.finalized = True
|
|
|
|
monkeypatch.setattr(
|
|
"opensquilla.cli.tui.opentui.renderer.OpenTuiStreamRenderer",
|
|
RecordingRenderer,
|
|
)
|
|
monkeypatch.setattr(
|
|
"opensquilla.cli.tui.adapters.turn_stream_defaults.OpenTuiStreamRenderer",
|
|
RecordingRenderer,
|
|
)
|
|
monkeypatch.setattr(
|
|
"opensquilla.cli.tui.adapters.runtime_bridge.validate_tui_backend_selection",
|
|
lambda env=None: "opentui",
|
|
)
|
|
fake = StatusGatewayClient()
|
|
|
|
result = await chat_cmd._stream_response_gateway(
|
|
fake,
|
|
"agent:main:abc123",
|
|
"hello",
|
|
{"mode": None},
|
|
)
|
|
|
|
renderer = RecordingRenderer.instances[-1]
|
|
assert result.text == "answer"
|
|
assert renderer.buffer == "answer"
|
|
assert renderer.finalized is True
|
|
assert len(renderer.statuses) == 3
|
|
assert "waiting" in renderer.statuses[0]
|
|
assert "synthesizing" in renderer.statuses[1]
|
|
assert "complete" in renderer.statuses[2]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_gateway_stream_collects_artifact_events(monkeypatch) -> None:
|
|
artifact = {
|
|
"id": "art-chat",
|
|
"kind": "artifact_ref",
|
|
"name": "report.txt",
|
|
"mime": "text/plain",
|
|
"size": 4,
|
|
"sha256": "e" * 64,
|
|
"session_id": "session-1",
|
|
"session_key": "agent:main:abc123",
|
|
"source": "publish_artifact",
|
|
"created_at": "2026-05-06T12:00:00Z",
|
|
"download_url": "/api/v1/artifacts/art-chat?sessionKey=agent%3Amain%3Aabc123",
|
|
}
|
|
|
|
class ArtifactGatewayClient(_FakeGatewayClient):
|
|
async def send_message(self, session_key, message, attachments=None, elevated=None):
|
|
yield {"event": "session.event.artifact", **artifact}
|
|
yield {"event": "session.event.text_delta", "text": "answer"}
|
|
yield {"event": "session.event.done"}
|
|
|
|
monkeypatch.setattr(
|
|
"opensquilla.cli.tui.opentui.renderer.OpenTuiStreamRenderer",
|
|
_RecordingRenderer,
|
|
)
|
|
monkeypatch.setattr(
|
|
"opensquilla.cli.tui.adapters.turn_stream_defaults.OpenTuiStreamRenderer",
|
|
_RecordingRenderer,
|
|
)
|
|
# Renderer choice is now backend-aware; pin the OpenTUI backend so the
|
|
# recording renderer (patched above) is the one the bridge selects.
|
|
monkeypatch.setattr(
|
|
"opensquilla.cli.tui.adapters.runtime_bridge.validate_tui_backend_selection",
|
|
lambda env=None: "opentui",
|
|
)
|
|
result = await chat_cmd._stream_response_gateway(
|
|
ArtifactGatewayClient(),
|
|
"agent:main:abc123",
|
|
"hello",
|
|
{"mode": None},
|
|
)
|
|
|
|
assert result.text == "answer"
|
|
assert result.artifacts[0]["download_url"] == "/api/v1/artifacts/art-chat"
|
|
assert "session_key" not in result.artifacts[0]
|
|
assert "sessionKey" not in json.dumps(result.artifacts[0])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_gateway_elevated_unknown_prefix_is_not_handled(monkeypatch) -> None:
|
|
_FakeGatewayClient.instances.clear()
|
|
monkeypatch.setattr("opensquilla.cli.gateway_client.GatewayClient", _FakeGatewayClient)
|
|
fake = _FakeGatewayClient()
|
|
state = ChatSessionState(session_key="agent:main:abc123", model="openai/test")
|
|
|
|
handled = await chat_cmd._handle_gateway_slash_command(
|
|
"/elevatedx", state, fake, {"mode": None}
|
|
)
|
|
|
|
assert handled is False
|