chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:12:33 +08:00
commit aacb60a4af
3387 changed files with 981307 additions and 0 deletions
+366
View File
@@ -0,0 +1,366 @@
from __future__ import annotations
import asyncio
import importlib
import inspect
import sys
from typing import Any, cast
import pytest
from opensquilla.cli.repl.session_state import ChatSessionState
from opensquilla.cli.repl.stream import TurnResult, UsageSummary
from opensquilla.cli.tui.contracts import TuiOutputHandle
def test_gateway_runtime_has_no_raw_prompt_application_dependency(monkeypatch) -> None:
monkeypatch.delitem(
sys.modules,
"opensquilla.cli.repl.gateway_runtime",
raising=False,
)
original_import = __import__
blocked_module = "prompt" + "_toolkit"
def _guarded_import(name, globals=None, locals=None, fromlist=(), level=0): # noqa: ANN001
if name == blocked_module or name.startswith(f"{blocked_module}."):
raise AssertionError(f"gateway runtime imported {blocked_module} via {name}")
return original_import(name, globals, locals, fromlist, level)
monkeypatch.setattr("builtins.__import__", _guarded_import)
module = importlib.import_module("opensquilla.cli.repl.gateway_runtime")
source = inspect.getsource(module)
assert "ChatApplication" not in source
def test_gateway_session_context_mirrors_state_to_legacy_scope() -> None:
from opensquilla.cli.repl.gateway_runtime import GatewaySessionContext
state = ChatSessionState(session_key="agent:main:original", model="gateway/original")
context = GatewaySessionContext.create(state)
assert context.scope["session_key"] == "agent:main:original"
assert context.scope["state"] is state
assert context.scope["model"] == "gateway/original"
state.session_key = "agent:main:slash"
state.model = "gateway/slash-model"
context.sync_from_state()
assert context.session_key == "agent:main:slash"
assert context.model == "gateway/slash-model"
assert context.scope["session_key"] == "agent:main:slash"
assert context.scope["model"] == "gateway/slash-model"
@pytest.mark.asyncio
async def test_gateway_runtime_connects_to_configured_gateway_target(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from opensquilla.cli.repl import gateway_runtime
class _FakeGatewayClient:
connected_url: str | None = None
connected_token: str | None = None
closed = False
async def connect(self, url: str, *, token: str | None = None) -> None:
type(self).connected_url = url
type(self).connected_token = token
async def create_session(self, model: str | None = None) -> str:
return "agent:main:new"
async def resolve_session(self, key: str) -> dict[str, str]:
return {"model": "gateway/resolved"}
async def close(self) -> None:
type(self).closed = True
monkeypatch.setattr("opensquilla.cli.gateway_client.GatewayClient", _FakeGatewayClient)
monkeypatch.setenv("OPENSQUILLA_GATEWAY_URL", "http://127.0.0.1:18790")
monkeypatch.setenv("OPENSQUILLA_GATEWAY_TOKEN", "branch-token")
async def fake_run_concurrent_repl(
*,
scope: gateway_runtime.GatewayRuntimeScope,
dispatch,
abort_active_turn=None,
) -> None:
return None
deps = gateway_runtime.GatewayRuntimeDependencies(
stream_response=cast(Any, None),
handle_slash_command=cast(Any, None),
run_input_loop=fake_run_concurrent_repl,
get_tui_output=lambda _scope: None,
is_exit_command=lambda _value: False,
notify=lambda _notice: None,
)
await gateway_runtime.run_gateway_chat(
model=None,
session_id=None,
deps=deps,
)
assert _FakeGatewayClient.connected_url == "ws://127.0.0.1:18790/ws"
assert _FakeGatewayClient.connected_token == "branch-token"
assert _FakeGatewayClient.closed is True
@pytest.mark.asyncio
async def test_gateway_runtime_dispatches_messages_slash_commands_and_exit(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from opensquilla.cli.repl import gateway_runtime
class _FakeGatewayClient:
instances: list[_FakeGatewayClient] = []
def __init__(self) -> None:
self.connected = False
self.closed = False
self.create_calls: list[str | None] = []
self.resolve_calls: list[str] = []
self.abort_calls: list[str] = []
_FakeGatewayClient.instances.append(self)
async def connect(self, url: str, *, token: str | None = None) -> None:
self.connected = True
async def create_session(self, model: str | None = None) -> str:
self.create_calls.append(model)
return "agent:main:new"
async def resolve_session(self, key: str) -> dict[str, str]:
self.resolve_calls.append(key)
return {"model": "gateway/resolved"}
async def abort_session(self, key: str) -> dict[str, object]:
self.abort_calls.append(key)
return {"aborted": True, "key": key}
async def close(self) -> None:
self.closed = True
monkeypatch.setattr("opensquilla.cli.gateway_client.GatewayClient", _FakeGatewayClient)
output = cast(TuiOutputHandle, object())
captured: dict[str, Any] = {}
async def fake_stream_response(
client: object,
session_key: str,
message: str,
elevated_state: dict[str, str | None] | None = None,
attachments: list[dict] | None = None,
*,
tui_output: object | None = None,
) -> TurnResult:
captured["stream"] = {
"client": client,
"session_key": session_key,
"message": message,
"elevated_state": elevated_state,
"attachments": attachments,
"tui_output": tui_output,
}
return TurnResult(
text="assistant reply",
usage=UsageSummary(input_tokens=2, output_tokens=3),
model_after="gateway/after",
)
async def fake_handle_slash_command(
cmd: str,
state: ChatSessionState,
client: object,
elevated_state: dict[str, str | None],
*,
tui_output: object | None = None,
) -> bool:
captured["slash"] = {
"cmd": cmd,
"client": client,
"elevated_state": elevated_state,
"tui_output": tui_output,
}
state.session_key = "agent:main:slash"
state.model = "gateway/slash-model"
return True
async def fake_run_concurrent_repl(
*,
scope: gateway_runtime.GatewayRuntimeScope,
dispatch,
abort_active_turn=None,
) -> None:
captured["initial_scope"] = dict(scope)
captured["abort_active_turn"] = abort_active_turn
assert await dispatch("hello") is True
active_state = cast(ChatSessionState, scope["state"])
captured["state_after_message"] = active_state
assert active_state.model == "gateway/after"
assert active_state.transcript.to_markdown()
assert active_state.usage.input_tokens == 2
assert active_state.usage.output_tokens == 3
assert await dispatch("/reset") is True
captured["scope_after_slash"] = dict(scope)
assert await dispatch("/exit") is False
def fake_get_tui_output(
_scope: gateway_runtime.GatewayRuntimeScope,
) -> TuiOutputHandle | None:
return output
deps = gateway_runtime.GatewayRuntimeDependencies(
stream_response=fake_stream_response,
handle_slash_command=fake_handle_slash_command,
run_input_loop=fake_run_concurrent_repl,
get_tui_output=fake_get_tui_output,
is_exit_command=lambda value: value.strip() == "/exit",
notify=lambda notice: captured.setdefault("notices", []).append(notice),
)
await gateway_runtime.run_gateway_chat(
model="anthropic/claude-sonnet-4",
session_id=None,
deps=deps,
)
client = _FakeGatewayClient.instances[-1]
assert client.connected is True
assert client.closed is True
assert client.create_calls == ["anthropic/claude-sonnet-4"]
assert client.resolve_calls == ["agent:main:new"]
assert captured["abort_active_turn"] is not None
await captured["abort_active_turn"]()
assert client.abort_calls == []
assert captured["initial_scope"]["session_key"] == "agent:main:new"
assert captured["initial_scope"]["model"] == "gateway/resolved"
assert captured["stream"]["client"] is client
assert captured["stream"]["session_key"] == "agent:main:new"
assert captured["stream"]["message"] == "hello"
assert captured["stream"]["elevated_state"] == {"mode": None}
assert captured["stream"]["tui_output"] is output
assert captured["slash"]["cmd"] == "/reset"
assert captured["slash"]["client"] is client
assert captured["slash"]["elevated_state"] == {"mode": None}
assert captured["slash"]["tui_output"] is output
assert captured["scope_after_slash"]["session_key"] == "agent:main:slash"
assert captured["scope_after_slash"]["model"] == "gateway/slash-model"
assert [notice.kind for notice in captured["notices"]] == [
"created",
"model",
"welcome",
"goodbye",
]
@pytest.mark.asyncio
async def test_gateway_abort_targets_active_turn_session_after_session_changes(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from opensquilla.cli.repl import gateway_runtime
class _FakeGatewayClient:
instances: list[_FakeGatewayClient] = []
def __init__(self) -> None:
self.abort_calls: list[str] = []
_FakeGatewayClient.instances.append(self)
async def connect(self, url: str, *, token: str | None = None) -> None:
return None
async def create_session(self, model: str | None = None) -> str:
return "agent:main:old"
async def resolve_session(self, key: str) -> dict[str, str]:
return {"model": "gateway/old"}
async def abort_session(self, key: str) -> dict[str, object]:
self.abort_calls.append(key)
return {"aborted": True, "key": key}
async def close(self) -> None:
return None
monkeypatch.setattr("opensquilla.cli.gateway_client.GatewayClient", _FakeGatewayClient)
stream_started = asyncio.Event()
release_stream = asyncio.Event()
async def fake_stream_response(
_client: object,
session_key: str,
_message: str,
_elevated_state: dict[str, str | None] | None = None,
_attachments: list[dict] | None = None,
*,
tui_output: object | None = None,
) -> TurnResult:
assert session_key == "agent:main:old"
stream_started.set()
await release_stream.wait()
return TurnResult(
text="assistant reply",
usage=UsageSummary(input_tokens=1, output_tokens=1),
model_after="gateway/old",
)
async def fake_handle_slash_command(
_cmd: str,
_state: ChatSessionState,
_client: object,
_elevated_state: dict[str, str | None],
*,
tui_output: object | None = None,
) -> bool:
return True
async def fake_run_concurrent_repl(
*,
scope: gateway_runtime.GatewayRuntimeScope,
dispatch,
abort_active_turn=None,
) -> None:
assert abort_active_turn is not None
turn = asyncio.create_task(dispatch("hello"))
await asyncio.wait_for(stream_started.wait(), timeout=2.0)
active_state = cast(ChatSessionState, scope["state"])
active_state.session_key = "agent:main:new"
active_state.model = "gateway/new"
scope["session_key"] = active_state.session_key
scope["model"] = active_state.model
await abort_active_turn()
release_stream.set()
assert await asyncio.wait_for(turn, timeout=2.0) is True
deps = gateway_runtime.GatewayRuntimeDependencies(
stream_response=fake_stream_response,
handle_slash_command=fake_handle_slash_command,
run_input_loop=fake_run_concurrent_repl,
get_tui_output=lambda _scope: None,
is_exit_command=lambda _value: False,
notify=lambda _notice: None,
)
await gateway_runtime.run_gateway_chat(
model=None,
session_id=None,
deps=deps,
)
client = _FakeGatewayClient.instances[-1]
assert client.abort_calls == ["agent:main:old"]
+136
View File
@@ -0,0 +1,136 @@
from __future__ import annotations
import importlib
import inspect
import sys
from pathlib import Path
from typing import Any
import pytest
def test_input_assets_has_no_raw_prompt_application_dependency(monkeypatch) -> None:
monkeypatch.delitem(
sys.modules,
"opensquilla.cli.repl.input_assets",
raising=False,
)
original_import = __import__
blocked_module = "prompt" + "_toolkit"
def _guarded_import(name, globals=None, locals=None, fromlist=(), level=0): # noqa: ANN001
if name == blocked_module or name.startswith(f"{blocked_module}."):
raise AssertionError(f"input assets imported {blocked_module} via {name}")
return original_import(name, globals, locals, fromlist, level)
monkeypatch.setattr("builtins.__import__", _guarded_import)
module = importlib.import_module("opensquilla.cli.repl.input_assets")
source = inspect.getsource(module)
assert "ChatApplication" not in source
def test_input_assets_wrap_existing_file_and_path_helpers(tmp_path: Path) -> None:
from opensquilla.cli.repl import input_assets
csv_path = tmp_path / "data.csv"
csv_path.write_text("a,b\n1,2\n", encoding="utf-8")
png_path = tmp_path / "screen.png"
png_path.write_bytes(b"\x89PNG\r\n\x1a\npayload")
file_prompt, file_attachments = input_assets.file_prompt_and_attachments(
f"/file {csv_path} summarize",
upload_callable=None,
)
image_prompt, image_attachments = input_assets.image_prompt_and_attachments(
f"/image {png_path} describe",
)
path_prompt, path_attachments = input_assets.path_prompt_and_attachments(
f"/path {csv_path} inspect",
)
assert file_prompt == "summarize"
assert file_attachments[0]["type"] == "text/csv"
assert input_assets.image_prompt_from_command(f"/image {png_path} describe") == "describe"
assert image_prompt == "describe"
assert image_attachments[0]["type"] == "image/png"
assert "inspect" in path_prompt
assert str(csv_path.resolve(strict=False)) in path_prompt
assert path_attachments == []
def test_gateway_slash_adapter_uses_input_bridge_for_path(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from opensquilla.cli.repl import slash_adapter
captured: dict[str, Any] = {}
def fake_path_prompt_and_attachments(command: str) -> tuple[str, list[dict[str, Any]]]:
captured["command"] = command
return "inspect from input assets", []
monkeypatch.setattr(
slash_adapter._input_bridge,
"path_prompt_and_attachments",
fake_path_prompt_and_attachments,
)
prompt, attachments = slash_adapter.path_prompt_and_attachments("/path /repo inspect")
assert captured["command"] == "/path /repo inspect"
assert prompt == "inspect from input assets"
assert attachments == []
def test_turn_bridge_default_image_builder_uses_input_bridge(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from opensquilla.cli.repl import turn_bridge
from opensquilla.cli.tui import turn_stream_defaults
captured: dict[str, str] = {}
def fake_image_prompt_and_attachments(command: str) -> tuple[str, list[dict[str, str]]]:
captured["command"] = command
return "describe via input assets", [{"type": "image/png", "data": "x", "name": "x.png"}]
monkeypatch.setattr(
turn_stream_defaults._input_bridge,
"image_prompt_and_attachments",
fake_image_prompt_and_attachments,
)
prompt, attachments = turn_bridge.image_prompt_and_attachments("/image x.png describe")
assert captured["command"] == "/image x.png describe"
assert prompt == "describe via input assets"
assert attachments == [{"type": "image/png", "data": "x", "name": "x.png"}]
def test_standalone_slash_adapter_uses_input_bridge_for_path(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from opensquilla.cli.repl import standalone_slash_adapter
captured: dict[str, Any] = {}
def fake_path_prompt_and_attachments(command: str) -> tuple[str, list[dict[str, Any]]]:
captured["command"] = command
return "standalone inspect from input bridge", []
monkeypatch.setattr(
standalone_slash_adapter._input_bridge,
"path_prompt_and_attachments",
fake_path_prompt_and_attachments,
)
prompt, attachments = standalone_slash_adapter._path_prompt_and_attachments(
"/path /repo inspect"
)
assert captured["command"] == "/path /repo inspect"
assert prompt == "standalone inspect from input bridge"
assert attachments == []
+151
View File
@@ -0,0 +1,151 @@
from __future__ import annotations
from typing import Any
import pytest
def test_input_bridge_announces_image_attachment_with_supplied_console(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from opensquilla.cli.repl import input_bridge
captured: dict[str, str] = {}
prints: list[str] = []
class FakeConsole:
def print(self, message: str) -> None:
prints.append(message)
def fake_image_prompt_and_attachments(
command: str,
) -> tuple[str, list[dict[str, str]]]:
captured["command"] = command
return (
"describe it",
[{"type": "image/png", "data": "x" * 3072, "name": "screen.png"}],
)
monkeypatch.setattr(
input_bridge._input_assets,
"image_prompt_and_attachments",
fake_image_prompt_and_attachments,
)
prompt, attachments = input_bridge.image_prompt_and_attachments(
"/image screen.png describe it",
output_console=FakeConsole(),
)
assert captured["command"] == "/image screen.png describe it"
assert prompt == "describe it"
assert attachments == [
{"type": "image/png", "data": "x" * 3072, "name": "screen.png"}
]
assert prints == ["[dim]Sending image: screen.png (3KB base64)[/dim]"]
def test_input_bridge_announces_image_attachment_with_default_console(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from opensquilla.cli.repl import input_bridge
prints: list[str] = []
class FakeConsole:
def print(self, message: str) -> None:
prints.append(message)
def fake_image_prompt_and_attachments(
command: str,
) -> tuple[str, list[dict[str, str]]]:
return (
"describe it",
[{"type": "image/png", "data": "x" * 2048, "name": "screen.png"}],
)
monkeypatch.setattr(
input_bridge._input_assets,
"image_prompt_and_attachments",
fake_image_prompt_and_attachments,
)
monkeypatch.setattr(input_bridge, "console", FakeConsole())
prompt, attachments = input_bridge.image_prompt_and_attachments(
"/image screen.png describe it"
)
assert prompt == "describe it"
assert attachments == [
{"type": "image/png", "data": "x" * 2048, "name": "screen.png"}
]
assert prints == ["[dim]Sending image: screen.png (2KB base64)[/dim]"]
def test_input_bridge_does_not_announce_when_image_has_no_attachments(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from opensquilla.cli.repl import input_bridge
prints: list[str] = []
class FakeConsole:
def print(self, message: str) -> None:
prints.append(message)
def fake_image_prompt_and_attachments(
command: str,
) -> tuple[str, list[dict[str, str]]]:
return "describe it", []
monkeypatch.setattr(
input_bridge._input_assets,
"image_prompt_and_attachments",
fake_image_prompt_and_attachments,
)
prompt, attachments = input_bridge.image_prompt_and_attachments(
"/image missing.png describe it",
output_console=FakeConsole(),
)
assert prompt == "describe it"
assert attachments == []
assert prints == []
@pytest.mark.asyncio
async def test_input_bridge_wraps_async_file_prompt_builder(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from opensquilla.cli.repl import input_bridge
captured: dict[str, Any] = {}
async def fake_async_file_prompt_and_attachments(
command: str,
*,
upload_callable: Any | None = None,
) -> tuple[str, list[dict[str, Any]]]:
captured["command"] = command
captured["upload_callable"] = upload_callable
return "summarize", [{"type": "text/plain", "name": "note.txt"}]
monkeypatch.setattr(
input_bridge._input_assets,
"async_file_prompt_and_attachments",
fake_async_file_prompt_and_attachments,
)
def fake_upload(*args: Any) -> str:
return "u-file"
prompt, attachments = await input_bridge.async_file_prompt_and_attachments(
"/file note.txt summarize",
upload_callable=fake_upload,
)
assert captured["command"] == "/file note.txt summarize"
assert captured["upload_callable"] is fake_upload
assert prompt == "summarize"
assert attachments == [{"type": "text/plain", "name": "note.txt"}]
+319
View File
@@ -0,0 +1,319 @@
from __future__ import annotations
import logging
from io import StringIO
from pathlib import Path
from types import SimpleNamespace
from typing import Any
import pytest
import typer
class FakeConsole:
def __init__(self, *, is_terminal: bool = True) -> None:
self.is_terminal = is_terminal
self.clears = 0
self.prints: list[Any] = []
def clear(self) -> None:
self.clears += 1
def print(self, payload: Any) -> None:
self.prints.append(payload)
class FakeTerminalStream(StringIO):
def isatty(self) -> bool:
return True
def test_launch_bridge_prepares_terminal_and_quiets_logs(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from opensquilla.cli.tui.adapters import launch_bridge
calls: list[str] = []
class FakeStdin:
def isatty(self) -> bool:
return True
monkeypatch.setattr(
launch_bridge,
"quiet_logs_for_interactive_chat",
lambda: calls.append("quiet"),
)
console = FakeConsole(is_terminal=True)
launch_bridge.prepare_interactive_chat(
input_stream=FakeStdin(),
output_console=console,
)
assert calls == ["quiet"]
assert console.clears == 1
def test_launch_bridge_routes_interactive_structlog_to_file(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
import structlog
from opensquilla.cli.tui.adapters import launch_bridge
original_config = structlog.get_config()
root = logging.getLogger()
original_root_handlers = list(root.handlers)
original_root_level = root.level
monkeypatch.setenv("OPENSQUILLA_LOG_DIR", str(tmp_path))
monkeypatch.delenv("OPENSQUILLA_LOG_LEVEL", raising=False)
for handler in original_root_handlers:
root.removeHandler(handler)
terminal_stream = FakeTerminalStream()
root.addHandler(logging.StreamHandler(terminal_stream))
try:
launch_bridge.quiet_logs_for_interactive_chat()
structlog.get_logger("opensquilla.test").warning(
"ui.hidden_warning",
answer=42,
)
logging.getLogger("opensquilla.test").warning("ui.hidden_stdlib_warning")
captured = capsys.readouterr()
assert captured.out == ""
assert captured.err == ""
assert terminal_stream.getvalue() == ""
log_text = (tmp_path / "interactive.log").read_text()
assert "ui.hidden_warning" in log_text
assert "ui.hidden_stdlib_warning" in log_text
finally:
for handler in list(root.handlers):
root.removeHandler(handler)
handler.close()
for handler in original_root_handlers:
root.addHandler(handler)
root.setLevel(original_root_level)
handle = getattr(launch_bridge, "_INTERACTIVE_STRUCTLOG_FILE", None)
if handle is not None:
handle.close()
setattr(launch_bridge, "_INTERACTIVE_STRUCTLOG_FILE", None)
structlog.configure(**original_config)
def test_launch_bridge_rejects_non_interactive_input() -> None:
from opensquilla.cli.tui.adapters import launch_bridge
class FakeStdin:
def isatty(self) -> bool:
return False
with pytest.raises(typer.Exit) as exc_info:
launch_bridge.prepare_interactive_chat(
input_stream=FakeStdin(),
output_console=FakeConsole(is_terminal=True),
)
assert exc_info.value.exit_code == 2
def test_launch_bridge_prints_standalone_banner_and_runs_standalone(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from opensquilla.cli.tui.adapters import launch_bridge
calls: list[dict[str, Any]] = []
console = FakeConsole(is_terminal=True)
async def fake_standalone(**kwargs: Any) -> None:
calls.append(kwargs)
monkeypatch.setattr(launch_bridge, "prepare_interactive_chat", lambda **_kwargs: None)
monkeypatch.setattr(launch_bridge, "validate_tui_backend_or_exit", lambda: "native")
launch_bridge.launch_chat(
model="openai/test",
session_id="agent:main:test",
standalone=True,
workspace="repo",
workspace_strict=True,
timeout=7.25,
standalone_runner=fake_standalone,
gateway_runner=None,
output_console=console,
)
assert len(console.prints) == 3
assert "OpenSquilla Chat" in str(console.prints[0].renderable)
assert console.prints[1] == "[dim]Model: openai/test[/dim]"
assert console.prints[2] == "[dim]Session: agent:main:test[/dim]"
assert calls == [
{
"model": "openai/test",
"session_id": "agent:main:test",
"workspace": "repo",
"workspace_strict": True,
"timeout": 7.25,
}
]
def test_launch_bridge_suppresses_native_banner_for_opentui_backend(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# The OpenTUI host draws its own full-screen footer; printing the native
# banner first only makes it flash for ~1s before OpenTUI takes the screen.
from opensquilla.cli.tui.adapters import launch_bridge
calls: list[dict[str, Any]] = []
console = FakeConsole(is_terminal=True)
async def fake_standalone(**kwargs: Any) -> None:
calls.append(kwargs)
monkeypatch.setattr(launch_bridge, "prepare_interactive_chat", lambda **_kwargs: None)
monkeypatch.setattr(launch_bridge, "validate_tui_backend_or_exit", lambda: "opentui")
launch_bridge.launch_chat(
model="openai/test",
session_id="agent:main:test",
standalone=True,
workspace="repo",
workspace_strict=True,
timeout=7.25,
standalone_runner=fake_standalone,
gateway_runner=None,
output_console=console,
)
# No native chrome printed to the main screen, but the runner still launches.
assert console.prints == []
assert len(calls) == 1
assert calls[0]["model"] == "openai/test"
def test_launch_bridge_warns_gateway_workspace_options_without_forwarding(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from opensquilla.cli.tui.adapters import launch_bridge
calls: list[dict[str, Any]] = []
console = FakeConsole(is_terminal=True)
async def fake_gateway(**kwargs: Any) -> None:
calls.append(kwargs)
monkeypatch.setattr(launch_bridge, "prepare_interactive_chat", lambda **_kwargs: None)
launch_bridge.launch_chat(
model="",
session_id="",
standalone=False,
workspace="repo",
workspace_strict=True,
timeout=None,
standalone_runner=None,
gateway_runner=fake_gateway,
output_console=console,
)
assert calls == [{"model": None, "session_id": None}]
assert len(console.prints) == 1
assert "--workspace only affects --standalone chat" in str(console.prints[0])
def test_launch_chat_command_uses_typed_overrides() -> None:
from opensquilla.cli.chat.launch import (
ChatCommandLaunchOverrides,
ChatCommandRequest,
)
from opensquilla.cli.tui.adapters import launch_bridge
calls: list[dict[str, Any]] = []
async def fake_standalone(**kwargs: Any) -> None:
return None
async def fake_gateway(**kwargs: Any) -> None:
return None
def fake_launch_chat(**kwargs: Any) -> None:
calls.append(kwargs)
launch_bridge.launch_chat_command(
ChatCommandRequest(
model="openai/test",
session_id="agent:main:test",
standalone=True,
workspace="repo",
workspace_strict=True,
timeout=7.25,
),
overrides=ChatCommandLaunchOverrides(
launch_chat=fake_launch_chat,
standalone_runner=fake_standalone,
gateway_runner=fake_gateway,
),
)
assert calls == [
{
"model": "openai/test",
"session_id": "agent:main:test",
"standalone": True,
"workspace": "repo",
"workspace_strict": True,
"timeout": 7.25,
"standalone_runner": fake_standalone,
"gateway_runner": fake_gateway,
}
]
def test_launch_chat_command_keeps_legacy_override_mapping() -> None:
from opensquilla.cli.chat.launch import ChatCommandRequest
from opensquilla.cli.tui.adapters import launch_bridge
calls: list[dict[str, Any]] = []
async def fake_standalone(**kwargs: Any) -> None:
return None
async def fake_gateway(**kwargs: Any) -> None:
return None
def fake_launch_chat(**kwargs: Any) -> None:
calls.append(kwargs)
launch_bridge.launch_chat_command(
ChatCommandRequest(
model="openai/test",
session_id="agent:main:test",
standalone=False,
workspace="",
workspace_strict=None,
timeout=None,
),
legacy_overrides={
"_launch_bridge": SimpleNamespace(launch_chat=fake_launch_chat),
"_standalone_repl": fake_standalone,
"_gateway_chat": fake_gateway,
},
)
assert calls == [
{
"model": "openai/test",
"session_id": "agent:main:test",
"standalone": False,
"workspace": "",
"workspace_strict": None,
"timeout": None,
"standalone_runner": fake_standalone,
"gateway_runner": fake_gateway,
}
]
@@ -0,0 +1,280 @@
from __future__ import annotations
import asyncio
import io
import sys
from collections.abc import AsyncIterator, Callable
from contextlib import asynccontextmanager
from types import SimpleNamespace
from typing import Any
import pytest
from opensquilla.engine.commands import Surface
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) -> None:
return None
def stream_output(self):
@asynccontextmanager
async def _cm() -> AsyncIterator[Callable[[str], None]]:
yield lambda _payload: None
return _cm()
class _FakeOpenTuiSurface:
output_handle = _FakeOutputHandle()
def __init__(self) -> None:
self.writes: list[str] = []
async def next_line(self) -> str | None:
return None
def set_cancel_callback(self, cb: Callable[[], None] | None) -> None:
return None
def set_shutdown_callback(self, cb: Callable[[], None] | None) -> None:
return None
def emit_eof(self) -> None:
return None
async def write_through(self, payload: str) -> None:
self.writes.append(payload)
async def send_message(self, message_type: str, payload: dict) -> None:
self.writes.append(f"{message_type}:{payload.get('text', '')}")
@property
def redraw_callback(self) -> Callable[[], None]:
return lambda: None
@pytest.mark.asyncio
async def test_opentui_chat_runtime_exposes_tui_output_and_reuses_runtime(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from opensquilla.cli.tui.opentui import runtime as opentui_runtime
scope: dict[str, Any] = {
"model": "model-a",
"session_key": "session-a",
"tool_ctx": SimpleNamespace(workspace_dir="/tmp/opentui-workspace"),
}
captured: dict[str, Any] = {}
fake_surface = _FakeOpenTuiSurface()
@asynccontextmanager
async def fake_open_opentui_surface(**kwargs: Any):
captured["surface_kwargs"] = kwargs
yield fake_surface
async def fake_run_tui_runtime(**kwargs: Any):
captured["runtime_kwargs"] = kwargs
async with kwargs["surface_factory"]() as yielded:
assert yielded is fake_surface
hooks = kwargs["hooks"]
assert not opentui_runtime.get_tui_output(scope)
hooks.expose_surface(fake_surface)
output = opentui_runtime.get_tui_output(scope)
captured["output"] = output
captured["manager"] = getattr(output, "plugin_manager", None)
hooks.clear_exposed_surface()
return object()
monkeypatch.setattr(opentui_runtime, "open_opentui_surface", fake_open_opentui_surface)
monkeypatch.setattr(opentui_runtime, "run_tui_runtime", fake_run_tui_runtime)
async def fake_dispatch(_value: str) -> bool:
return True
await opentui_runtime.run_opentui_chat_runtime(
surface=Surface.CLI_GATEWAY,
scope=scope,
dispatch=fake_dispatch,
queue_max_size=8,
)
assert captured["surface_kwargs"] == {
"surface": Surface.CLI_GATEWAY,
"model": "model-a",
"session_id": "session-a",
"workspace_dir": "/tmp/opentui-workspace",
}
assert captured["runtime_kwargs"]["dispatch"] is fake_dispatch
assert captured["runtime_kwargs"]["config"].concurrent_input_during_turn is True
assert opentui_runtime.get_tui_output(scope) is None
assert getattr(captured["output"], "_output_handle", None) is fake_surface.output_handle
assert captured["manager"] is not None
@pytest.mark.asyncio
async def test_opentui_chat_runtime_forwards_workspace_dir_from_tool_context(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from opensquilla.cli.tui.opentui import runtime as opentui_runtime
scope: dict[str, Any] = {
"model": "model-a",
"session_key": "session-a",
"tool_ctx": SimpleNamespace(workspace_dir="/tmp/workspace-a"),
}
captured: dict[str, Any] = {}
fake_surface = _FakeOpenTuiSurface()
@asynccontextmanager
async def fake_open_opentui_surface(**kwargs: Any):
captured["surface_kwargs"] = kwargs
yield fake_surface
async def fake_run_tui_runtime(**kwargs: Any):
async with kwargs["surface_factory"]() as yielded:
assert yielded is fake_surface
monkeypatch.setattr(opentui_runtime, "open_opentui_surface", fake_open_opentui_surface)
monkeypatch.setattr(opentui_runtime, "run_tui_runtime", fake_run_tui_runtime)
async def fake_dispatch(_value: str) -> bool:
return True
await opentui_runtime.run_opentui_chat_runtime(
surface=Surface.CLI_GATEWAY,
scope=scope,
dispatch=fake_dispatch,
queue_max_size=8,
)
assert captured["surface_kwargs"]["workspace_dir"] == "/tmp/workspace-a"
@pytest.mark.asyncio
async def test_opentui_chat_runtime_uses_footer_native_echo_hooks(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from opensquilla.cli.tui.adapters import runtime_helpers
from opensquilla.cli.tui.opentui import runtime as opentui_runtime
assert runtime_helpers.classify_chat_input("/help") is not None
scope: dict[str, Any] = {"model": "model-a", "session_key": "session-a"}
fake_surface = _FakeOpenTuiSurface()
@asynccontextmanager
async def fake_open_opentui_surface(**_kwargs: Any):
yield fake_surface
async def fake_run_tui_runtime(**kwargs: Any):
hooks = kwargs["hooks"]
await hooks.on_user_input_echo(fake_surface, "hello opentui")
await hooks.on_user_input_echo(fake_surface, "中文输入 CJK混合ASCII")
await hooks.on_queued_turn_start(fake_surface)
return object()
monkeypatch.setattr(opentui_runtime, "open_opentui_surface", fake_open_opentui_surface)
monkeypatch.setattr(opentui_runtime, "run_tui_runtime", fake_run_tui_runtime)
async def fake_dispatch(_value: str) -> bool:
return True
await opentui_runtime.run_opentui_chat_runtime(
surface=Surface.CLI_GATEWAY,
scope=scope,
dispatch=fake_dispatch,
queue_max_size=8,
)
joined_writes = "".join(fake_surface.writes)
assert "你 / you" not in joined_writes
assert "prompt.echo:hello opentui" in joined_writes
assert "中文输入 CJK混合ASCII" in joined_writes
assert "running queued input" in joined_writes
@pytest.mark.asyncio
async def test_opentui_chat_runtime_reprints_exit_notices_to_real_stderr(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The surface-error notice carries the only copy of a sidecar crash
reason, and the Goodbye notice is emitted with the bridge already doomed.
Both must reach the real terminal stderr after teardown instead of dying
with the captured console."""
from opensquilla.cli.tui.opentui import runtime as opentui_runtime
scope: dict[str, Any] = {"model": "model-a", "session_key": "session-a"}
fake_surface = _FakeOpenTuiSurface()
@asynccontextmanager
async def fake_open_opentui_surface(**_kwargs: Any):
yield fake_surface
async def fake_run_tui_runtime(**kwargs: Any):
hooks = kwargs["hooks"]
hooks.notice("[red]Input surface error: OpenTUI host exited with code 7[/red]")
hooks.notice("[yellow]Goodbye.[/yellow]")
return object()
monkeypatch.setattr(opentui_runtime, "open_opentui_surface", fake_open_opentui_surface)
monkeypatch.setattr(opentui_runtime, "run_tui_runtime", fake_run_tui_runtime)
fake_stderr = io.StringIO()
monkeypatch.setattr(sys, "stderr", fake_stderr)
async def fake_dispatch(_value: str) -> bool:
return True
await opentui_runtime.run_opentui_chat_runtime(
surface=Surface.CLI_GATEWAY,
scope=scope,
dispatch=fake_dispatch,
queue_max_size=8,
)
output = fake_stderr.getvalue()
assert "Input surface error: OpenTUI host exited with code 7" in output
assert "Goodbye" in output
@pytest.mark.asyncio
async def test_forward_console_notice_prunes_completed_pending_tasks() -> None:
"""The session-scoped pending set must shed tasks as their sends finish —
every captured console line schedules one, so retaining completed tasks
grows without bound over a long interactive session."""
from opensquilla.cli.tui.backend.output_binding import TuiOutputBinding
from opensquilla.cli.tui.opentui import runtime as opentui_runtime
scope: dict[str, Any] = {}
TuiOutputBinding(scope).expose(_FakeOutputHandle())
pending: set[asyncio.Task[None]] = set()
for index in range(5):
opentui_runtime.forward_console_notice(scope, f"line-{index}", pending_tasks=pending)
assert len(pending) == 5
await asyncio.gather(*pending)
# Done callbacks run one call_soon hop after completion.
await asyncio.sleep(0)
assert pending == set()
def test_opentui_notice_renders_through_captured_console() -> None:
from opensquilla.cli.tui.opentui.notice_capture import capture_stdout_as_notices
from opensquilla.cli.tui.opentui.runtime import opentui_notice
lines: list[str] = []
# Runtime notices must render as clean styled host notices, never as raw Rich
# markup. With the capture installed, console.print is forwarded line-by-line.
with capture_stdout_as_notices(lines.append):
opentui_notice({}, "[yellow]Hello[/yellow]")
joined = "".join(lines)
assert "Hello" in joined
assert "[yellow]" not in joined # markup must be rendered out, not leaked verbatim
+542
View File
@@ -0,0 +1,542 @@
from __future__ import annotations
from contextlib import asynccontextmanager
from types import SimpleNamespace
from typing import Any, cast
import pytest
from rich.console import Console
from rich.panel import Panel
from opensquilla.cli.repl import gateway_runtime, standalone_runtime
from opensquilla.cli.repl.session_state import ChatSessionState
from opensquilla.cli.repl.stream import TurnResult
from opensquilla.engine.commands import Surface
REMOVED_TEXT_BACKEND = "text" + "ual"
REMOVED_BACKEND_IDS = ["terminal", REMOVED_TEXT_BACKEND, f"live-{REMOVED_TEXT_BACKEND}"]
async def _fake_gateway_stream(*args: Any, **kwargs: Any) -> TurnResult:
return TurnResult(text="gateway")
async def _fake_gateway_slash(*args: Any, **kwargs: Any) -> bool:
return True
async def _fake_standalone_stream(*args: Any, **kwargs: Any) -> TurnResult:
return TurnResult(text="standalone")
def _fake_error_panel(message: str, *, title: str = "Error") -> Panel:
return Panel(message, title=title)
class _FakeStreamOutput:
async def __aenter__(self):
return lambda _payload: None
async def __aexit__(self, exc_type, exc, tb) -> None:
return None
class _FakeOutputHandle:
approval_surface = Surface.CLI_GATEWAY
async def write_through(self, payload: str) -> None:
return None
def stream_output(self) -> _FakeStreamOutput:
return _FakeStreamOutput()
def set_toolbar(self, key: str, value: object | None) -> None:
return None
def invalidate(self) -> None:
return None
class _FakeTuiSurface:
output_handle = _FakeOutputHandle()
async def next_line(self) -> str | None:
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
async def write_through(self, payload: str) -> None:
return None
@property
def redraw_callback(self):
return lambda: None
class _RecordingConsole:
def __init__(self) -> None:
self.printed: list[object] = []
def print(self, value: object) -> None:
self.printed.append(value)
def test_gateway_runtime_notifier_maps_all_notice_kinds() -> None:
from opensquilla.cli.repl import runtime_bridge
output_console = _RecordingConsole()
notify = runtime_bridge._gateway_runtime_notifier(
output_console,
_fake_error_panel,
)
for notice in (
gateway_runtime.GatewayRuntimeNotice(
kind="created",
session_key="agent:main:new",
),
gateway_runtime.GatewayRuntimeNotice(
kind="resumed",
session_key="agent:main:old",
),
gateway_runtime.GatewayRuntimeNotice(kind="resume_model_ignored"),
gateway_runtime.GatewayRuntimeNotice(kind="model", model="openai/test"),
gateway_runtime.GatewayRuntimeNotice(kind="welcome"),
gateway_runtime.GatewayRuntimeNotice(kind="goodbye"),
gateway_runtime.GatewayRuntimeNotice(kind="unknown_command"),
gateway_runtime.GatewayRuntimeNotice(kind="error", message="boom"),
):
notify(notice)
assert output_console.printed[0] == (
"[dim]Connected to gateway. Session: agent:main:new[/dim]"
)
assert output_console.printed[1] == (
"[dim]Connected to gateway. Resuming session: agent:main:old[/dim]"
)
assert output_console.printed[2] == (
"[yellow]Note: --model is honored only at session creation; ignored "
"when resuming a session.[/yellow]"
)
assert output_console.printed[3] == "[dim]Model: openai/test[/dim]"
assert isinstance(output_console.printed[4], Panel)
assert output_console.printed[5] == "[yellow]Goodbye.[/yellow]"
assert output_console.printed[6] == "[red]Unknown command.[/red] [dim]Use /help.[/dim]"
assert isinstance(output_console.printed[7], Panel)
@pytest.mark.asyncio
async def test_gateway_runtime_bridge_assembles_gateway_dependencies(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from opensquilla.cli.repl import runtime_bridge
captured: dict[str, Any] = {}
output_console = _RecordingConsole()
async def fake_run_gateway_chat(**kwargs: Any) -> None:
captured.update(kwargs)
monkeypatch.setattr(gateway_runtime, "run_gateway_chat", fake_run_gateway_chat)
await runtime_bridge.run_gateway_chat(
model="openai/test",
session_id="agent:main:test",
stream_response=_fake_gateway_stream,
handle_slash_command=_fake_gateway_slash,
output_console=output_console,
error_panel_factory=_fake_error_panel,
)
deps = cast(gateway_runtime.GatewayRuntimeDependencies, captured["deps"])
assert captured["model"] == "openai/test"
assert captured["session_id"] == "agent:main:test"
assert deps.stream_response is _fake_gateway_stream
assert deps.handle_slash_command is _fake_gateway_slash
assert callable(deps.run_input_loop)
assert deps.get_tui_output is runtime_bridge.get_tui_output
deps.notify(gateway_runtime.GatewayRuntimeNotice(kind="error", message="boom"))
assert isinstance(output_console.printed[-1], Panel)
@pytest.mark.asyncio
async def test_gateway_runtime_bridge_resolves_default_repl_runner_at_call_time(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from opensquilla.cli.repl import runtime_bridge
captured: dict[str, Any] = {}
async def fake_run_gateway_chat(**kwargs: Any) -> None:
captured.update(kwargs)
runner_kwargs: dict[str, Any] = {}
async def replacement_run_concurrent_repl(**kwargs: Any) -> None:
runner_kwargs.update(kwargs)
return None
async def fake_dispatch(_value: str) -> bool:
return True
async def fake_abort() -> None:
return None
monkeypatch.setattr(gateway_runtime, "run_gateway_chat", fake_run_gateway_chat)
monkeypatch.setattr(runtime_bridge, "run_concurrent_repl", replacement_run_concurrent_repl)
await runtime_bridge.run_gateway_chat(
model=None,
session_id=None,
stream_response=_fake_gateway_stream,
handle_slash_command=_fake_gateway_slash,
)
deps = cast(gateway_runtime.GatewayRuntimeDependencies, captured["deps"])
scope = {
"session_key": "agent:main:test",
"state": ChatSessionState(session_key="agent:main:test"),
"model": None,
}
await deps.run_input_loop(
scope=scope,
dispatch=fake_dispatch,
abort_active_turn=fake_abort,
)
assert runner_kwargs["surface"] is Surface.CLI_GATEWAY
assert runner_kwargs["scope"] is scope
assert runner_kwargs["dispatch"] is fake_dispatch
assert runner_kwargs["abort_active_turn"] is fake_abort
@pytest.mark.asyncio
async def test_gateway_runtime_bridge_owns_default_turn_callbacks(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from opensquilla.cli.repl import runtime_bridge
captured: dict[str, Any] = {}
async def fake_run_gateway_chat(**kwargs: Any) -> None:
captured.update(kwargs)
monkeypatch.setattr(gateway_runtime, "run_gateway_chat", fake_run_gateway_chat)
await runtime_bridge.run_gateway_chat(model=None, session_id=None)
deps = cast(gateway_runtime.GatewayRuntimeDependencies, captured["deps"])
assert deps.stream_response is runtime_bridge.stream_response_gateway
assert deps.handle_slash_command is runtime_bridge.handle_gateway_slash_command
@pytest.mark.asyncio
async def test_run_concurrent_repl_defaults_to_native_bridge(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from opensquilla.cli.repl import runtime_bridge
monkeypatch.delenv("OPENSQUILLA_TUI_BACKEND", raising=False)
calls: list[dict[str, Any]] = []
async def fake_native_repl(**kwargs: Any) -> None:
calls.append(kwargs)
async def fake_dispatch(_value: str) -> bool:
return True
scope: dict[str, Any] = {}
monkeypatch.setattr(runtime_bridge._native_bridge, "run_concurrent_repl", fake_native_repl)
await runtime_bridge.run_concurrent_repl(
surface=Surface.CLI_GATEWAY,
scope=scope,
dispatch=fake_dispatch,
)
assert calls == [
{
"surface": Surface.CLI_GATEWAY,
"scope": scope,
"dispatch": fake_dispatch,
"queue_max_size": runtime_bridge.PENDING_QUEUE_MAX_SIZE,
"abort_active_turn": None,
}
]
@pytest.mark.parametrize("backend_id", REMOVED_BACKEND_IDS)
def test_validate_tui_backend_selection_rejects_removed_backends(
monkeypatch: pytest.MonkeyPatch,
backend_id: str,
) -> None:
from opensquilla.cli.repl import runtime_bridge
monkeypatch.setenv("OPENSQUILLA_TUI_BACKEND", backend_id)
with pytest.raises(ValueError) as exc_info:
runtime_bridge.validate_tui_backend_selection()
assert "Unsupported TUI backend" in str(exc_info.value)
assert backend_id in str(exc_info.value)
@pytest.mark.asyncio
async def test_run_concurrent_repl_uses_opentui_bridge_when_selected(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from opensquilla.cli.repl import runtime_bridge
monkeypatch.setenv("OPENSQUILLA_TUI_BACKEND", "opentui")
calls: list[dict[str, Any]] = []
async def fake_opentui_repl(**kwargs: Any) -> None:
calls.append(kwargs)
async def fake_dispatch(_value: str) -> bool:
return True
scope: dict[str, Any] = {}
monkeypatch.setattr(
runtime_bridge,
"validate_tui_backend_selection",
lambda env=None: "opentui",
)
monkeypatch.setattr(runtime_bridge._opentui_bridge, "run_concurrent_repl", fake_opentui_repl)
await runtime_bridge.run_concurrent_repl(
surface=Surface.CLI_GATEWAY,
scope=scope,
dispatch=fake_dispatch,
queue_max_size=5,
)
assert calls == [
{
"surface": Surface.CLI_GATEWAY,
"scope": scope,
"dispatch": fake_dispatch,
"queue_max_size": 5,
"abort_active_turn": None,
}
]
def test_turn_stream_dependencies_use_native_renderer_by_default(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from opensquilla.cli.repl import runtime_bridge
from opensquilla.cli.tui.native.renderer import NativeStreamRenderer
monkeypatch.delenv("OPENSQUILLA_TUI_BACKEND", raising=False)
deps = runtime_bridge._turn_stream_dependencies()
assert deps.renderer_factory is NativeStreamRenderer
def test_turn_stream_dependencies_use_opentui_renderer_when_selected(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from opensquilla.cli.repl import runtime_bridge
from opensquilla.cli.tui.opentui.renderer import OpenTuiStreamRenderer
monkeypatch.setattr(
runtime_bridge,
"validate_tui_backend_selection",
lambda env=None: "opentui",
)
deps = runtime_bridge._turn_stream_dependencies()
assert deps.renderer_factory is OpenTuiStreamRenderer
@pytest.mark.asyncio
async def test_opentui_chat_runtime_exposes_launch_scoped_plugin_manager(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from opensquilla.cli.tui.backend.plugins import TuiPluginManager
from opensquilla.cli.tui.opentui import runtime as opentui_runtime
from opensquilla.cli.tui.plugins.router_hud import RouterHudPlugin
scope: dict[str, object] = {}
captured: dict[str, object] = {}
fake_surface = _FakeTuiSurface()
@asynccontextmanager
async def fake_open_opentui_surface(**_kwargs: object):
yield fake_surface
async def fake_run_tui_runtime(**kwargs: object):
hooks = kwargs["hooks"]
assert not opentui_runtime.get_tui_output(scope)
hooks.expose_surface(fake_surface)
output = opentui_runtime.get_tui_output(scope)
captured["output"] = output
captured["manager"] = getattr(output, "plugin_manager", None)
hooks.clear_exposed_surface()
return SimpleNamespace()
monkeypatch.setattr(
opentui_runtime,
"open_opentui_surface",
fake_open_opentui_surface,
)
monkeypatch.setattr(opentui_runtime, "run_tui_runtime", fake_run_tui_runtime)
async def fake_dispatch(_value: str) -> bool:
return True
await opentui_runtime.run_opentui_chat_runtime(
surface=Surface.CLI_GATEWAY,
scope=scope,
dispatch=fake_dispatch,
queue_max_size=8,
)
assert opentui_runtime.get_tui_output(scope) is None
manager = captured["manager"]
assert isinstance(manager, TuiPluginManager)
assert any(isinstance(plugin, RouterHudPlugin) for plugin in manager.plugins)
@pytest.mark.asyncio
async def test_gateway_runtime_bridge_threads_stream_override_to_default_slash(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from opensquilla.cli.repl import runtime_bridge
captured: dict[str, Any] = {}
slash_captured: dict[str, Any] = {}
output_console = Console(file=None, force_terminal=False)
async def fake_run_gateway_chat(**kwargs: Any) -> None:
captured.update(kwargs)
async def fake_handle_gateway_slash_command(*args: Any, **kwargs: Any) -> bool:
slash_captured.update({"args": args, **kwargs})
return True
monkeypatch.setattr(gateway_runtime, "run_gateway_chat", fake_run_gateway_chat)
monkeypatch.setattr(
runtime_bridge._slash_bridge,
"handle_gateway_slash_command",
fake_handle_gateway_slash_command,
)
await runtime_bridge.run_gateway_chat(
model=None,
session_id=None,
stream_response=_fake_gateway_stream,
output_console=output_console,
error_panel_factory=_fake_error_panel,
)
deps = cast(gateway_runtime.GatewayRuntimeDependencies, captured["deps"])
handled = await deps.handle_slash_command(
"/path report.md summarize",
ChatSessionState(session_key="agent:main:test"),
cast(gateway_runtime.GatewayClientLike, object()),
{"mode": None},
tui_output=None,
)
assert handled is True
assert slash_captured["stream_response"] is _fake_gateway_stream
assert slash_captured["output_console"] is output_console
assert slash_captured["error_panel_factory"] is _fake_error_panel
@pytest.mark.asyncio
async def test_standalone_runtime_bridge_assembles_standalone_dependencies(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from opensquilla.cli.repl import runtime_bridge
captured: dict[str, Any] = {}
output_console = Console(file=None, force_terminal=False)
async def fake_run_standalone_chat(**kwargs: Any) -> None:
captured.update(kwargs)
monkeypatch.setattr(standalone_runtime, "run_standalone_chat", fake_run_standalone_chat)
await runtime_bridge.run_standalone_chat(
model="openai/test",
session_id="agent:main:test",
workspace="repo",
workspace_strict=True,
timeout=7.25,
stream_response=_fake_standalone_stream,
image_command_handler=_fake_standalone_stream,
output_console=output_console,
error_panel_factory=_fake_error_panel,
)
deps = cast(standalone_runtime.StandaloneRuntimeDependencies, captured["deps"])
assert captured["model"] == "openai/test"
assert captured["session_id"] == "agent:main:test"
assert captured["workspace"] == "repo"
assert captured["workspace_strict"] is True
assert captured["timeout"] == 7.25
assert deps.stream_response is _fake_standalone_stream
assert deps.image_command_handler is _fake_standalone_stream
assert deps.run_concurrent_repl is runtime_bridge.run_concurrent_repl
assert deps.slash_services_factory is runtime_bridge.standalone_slash_services_from_runtime
assert deps.get_tui_output is runtime_bridge.get_tui_output
assert deps.output_console is output_console
@pytest.mark.asyncio
async def test_standalone_runtime_bridge_resolves_default_repl_runner_at_call_time(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from opensquilla.cli.repl import runtime_bridge
captured: dict[str, Any] = {}
async def fake_run_standalone_chat(**kwargs: Any) -> None:
captured.update(kwargs)
async def replacement_run_concurrent_repl(**kwargs: Any) -> None:
return None
monkeypatch.setattr(standalone_runtime, "run_standalone_chat", fake_run_standalone_chat)
monkeypatch.setattr(runtime_bridge, "run_concurrent_repl", replacement_run_concurrent_repl)
await runtime_bridge.run_standalone_chat(
model=None,
session_id=None,
stream_response=_fake_standalone_stream,
image_command_handler=_fake_standalone_stream,
)
deps = cast(standalone_runtime.StandaloneRuntimeDependencies, captured["deps"])
assert deps.run_concurrent_repl is replacement_run_concurrent_repl
@pytest.mark.asyncio
async def test_standalone_runtime_bridge_owns_default_turn_callbacks(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from opensquilla.cli.repl import runtime_bridge
captured: dict[str, Any] = {}
async def fake_run_standalone_chat(**kwargs: Any) -> None:
captured.update(kwargs)
monkeypatch.setattr(standalone_runtime, "run_standalone_chat", fake_run_standalone_chat)
await runtime_bridge.run_standalone_chat(model=None, session_id=None)
deps = cast(standalone_runtime.StandaloneRuntimeDependencies, captured["deps"])
assert deps.stream_response is runtime_bridge.stream_response_turnrunner
assert deps.image_command_handler is runtime_bridge.handle_image_command_turnrunner
+205
View File
@@ -0,0 +1,205 @@
from __future__ import annotations
from collections.abc import AsyncIterator
from pathlib import Path
from types import SimpleNamespace
from typing import Any
import pytest
from opensquilla.cli.repl.session_state import ChatSessionState
from opensquilla.cli.repl.stream import TurnResult
from opensquilla.cli.tui.contracts import TuiOutputHandle
class _FakeGatewayClient:
def __init__(self) -> None:
self.reset_calls: list[str] = []
async def create_session(
self,
agent_id: str = "main",
model: str | None = None,
display_name: str | None = None,
) -> str:
raise AssertionError("create_session is not used by these tests")
async def list_sessions(self, limit: int = 50) -> dict[str, Any]:
raise AssertionError("list_sessions is not used by these tests")
async def resolve_session(self, key: str) -> dict[str, Any]:
raise AssertionError("resolve_session is not used by these tests")
async def delete_sessions(self, keys: list[str]) -> dict[str, Any]:
raise AssertionError("delete_sessions is not used by these tests")
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, key: str) -> dict[str, Any]:
raise AssertionError("compact_session is not used by these tests")
async def list_models(
self,
provider: str | None = None,
capabilities: list[str] | None = None,
) -> list[dict[str, Any]]:
raise AssertionError("list_models is not used by these tests")
async def patch_session(self, key: str, **fields: Any) -> dict[str, Any]:
raise AssertionError("patch_session is not used by these tests")
async def usage_status(self) -> dict[str, Any]:
raise AssertionError("usage_status is not used by these tests")
async def upload_file(self, path: Path, mime: str, name: str) -> str:
raise AssertionError("upload_file is not used by these tests")
def send_message(
self,
session_key: str,
message: str,
attachments: list[dict] | None = None,
elevated: str | None = None,
) -> AsyncIterator[dict[str, Any]]:
async def _unused_events() -> AsyncIterator[dict[str, Any]]:
raise AssertionError("send_message is not used by these tests")
yield {}
return _unused_events()
async def resolve_approval(
self,
approval_id: str,
approved: bool,
*,
choice: str | None = None,
) -> Any:
del choice
raise AssertionError("resolve_approval is not used by these tests")
async def abort_session(self, key: str) -> dict[str, Any]:
raise AssertionError("abort_session is not used by these tests")
class _RecordingOutputHandle:
@property
def approval_surface(self) -> object:
return "approval-surface"
async def write_through(self, payload: str) -> None:
return None
def stream_output(self):
raise AssertionError("stream_output is only consumed by the renderer")
def test_gateway_slash_adapter_exposes_typed_context() -> None:
from opensquilla.cli.repl.slash_adapter import GatewaySlashContext
tui_output = _RecordingOutputHandle()
context = GatewaySlashContext(
state=ChatSessionState(session_key="agent:main:test", model="openai/test"),
client=_FakeGatewayClient(),
elevated_state={"mode": None},
tui_output=tui_output,
)
assert isinstance(context.tui_output, TuiOutputHandle)
@pytest.mark.asyncio
async def test_gateway_slash_adapter_handles_clear_without_chat_command_state() -> None:
from opensquilla.cli.repl.slash_adapter import (
GatewaySlashContext,
handle_gateway_slash_command,
)
state = ChatSessionState(session_key="agent:main:test", model="openai/test")
state.transcript.add("user", "hello")
client = _FakeGatewayClient()
handled = await handle_gateway_slash_command(
"/clear",
GatewaySlashContext(
state=state,
client=client,
elevated_state={"mode": None},
),
)
assert handled is True
assert client.reset_calls == ["agent:main:test"]
assert state.transcript.turns == []
@pytest.mark.asyncio
async def test_gateway_slash_adapter_threads_tui_output_to_streaming_slash_command(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from opensquilla.cli.repl import slash_adapter
from opensquilla.cli.repl.slash_adapter import (
GatewaySlashContext,
handle_gateway_slash_command,
)
state = ChatSessionState(session_key="agent:main:test", model="openai/test")
client = SimpleNamespace(is_local_gateway=True)
tui_output = _RecordingOutputHandle()
captured: dict[str, Any] = {}
def fake_path_prompt_and_attachments(command: str) -> tuple[str, list[dict[str, Any]]]:
captured["path_command"] = command
return "inspect /repo", []
async def fake_stream_response_gateway(
gateway_client: object,
session_key: str,
message: str,
elevated_state: dict[str, str | None],
*,
attachments: list[dict[str, Any]] | None = None,
tui_output: TuiOutputHandle | None = None,
) -> TurnResult:
captured.update(
{
"client": gateway_client,
"session_key": session_key,
"message": message,
"elevated_state": elevated_state,
"attachments": attachments,
"tui_output": tui_output,
}
)
return TurnResult(text="done")
monkeypatch.setattr(
slash_adapter,
"path_prompt_and_attachments",
fake_path_prompt_and_attachments,
)
monkeypatch.setattr(
slash_adapter,
"stream_response_gateway",
fake_stream_response_gateway,
)
handled = await handle_gateway_slash_command(
"/path /repo inspect",
GatewaySlashContext(
state=state,
client=client,
elevated_state={"mode": "on"},
tui_output=tui_output,
),
)
assert handled is True
assert captured["path_command"] == "/path /repo inspect"
assert captured["session_key"] == "agent:main:test"
assert captured["message"] == "inspect /repo"
assert captured["elevated_state"] == {"mode": "on"}
assert captured["attachments"] == []
assert captured["tui_output"] is tui_output
assert state.transcript.to_markdown()
+60
View File
@@ -0,0 +1,60 @@
from __future__ import annotations
from typing import Any, cast
import pytest
from rich.console import Console
from rich.panel import Panel
from opensquilla.cli.repl.session_state import ChatSessionState
from opensquilla.cli.repl.stream import TurnResult
@pytest.mark.asyncio
async def test_gateway_slash_bridge_syncs_io_and_builds_context(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from opensquilla.cli.repl import slash_adapter, slash_bridge
from opensquilla.cli.repl.slash_adapter import GatewaySlashContext
output_console = Console(file=None, force_terminal=False)
observed: dict[str, Any] = {}
async def fake_handle(cmd: str, context: GatewaySlashContext) -> bool:
observed["cmd"] = cmd
observed["context"] = context
observed["adapter_console"] = slash_adapter.console
observed["adapter_error_panel"] = slash_adapter.error_panel
return True
async def fake_stream(*args: Any, **kwargs: Any) -> TurnResult:
return TurnResult(text="streamed")
def fake_error_panel(message: str, *, title: str = "Error") -> Panel:
return Panel(message, title=title)
monkeypatch.setattr(slash_adapter, "handle_gateway_slash_command", fake_handle)
state = ChatSessionState(session_key="agent:main:test", model="openai/test")
client = cast(slash_bridge.GatewayClientLike, object())
elevated_state: dict[str, str | None] = {"mode": "enabled"}
handled = await slash_bridge.handle_gateway_slash_command(
"/status",
state,
client,
elevated_state,
stream_response=fake_stream,
output_console=output_console,
error_panel_factory=fake_error_panel,
)
context = observed["context"]
assert handled is True
assert observed["cmd"] == "/status"
assert context.state is state
assert context.client is client
assert context.elevated_state is elevated_state
assert context.stream_response is fake_stream
assert observed["adapter_console"] is output_console
assert observed["adapter_error_panel"] is fake_error_panel
+288
View File
@@ -0,0 +1,288 @@
"""Slash-command classification table.
The concurrent REPL spawns each user input as a child turn task while the
input task keeps accepting keystrokes. When new input arrives mid-turn,
the policy split routes the command by category:
* ``DESTRUCTIVE`` (``/clear`` / ``/reset`` / ``/compact``) — purge the
pending queue, cancel the active turn, then run synchronously.
* ``EXIT`` (``/exit`` / ``/quit``) — drain the pending queue then exit
the loop (mirroring Ctrl-D semantics).
* ``PURE_INFO`` / ``STATE_MUTATION`` — both enqueue identically.
* ``NON_SLASH`` — runs as a normal turn.
These tests pin the classification surface so the runtime split in
``chat_cmd._run_concurrent_repl`` can rely on it.
"""
from __future__ import annotations
import pytest
from opensquilla.cli.repl.slash_policy import (
DESTRUCTIVE_SLASH_WORDS,
EXIT_SLASH_WORDS,
SlashCategory,
classify,
)
# --------------------------------------------------------------------------- #
# Destructive set #
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize(
"command",
[
"/clear",
"/reset",
"/compact",
"/cmp",
"/clear ",
],
)
def test_classify_destructive(command: str) -> None:
"""Exact bare destructive words return DESTRUCTIVE.
Only the exact bare lowercase word qualifies: the slash handlers match
exact strings, so anything else must not purge queued work and then
fall through to "Unknown command".
"""
assert classify(command) is SlashCategory.DESTRUCTIVE
@pytest.mark.parametrize(
"command",
[
"/reset trailing-junk",
"/compact extra args",
"/CLEAR",
"/Clear",
],
)
def test_classify_destructive_requires_exact_bare_word(command: str) -> None:
"""Case slips and stray arguments never classify as DESTRUCTIVE.
Destructive routing purges the pending queue and cancels the in-flight
turn BEFORE dispatch, while the handlers only match the exact bare
lowercase word — so these variants must enqueue instead, letting the
handler chain surface "Unknown command" without destroying work.
"""
assert classify(command) is not SlashCategory.DESTRUCTIVE
def test_destructive_set_matches_plan_lock() -> None:
"""The destructive set is locked to exactly these commands.
The destructive set is closed; any future addition needs a
plan amendment. This test pins the frozenset contents so a silent
expansion fails loudly. ``/cmp`` is the ``/compact`` alias and shares its
context-rewriting (and therefore destructive) semantics.
"""
assert DESTRUCTIVE_SLASH_WORDS == frozenset({"/clear", "/reset", "/compact", "/cmp"})
# --------------------------------------------------------------------------- #
# Exit set #
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize(
"command",
[
"/exit",
"/quit",
"/exit ",
],
)
def test_classify_exit(command: str) -> None:
"""Exact bare exit words return EXIT.
``/exit`` and ``/quit`` are NOT destructive — they drain the pending
queue first so queued user work still runs.
"""
assert classify(command) is SlashCategory.EXIT
@pytest.mark.parametrize("command", ["/quit now", "/Exit", "/EXIT stuff"])
def test_classify_exit_requires_exact_bare_word(command: str) -> None:
"""Case slips and stray arguments never classify as EXIT.
EXIT drains the queue and terminates the loop before dispatch; a
variant the handlers would reject must enqueue instead.
"""
assert classify(command) is not SlashCategory.EXIT
def test_exit_set_matches_plan_lock() -> None:
"""The exit set is locked to exactly ``/exit`` and ``/quit``."""
assert EXIT_SLASH_WORDS == frozenset({"/exit", "/quit"})
# --------------------------------------------------------------------------- #
# Enqueue set (pure-info and state-mutation — both enqueue identically) #
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize(
"command",
[
"/help",
"/version",
"/cost",
"/usage",
"/save",
"/approvals",
"/permissions",
"/forget",
"/sessions",
"/resume some-id",
"/delete other-id",
"/file /tmp/path.txt",
"/new",
"/model gpt-5",
"/image /tmp/pic.png",
"/path /tmp/file.md",
"/models",
"/status",
"/session",
],
)
def test_classify_pure_info_or_state_mutation(command: str) -> None:
"""Pure-info and state-mutation commands both enqueue.
The two enqueue subcategories share the same runtime behavior (append
to pending FIFO, run after current turn finishes). The classifier
reports the more specific category so callers that want telemetry
differentiation can subset, but the dispatch loop never branches on
this distinction.
"""
category = classify(command)
assert category in {SlashCategory.PURE_INFO, SlashCategory.STATE_MUTATION}
# Sanity: must NOT be destructive / exit / non-slash for this set.
assert category is not SlashCategory.DESTRUCTIVE
assert category is not SlashCategory.EXIT
assert category is not SlashCategory.NON_SLASH
# --------------------------------------------------------------------------- #
# Non-slash and edge cases #
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize(
"command",
[
"hello world",
"what is the capital of France?",
" multi-word user prompt ",
"/", # bare slash with no command word — not a slash command yet
],
)
def test_classify_non_slash(command: str) -> None:
"""Non-slash inputs return NON_SLASH and run as a normal turn.
A bare ``/`` with no following character is not a slash command — the
head word is just ``/`` which is not in any of the explicit sets and
does not start with a recognized slash word; it falls through to the
enqueue path. (This is an internal detail; see the docstring of
``test_classify_unknown_slash_is_enqueue`` for the unknown-slash
contract.)
"""
# The bare `/` case actually starts with `/` so it'll be treated as an
# unknown slash word (enqueue) under the locked policy. Skip it from the
# strict NON_SLASH assertion and assert on the others.
if command.strip() == "/":
category = classify(command)
assert category is not SlashCategory.DESTRUCTIVE
assert category is not SlashCategory.EXIT
return
assert classify(command) is SlashCategory.NON_SLASH
def test_classify_empty_input_is_non_slash() -> None:
"""Empty input maps to NON_SLASH; the dispatch loop ignores it."""
assert classify("") is SlashCategory.NON_SLASH
assert classify(" ") is SlashCategory.NON_SLASH
@pytest.mark.parametrize(
"command",
[
" /clear ",
"\t/reset",
],
)
def test_classify_handles_leading_whitespace(command: str) -> None:
"""Surrounding whitespace must not change classification.
Users typing into the REPL may have trailing or leading spaces from a
history edit; the classifier strips before matching the bare word.
"""
assert classify(command) is SlashCategory.DESTRUCTIVE
def test_classify_destructive_and_exit_are_case_sensitive() -> None:
"""DESTRUCTIVE/EXIT match only the exact lowercase spelling.
The handlers match exact lowercase strings, so ``/CLEAR`` must not
purge the queue and cancel the turn only to land on "Unknown
command". Enqueue categories keep matching case-insensitively via the
lowercased head word.
"""
assert classify("/CLEAR") is not SlashCategory.DESTRUCTIVE
assert classify("/Exit") is not SlashCategory.EXIT
assert classify("/Help") in {SlashCategory.PURE_INFO, SlashCategory.STATE_MUTATION}
def test_classify_unknown_slash_is_enqueue() -> None:
"""Unknown slash words fall through to an enqueue category.
The destructive set is explicitly closed (``/clear``,
``/reset``, ``/compact`` only); anything else starting with ``/`` and
not in the exit set MUST NOT cancel the active turn. The chosen
behavior is to route through the enqueue path so the existing slash-
handler chain surfaces the canonical
``"Unknown command. Use /help."`` notice without disturbing the
in-flight turn. This locks the safe default.
"""
category = classify("/foobar")
assert category is not SlashCategory.DESTRUCTIVE
assert category is not SlashCategory.EXIT
assert category is not SlashCategory.NON_SLASH
# Documented choice: route through the enqueue path.
assert category in {SlashCategory.PURE_INFO, SlashCategory.STATE_MUTATION}
# --------------------------------------------------------------------------- #
# Local (host-only UI) set #
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize("command", ["/theme", "/theme midnight", "/theme ", "/THEME"])
def test_classify_local_theme(command: str) -> None:
"""/theme is a host-only UI command -> LOCAL.
LOCAL commands run immediately (inline on the runtime loop), are never echoed
as a prompt block, and are never queued behind an in-flight turn.
"""
assert classify(command) is SlashCategory.LOCAL
def test_local_set_is_narrow_and_disjoint() -> None:
from opensquilla.cli.repl.slash_policy import (
LOCAL_SLASH_WORDS,
PURE_INFO_SLASH_WORDS,
STATE_MUTATION_SLASH_WORDS,
)
# Keep LOCAL narrow: only side-effect-free host commands belong here today.
assert LOCAL_SLASH_WORDS == {"/theme"}
# LOCAL must not overlap any queue/cancel/exit category.
for other in (
DESTRUCTIVE_SLASH_WORDS,
EXIT_SLASH_WORDS,
PURE_INFO_SLASH_WORDS,
STATE_MUTATION_SLASH_WORDS,
):
assert not (LOCAL_SLASH_WORDS & other)
@@ -0,0 +1,249 @@
from __future__ import annotations
import importlib
import inspect
import sys
from types import SimpleNamespace
from typing import Any, cast
import pytest
from opensquilla.cli.repl import standalone_slash_adapter
from opensquilla.cli.repl.session_state import ChatSessionState
from opensquilla.cli.repl.stream import TurnResult, UsageSummary
from opensquilla.cli.tui.contracts import TuiOutputHandle
from opensquilla.engine.commands import Surface
def test_standalone_runtime_has_no_raw_prompt_application_dependency(monkeypatch) -> None:
monkeypatch.delitem(
sys.modules,
"opensquilla.cli.repl.standalone_runtime",
raising=False,
)
original_import = __import__
blocked_module = "prompt" + "_toolkit"
def _guarded_import(name, globals=None, locals=None, fromlist=(), level=0): # noqa: ANN001
if name == blocked_module or name.startswith(f"{blocked_module}."):
raise AssertionError(f"standalone runtime imported {blocked_module} via {name}")
return original_import(name, globals, locals, fromlist, level)
monkeypatch.setattr("builtins.__import__", _guarded_import)
module = importlib.import_module("opensquilla.cli.repl.standalone_runtime")
source = inspect.getsource(module)
assert "ChatApplication" not in source
def test_standalone_session_context_mirrors_state_to_legacy_scope() -> None:
from opensquilla.cli.repl.standalone_runtime import StandaloneSessionContext
original_tool_ctx = object()
replacement_tool_ctx = object()
state = ChatSessionState(
session_key="agent:main:standalone:original",
model="standalone/original",
)
replacement_state = ChatSessionState(
session_key="agent:main:standalone:replacement",
model="standalone/replacement",
)
context = StandaloneSessionContext.create(state=state, tool_ctx=original_tool_ctx)
assert context.scope["session_key"] == "agent:main:standalone:original"
assert context.scope["tool_ctx"] is original_tool_ctx
assert context.scope["state"] is state
assert context.scope["model"] == "standalone/original"
context.replace_session(
session_key="agent:main:standalone:replacement",
tool_ctx=replacement_tool_ctx,
state=replacement_state,
model="standalone/replacement",
)
assert context.session_key == "agent:main:standalone:replacement"
assert context.tool_ctx is replacement_tool_ctx
assert context.model == "standalone/replacement"
assert context.scope["session_key"] == "agent:main:standalone:replacement"
assert context.scope["tool_ctx"] is replacement_tool_ctx
assert context.scope["state"] is replacement_state
assert context.scope["model"] == "standalone/replacement"
@pytest.mark.asyncio
async def test_standalone_runtime_mirrors_turn_model_update_to_legacy_scope(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from opensquilla.cli.repl import standalone_runtime
class _FakeSessionManager:
async def get_or_create(self, session_key: str, agent_id: str = "main") -> object:
return SimpleNamespace(session_key=session_key, agent_id=agent_id)
class _FakeServices:
def __init__(self) -> None:
self.config = None
self.session_manager = _FakeSessionManager()
async def close(self) -> None:
return None
services = _FakeServices()
turn_runner = object()
output = cast(TuiOutputHandle, object())
pending_provider = SimpleNamespace(drain_pending=lambda: [])
captured: dict[str, Any] = {}
async def fake_build_services() -> _FakeServices:
return services
def fake_build_turn_runner_from_services(_services: object) -> object:
return turn_runner
async def fake_stream_response(
active_turn_runner: object,
session_key: str,
tool_ctx: object,
message: str,
model: str | None = None,
svc: object = None,
timeout: float | None = None,
*,
tui_output: TuiOutputHandle | None = None,
pending_input_provider: object | None = None,
) -> TurnResult:
captured["stream"] = {
"turn_runner": active_turn_runner,
"session_key": session_key,
"tool_ctx": tool_ctx,
"message": message,
"model": model,
"svc": svc,
"timeout": timeout,
"tui_output": tui_output,
"pending_input_provider": pending_input_provider,
}
return TurnResult(
text="assistant reply",
usage=UsageSummary(input_tokens=5, output_tokens=8),
model_after="standalone/after",
)
async def fake_run_concurrent_repl(
*,
surface: Surface,
scope: standalone_runtime.StandaloneRuntimeScope,
dispatch,
) -> None:
captured["surface"] = surface
captured["initial_scope"] = dict(scope)
scope["pending_input_provider"] = pending_provider
assert await dispatch("hello") is True
captured["scope_after_message"] = dict(scope)
captured["state_after_message"] = scope["state"]
monkeypatch.setattr("opensquilla.gateway.build_services", fake_build_services)
monkeypatch.setattr(
"opensquilla.gateway.build_turn_runner_from_services",
fake_build_turn_runner_from_services,
)
deps = standalone_runtime.StandaloneRuntimeDependencies(
stream_response=fake_stream_response,
image_command_handler=fake_stream_response,
run_concurrent_repl=fake_run_concurrent_repl,
slash_services_factory=lambda _svc: standalone_slash_adapter.StandaloneSlashServices(),
sync_slash_adapter_io=lambda: None,
get_tui_output=lambda _scope: output,
)
await standalone_runtime.run_standalone_chat(
model="openrouter/test",
session_id="agent:main:standalone:test",
deps=deps,
timeout=7.25,
)
assert captured["surface"] is Surface.CLI_STANDALONE
assert captured["initial_scope"]["session_key"] == "agent:main:standalone:test"
assert captured["initial_scope"]["model"] == "openrouter/test"
assert captured["stream"]["turn_runner"] is turn_runner
assert captured["stream"]["session_key"] == "agent:main:standalone:test"
assert captured["stream"]["message"] == "hello"
assert captured["stream"]["model"] == "openrouter/test"
assert captured["stream"]["svc"] is services
assert captured["stream"]["timeout"] == 7.25
assert captured["stream"]["tui_output"] is output
assert captured["stream"]["pending_input_provider"] is pending_provider
assert captured["scope_after_message"]["model"] == "standalone/after"
assert captured["state_after_message"].model == "standalone/after"
assert captured["state_after_message"].usage.input_tokens == 5
assert captured["state_after_message"].usage.output_tokens == 8
@pytest.mark.asyncio
async def test_standalone_runtime_matches_exit_with_standalone_surface(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from opensquilla.cli.repl import standalone_runtime
class _FakeSessionManager:
async def get_or_create(self, session_key: str, agent_id: str = "main") -> object:
return SimpleNamespace(session_key=session_key, agent_id=agent_id)
class _FakeServices:
def __init__(self) -> None:
self.config = None
self.session_manager = _FakeSessionManager()
async def close(self) -> None:
return None
surfaces: list[Surface] = []
def fake_is_exit_command(value: str, surface: Surface) -> bool:
surfaces.append(surface)
return value == "/exit"
async def fake_build_services() -> _FakeServices:
return _FakeServices()
async def fake_run_concurrent_repl(
*,
surface: Surface,
scope: standalone_runtime.StandaloneRuntimeScope,
dispatch,
) -> None:
assert surface is Surface.CLI_STANDALONE
assert await dispatch("/exit") is False
monkeypatch.setattr("opensquilla.gateway.build_services", fake_build_services)
monkeypatch.setattr(
"opensquilla.gateway.build_turn_runner_from_services",
lambda _services: object(),
)
monkeypatch.setattr(standalone_runtime, "is_exit_command", fake_is_exit_command)
deps = standalone_runtime.StandaloneRuntimeDependencies(
stream_response=lambda *args, **kwargs: None, # type: ignore[arg-type]
image_command_handler=lambda *args, **kwargs: None, # type: ignore[arg-type]
run_concurrent_repl=fake_run_concurrent_repl,
slash_services_factory=lambda _svc: standalone_slash_adapter.StandaloneSlashServices(),
sync_slash_adapter_io=lambda: None,
get_tui_output=lambda _scope: None,
)
await standalone_runtime.run_standalone_chat(
model=None,
session_id="agent:main:standalone:test",
deps=deps,
)
assert surfaces == [Surface.CLI_STANDALONE]
@@ -0,0 +1,323 @@
from __future__ import annotations
from types import SimpleNamespace
from typing import Any, cast
import pytest
from opensquilla.cli.repl.session_state import ChatSessionState
from opensquilla.cli.repl.stream import TurnResult
from opensquilla.cli.tui.contracts import TuiOutputHandle
class _StandaloneSlashHarness:
def __init__(self) -> None:
self.create_calls: list[dict[str, str]] = []
self.truncate_calls: list[tuple[str, int]] = []
self.compact_calls: list[tuple[str, int, object | None]] = []
self.flush_calls: list[dict[str, object]] = []
self.transcripts: dict[str, list[object]] = {}
async def create_session(self, session_key: str, *, agent_id: str = "main") -> object:
self.create_calls.append({"session_key": session_key, "agent_id": agent_id})
return SimpleNamespace(session_key=session_key, agent_id=agent_id)
async def read_transcript(self, session_key: str) -> list[object]:
return list(self.transcripts.get(session_key, []))
async def truncate_session(self, session_key: str, *, max_messages: int = 0) -> None:
self.truncate_calls.append((session_key, max_messages))
self.transcripts[session_key] = []
async def compact_session(
self,
session_key: str,
context_window_tokens: int,
config: object | None = None,
) -> str:
self.compact_calls.append((session_key, context_window_tokens, config))
return "summary"
async def flush_transcript(
self,
transcript: object,
session_key: str,
**kwargs: object,
) -> object:
self.flush_calls.append(
{"transcript": transcript, "session_key": session_key, "kwargs": kwargs}
)
return 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=[],
)
def _slash_services(harness: _StandaloneSlashHarness):
from opensquilla.cli.repl.standalone_slash_adapter import StandaloneSlashServices
return StandaloneSlashServices(
create_session=harness.create_session,
read_transcript=harness.read_transcript,
truncate_session=harness.truncate_session,
compact_session=harness.compact_session,
flush_transcript=harness.flush_transcript,
)
@pytest.mark.asyncio
async def test_standalone_slash_adapter_leaves_exit_to_the_runtime_loop(
capsys: pytest.CaptureFixture[str],
) -> None:
"""Exit commands are owned by the runtime loop, not the slash handler.
The handler has no exit branch: an ``/exit`` that reaches it anyway is
answered with the unknown-command notice and ``True`` (handled), so
the dispatch loop keeps running instead of terminating from inside
slash dispatch.
"""
from opensquilla.cli.repl.standalone_slash_adapter import (
StandaloneSlashContext,
handle_standalone_slash_command,
)
state = ChatSessionState(session_key="agent:main:standalone:test", model="openai/test")
context = StandaloneSlashContext(
state=state,
session_key=state.session_key,
model=state.model,
tool_ctx=object(),
slash_services=_slash_services(_StandaloneSlashHarness()),
turn_runner=object(),
build_tool_ctx=lambda _session_key: object(),
replace_session=lambda **_updates: None,
)
handled = await handle_standalone_slash_command("/exit", context)
assert handled is True
assert "Unknown command." in capsys.readouterr().out
@pytest.mark.asyncio
async def test_standalone_slash_adapter_updates_model_without_chat_cmd_loop() -> None:
from opensquilla.cli.repl.standalone_slash_adapter import (
StandaloneSlashContext,
handle_standalone_slash_command,
)
state = ChatSessionState(session_key="agent:main:standalone:test", model="old/model")
context = StandaloneSlashContext(
state=state,
session_key=state.session_key,
model=state.model,
tool_ctx=object(),
slash_services=_slash_services(_StandaloneSlashHarness()),
turn_runner=object(),
build_tool_ctx=lambda _session_key: object(),
replace_session=lambda **_updates: None,
)
handled = await handle_standalone_slash_command("/model new/model", context)
assert handled is True
assert state.model == "new/model"
assert context.model == "new/model"
@pytest.mark.asyncio
async def test_standalone_slash_adapter_streams_path_without_chat_cmd_loop(
tmp_path,
) -> None:
from opensquilla.cli.repl.standalone_slash_adapter import (
StandaloneSlashContext,
handle_standalone_slash_command,
)
target = tmp_path / "notes.md"
target.write_text("hello\n", encoding="utf-8")
state = ChatSessionState(session_key="agent:main:standalone:test", model="openai/test")
tool_ctx = object()
stream_calls: list[dict[str, Any]] = []
async def stream_response(
turn_runner: object,
session_key: str,
tool_context: object,
message: str,
*,
model: str | None = None,
services: object = None,
timeout: float | None = None,
tui_output: TuiOutputHandle | None = None,
) -> TurnResult:
stream_calls.append(
{
"turn_runner": turn_runner,
"session_key": session_key,
"tool_context": tool_context,
"message": message,
"model": model,
"services": services,
"timeout": timeout,
"tui_output": tui_output,
}
)
return TurnResult(text="done")
runtime_services = SimpleNamespace()
turn_runner = object()
tui_output = cast(TuiOutputHandle, object())
context = StandaloneSlashContext(
state=state,
session_key=state.session_key,
model=state.model,
tool_ctx=tool_ctx,
slash_services=_slash_services(_StandaloneSlashHarness()),
runtime_services=runtime_services,
turn_runner=turn_runner,
timeout=7.25,
tui_output=tui_output,
build_tool_ctx=lambda _session_key: object(),
replace_session=lambda **_updates: None,
stream_response=stream_response,
)
handled = await handle_standalone_slash_command(f"/path {target} inspect", context)
assert handled is True
assert len(stream_calls) == 1
assert stream_calls[0]["turn_runner"] is turn_runner
assert stream_calls[0]["session_key"] == "agent:main:standalone:test"
assert stream_calls[0]["tool_context"] is tool_ctx
assert stream_calls[0]["model"] == "openai/test"
assert stream_calls[0]["services"] is runtime_services
assert stream_calls[0]["timeout"] == 7.25
assert stream_calls[0]["tui_output"] is tui_output
assert "inspect" in stream_calls[0]["message"]
assert str(target.resolve(strict=False)) in stream_calls[0]["message"]
assert state.transcript.to_markdown()
@pytest.mark.asyncio
async def test_standalone_slash_adapter_new_session_uses_typed_create_handle() -> None:
from opensquilla.cli.repl.standalone_slash_adapter import (
StandaloneSlashContext,
handle_standalone_slash_command,
)
harness = _StandaloneSlashHarness()
replacement_calls: list[dict[str, object]] = []
state = ChatSessionState(session_key="agent:main:standalone:old", model="openai/test")
context = StandaloneSlashContext(
state=state,
session_key=state.session_key,
model=state.model,
tool_ctx=object(),
slash_services=_slash_services(harness),
turn_runner=object(),
build_tool_ctx=lambda session_key: {"session_key": session_key},
replace_session=lambda **updates: replacement_calls.append(updates),
)
handled = await handle_standalone_slash_command("/new scratch", context)
assert handled is True
assert len(harness.create_calls) == 1
new_session_key = harness.create_calls[0]["session_key"]
assert new_session_key.startswith("agent:main:standalone:")
assert harness.create_calls[0]["agent_id"] == "main"
assert context.session_key == new_session_key
assert context.state.session_key == new_session_key
assert context.tool_ctx == {"session_key": new_session_key}
assert replacement_calls[0]["session_key"] == new_session_key
@pytest.mark.asyncio
async def test_standalone_slash_adapter_reset_uses_typed_flush_and_truncate_handles() -> None:
from opensquilla.cli.repl.standalone_slash_adapter import (
StandaloneSlashContext,
handle_standalone_slash_command,
)
harness = _StandaloneSlashHarness()
session_key = "agent:main:standalone:test"
harness.transcripts[session_key] = [SimpleNamespace(role="user", content="persisted")]
state = ChatSessionState(session_key=session_key, model="openai/test")
state.transcript.add("user", "local")
context = StandaloneSlashContext(
state=state,
session_key=session_key,
model=state.model,
tool_ctx=object(),
slash_services=_slash_services(harness),
turn_runner=object(),
build_tool_ctx=lambda _session_key: object(),
replace_session=lambda **_updates: None,
)
handled = await handle_standalone_slash_command("/reset", context)
assert handled is True
assert len(harness.flush_calls) == 1
assert harness.flush_calls[0]["session_key"] == session_key
assert harness.flush_calls[0]["kwargs"] == {
"agent_id": "main",
"timeout": 30.0,
"message_window": 0,
"segment_mode": "auto",
}
assert harness.truncate_calls == [(session_key, 0)]
assert not state.transcript.to_markdown()
@pytest.mark.asyncio
async def test_standalone_slash_adapter_compact_uses_typed_compact_handles() -> None:
from opensquilla.cli.repl.standalone_slash_adapter import (
StandaloneSlashContext,
StandaloneSlashServices,
handle_standalone_slash_command,
)
harness = _StandaloneSlashHarness()
session_key = "agent:main:standalone:test"
harness.transcripts[session_key] = [SimpleNamespace(role="user", content="persisted")]
config = SimpleNamespace(
context_budget_tokens=4321,
compaction=SimpleNamespace(enabled=True, model=None, timeout_seconds=12.5),
)
state = ChatSessionState(session_key=session_key, model="openai/test")
context = StandaloneSlashContext(
state=state,
session_key=session_key,
model=state.model,
tool_ctx=object(),
slash_services=StandaloneSlashServices(
create_session=harness.create_session,
read_transcript=harness.read_transcript,
compact_session=harness.compact_session,
flush_transcript=harness.flush_transcript,
config=config,
provider_selector=None,
),
turn_runner=object(),
build_tool_ctx=lambda _session_key: object(),
replace_session=lambda **_updates: None,
)
handled = await handle_standalone_slash_command("/compact", context)
assert handled is True
assert len(harness.flush_calls) == 1
assert len(harness.compact_calls) == 1
compact_session_key, context_window, compaction_config = harness.compact_calls[0]
assert compact_session_key == session_key
assert context_window == 4321
assert compaction_config is not None
@@ -0,0 +1,400 @@
from __future__ import annotations
import inspect
from types import SimpleNamespace
from typing import Any, cast
import pytest
from opensquilla.cli import chat_cmd
from opensquilla.cli.repl.stream import TurnResult
from opensquilla.cli.tui.contracts import TuiOutputHandle
from opensquilla.engine.commands import Surface
class _RecordingOutputHandle:
def __init__(self) -> None:
self._approval_surface = object()
@property
def approval_surface(self) -> object:
return self._approval_surface
async def write_through(self, payload: str) -> None:
return None
def stream_output(self):
raise AssertionError("stream_output is only consumed by renderers")
class _FalseyCallable:
def __call__(self, *args: Any, **kwargs: Any) -> Any:
return None
def __bool__(self) -> bool:
return False
class _FalseyConsole:
def __bool__(self) -> bool:
return False
class _ApprovalRenderer:
buffer = ""
def __init__(self, **_kwargs: Any) -> None:
return None
def __enter__(self) -> _ApprovalRenderer:
return self
def __exit__(self, *_args: Any) -> bool:
return False
async def aappend_text(self, _delta: str) -> None:
return None
def tool_start(self, *_args: Any, **_kwargs: Any) -> None:
return None
def tool_finished(self, *_args: Any, **_kwargs: Any) -> None:
return None
def pulse(self) -> None:
return None
def status(self, *_args: Any, **_kwargs: Any) -> None:
return None
def error(self, *_args: Any, **_kwargs: Any) -> None:
return None
def finalize(self, *_args: Any, **_kwargs: Any) -> None:
return None
class _ApprovalClient:
async def send_message(self, *_args: Any, **_kwargs: Any):
yield {
"event": "session.event.tool_result",
"result": {
"status": "approval_required",
"approval_id": "approval-1",
"command": "touch file",
},
"tool_use_id": "tool-1",
}
yield {"event": "session.event.done", "reason": "stop"}
async def resolve_approval(self, *_args: Any, **_kwargs: Any) -> None:
return None
async def abort_session(self, *_args: Any, **_kwargs: Any) -> None:
return None
class _SessionManager:
def __init__(self) -> None:
self.node = SimpleNamespace(
session_key="agent:main:webchat:abc",
agent_id="main",
origin=None,
)
async def get_session(self, session_key: str) -> object | None:
return self.node if session_key == self.node.session_key else None
async def update(self, session_key: str, **fields: Any) -> object:
for key, value in fields.items():
setattr(self.node, key, value)
return self.node
def _sandbox_config() -> SimpleNamespace:
return SimpleNamespace(
sandbox=SimpleNamespace(run_mode="standard", sandbox=True, security_grading=True),
permissions=SimpleNamespace(default_mode="off"),
)
def test_default_turn_stream_dependencies_preserves_explicit_falsey_overrides() -> None:
from opensquilla.cli.repl import turn_stream
renderer_factory = _FalseyCallable()
output_console = _FalseyConsole()
deps = turn_stream.default_turn_stream_dependencies(
renderer_factory=renderer_factory,
output_console=output_console,
)
assert deps.renderer_factory is renderer_factory
assert deps.output_console is output_console
def test_turn_stream_dependencies_preserve_frontend_owned_approval_surfaces() -> None:
from opensquilla.cli.repl import turn_stream
gateway_surface = object()
standalone_surface = object()
deps = turn_stream.default_turn_stream_dependencies(
gateway_approval_surface=gateway_surface,
standalone_approval_surface=standalone_surface,
)
assert deps.gateway_approval_surface is gateway_surface
assert deps.standalone_approval_surface is standalone_surface
def test_turn_stream_approval_surface_uses_structural_output_handle_value() -> None:
from opensquilla.cli.repl import turn_stream
output = _RecordingOutputHandle()
default_surface = object()
assert (
turn_stream.approval_surface_for_tui_output(output, default_surface)
is output.approval_surface
)
def test_tui_turn_bridge_supplies_cli_approval_surface_defaults() -> None:
from opensquilla.cli.repl import turn_bridge
deps = turn_bridge.default_turn_stream_dependencies()
assert deps.gateway_approval_surface is Surface.CLI_GATEWAY
assert deps.standalone_approval_surface is Surface.CLI_STANDALONE
def test_tui_turn_bridge_rejects_non_surface_output_approval_value() -> None:
from opensquilla.cli.repl import turn_bridge
assert (
turn_bridge.approval_surface_for_tui_output(
_RecordingOutputHandle(),
Surface.CLI_STANDALONE,
)
is Surface.CLI_STANDALONE
)
@pytest.mark.asyncio
async def test_tui_gateway_stream_coerces_invalid_output_approval_surface(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from opensquilla.cli.repl import turn_bridge
from opensquilla.cli.tui import turn_stream_defaults
captured: dict[str, Any] = {}
async def fake_approval_handler(*_args: Any, **kwargs: Any) -> None:
captured.update(kwargs)
# The default approval handler is built lazily per dependency set; patch
# its factory so the coerced surface kwarg can be observed.
monkeypatch.setattr(
turn_stream_defaults,
"tui_approval_handler",
lambda: fake_approval_handler,
)
deps = turn_bridge.default_turn_stream_dependencies(
renderer_factory=_ApprovalRenderer,
)
await turn_bridge.stream_response_gateway(
_ApprovalClient(),
"agent:main:test",
"hello",
tui_output=_RecordingOutputHandle(),
deps=deps,
)
assert captured["surface"] is Surface.CLI_GATEWAY
@pytest.mark.asyncio
async def test_local_approval_resolver_threads_sandbox_choice(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
from opensquilla.cli.repl import turn_stream
from opensquilla.gateway.approval_queue import get_approval_queue, reset_approval_queue
from opensquilla.sandbox.escalation import build_path_approval_params
from opensquilla.sandbox.path_validation import MountDecision
reset_approval_queue()
manager = _SessionManager()
workspace = tmp_path / "workspace"
outside = tmp_path / "outside"
workspace.mkdir()
outside.mkdir()
params = build_path_approval_params(
MountDecision(
status="request",
normalized_path=str(outside.resolve(strict=False)),
access="ro",
reason="outside_sandbox_mounts",
),
session_key=manager.node.session_key,
workspace=str(workspace),
)
assert params is not None
approval_id = get_approval_queue().request(namespace="exec", params=params)
captured: dict[str, Any] = {}
async def fake_apply_sandbox_approval_choice(
params: dict[str, Any] | None,
*,
choice: str | None,
approved: bool,
session_manager: object,
config: object,
) -> None:
captured.update(
{
"params": params,
"choice": choice,
"approved": approved,
"session_manager": session_manager,
"config": config,
}
)
monkeypatch.setattr(
"opensquilla.gateway.rpc_approvals.apply_sandbox_approval_choice",
fake_apply_sandbox_approval_choice,
)
resolver = turn_stream.local_approval_resolver(
session_manager=manager,
config=_sandbox_config(),
)
await resolver(approval_id, True, choice="allow_same_type")
pending = get_approval_queue().get(approval_id)
assert pending.resolved is True
assert pending.approved is True
assert pending.claim_token is None
assert captured["choice"] == "allow_same_type"
assert captured["approved"] is True
assert captured["params"] == params
assert captured["session_manager"] is manager
reset_approval_queue()
@pytest.mark.asyncio
async def test_turn_stream_adapter_threads_gateway_tui_output_without_chat_cmd(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from opensquilla.cli.repl import turn_stream
captured: dict[str, Any] = {}
async def fake_stream_response_gateway(
client: object,
session_key: str,
message: str,
elevated_state: dict[str, str | None] | None = None,
attachments: list[dict[str, Any]] | None = None,
*,
tui_output: TuiOutputHandle | None = None,
) -> TurnResult:
captured.update(
{
"client": client,
"session_key": session_key,
"message": message,
"elevated_state": elevated_state,
"attachments": attachments,
"tui_output": tui_output,
}
)
return TurnResult(text="ok")
monkeypatch.setattr(turn_stream, "stream_response_gateway", fake_stream_response_gateway)
output = _RecordingOutputHandle()
result = await turn_stream.dispatch_gateway_stream(
cast(turn_stream.GatewayStreamingClient, object()),
"agent:main:test",
"hello",
{"mode": "bypass"},
attachments=[{"id": "file-1"}],
tui_output=output,
)
assert result.text == "ok"
assert captured == {
"client": captured["client"],
"session_key": "agent:main:test",
"message": "hello",
"elevated_state": {"mode": "bypass"},
"attachments": [{"id": "file-1"}],
"tui_output": output,
}
def test_turn_stream_adapter_has_no_raw_prompt_application_dependency() -> None:
from opensquilla.cli.repl import turn_stream
source = inspect.getsource(turn_stream)
blocked_module = "prompt" + "_toolkit"
assert blocked_module not in source
assert "ChatApplication" not in source
@pytest.mark.asyncio
async def test_chat_cmd_stream_wrapper_uses_bridge_owned_renderer_dependency(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from opensquilla.cli.repl import turn_stream
from opensquilla.cli.tui.adapters import runtime_bridge
from opensquilla.cli.tui.opentui import renderer as opentui_renderer
captured: dict[str, Any] = {}
class _TemporaryOpenTuiRenderer:
pass
async def fake_stream_response_gateway(
client: object,
session_key: str,
message: str,
elevated_state: dict[str, str | None] | None = None,
attachments: list[dict[str, Any]] | None = None,
*,
tui_output: TuiOutputHandle | None = None,
deps: turn_stream.TurnStreamDependencies | None = None,
) -> TurnResult:
captured["renderer_dependency"] = None if deps is None else deps.renderer_factory
return TurnResult(text="ok")
# The bridge resolves the renderer from the selected backend, sourcing the
# class from the opentui renderer module — pin both halves of that wiring.
monkeypatch.setattr(
runtime_bridge,
"validate_tui_backend_selection",
lambda env=None: "opentui",
)
monkeypatch.setattr(
opentui_renderer,
"OpenTuiStreamRenderer",
_TemporaryOpenTuiRenderer,
)
monkeypatch.setattr(turn_stream, "stream_response_gateway", fake_stream_response_gateway)
result = await chat_cmd._stream_response_gateway(
cast(Any, object()),
"agent:main:test",
"hello",
{"mode": "bypass"},
)
assert result.text == "ok"
assert captured == {"renderer_dependency": _TemporaryOpenTuiRenderer}
@@ -0,0 +1,347 @@
from __future__ import annotations
import ast
import importlib
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[4]
CHAT_CMD = PROJECT_ROOT / "src/opensquilla/cli/chat_cmd.py"
TURN_STREAM = PROJECT_ROOT / "src/opensquilla/cli/chat/turn_stream.py"
TURN_BRIDGE = PROJECT_ROOT / "src/opensquilla/cli/tui/turn_bridge.py"
TURN_STREAM_DEFAULTS = (
PROJECT_ROOT / "src/opensquilla/cli/tui/adapters/turn_stream_defaults.py"
)
GATEWAY_SLASH_ADAPTER = (
PROJECT_ROOT / "src/opensquilla/cli/tui/adapters/slash_gateway.py"
)
STANDALONE_SLASH_ADAPTER = (
PROJECT_ROOT / "src/opensquilla/cli/tui/adapters/slash_standalone.py"
)
OPENTUI_RENDERER = "opensquilla.cli.tui.opentui.renderer"
REMOVED_TERMINAL_BRIDGE = "terminal" + "_bridge"
def _imports_name_from_module(path: Path, module: str, name: str) -> bool:
tree = ast.parse(path.read_text())
for node in ast.walk(tree):
if not isinstance(node, ast.ImportFrom):
continue
if node.module == module and any(alias.name == name for alias in node.names):
return True
return False
def _imports_from_module(path: Path, module: str) -> bool:
tree = ast.parse(path.read_text())
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom) and node.module == module:
return True
if isinstance(node, ast.Import) and any(alias.name == module for alias in node.names):
return True
return False
def _imports_module(path: Path, module: str) -> bool:
tree = ast.parse(path.read_text())
for node in ast.walk(tree):
if isinstance(node, ast.Import) and any(alias.name == module for alias in node.names):
return True
if isinstance(node, ast.ImportFrom) and node.module == module:
return True
return False
def _imports_module_from_package(path: Path, package: str, module_name: str) -> bool:
tree = ast.parse(path.read_text())
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom):
if node.module == package and any(alias.name == module_name for alias in node.names):
return True
continue
if isinstance(node, ast.Import):
module = f"{package}.{module_name}"
if any(alias.name == module for alias in node.names):
return True
return False
def _defined_function_names(path: Path) -> set[str]:
tree = ast.parse(path.read_text())
return {
node.name
for node in tree.body
if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef)
}
def _module_aliases(path: Path, module_alias: str) -> dict[str, str]:
tree = ast.parse(path.read_text())
aliases: dict[str, str] = {}
for node in tree.body:
if not isinstance(node, ast.Assign):
continue
if len(node.targets) != 1 or not isinstance(node.targets[0], ast.Name):
continue
value = node.value
if (
isinstance(value, ast.Attribute)
and isinstance(value.value, ast.Name)
and value.value.id == module_alias
):
aliases[node.targets[0].id] = value.attr
return aliases
def test_chat_cmd_does_not_import_legacy_renderer_or_approval_handler() -> None:
assert not _imports_name_from_module(
CHAT_CMD,
"opensquilla.cli.repl.stream",
"StreamingRenderer",
)
assert not _imports_name_from_module(
CHAT_CMD,
"opensquilla.cli.repl.approval",
"maybe_handle_approval",
)
def test_chat_cmd_does_not_import_raw_slash_adapters_or_context() -> None:
assert not _imports_module_from_package(
CHAT_CMD,
"opensquilla.cli.repl",
"slash_adapter",
)
assert not _imports_module_from_package(
CHAT_CMD,
"opensquilla.cli.repl",
"standalone_slash_adapter",
)
assert not _imports_from_module(
CHAT_CMD,
"opensquilla.cli.repl.slash_adapter",
)
def test_chat_cmd_does_not_import_raw_runtime_or_removed_bridges() -> None:
assert not _imports_module_from_package(
CHAT_CMD,
"opensquilla.cli.repl",
"gateway_runtime",
)
assert not _imports_module_from_package(
CHAT_CMD,
"opensquilla.cli.repl",
"standalone_runtime",
)
assert not _imports_module_from_package(
CHAT_CMD,
"opensquilla.cli.repl",
REMOVED_TERMINAL_BRIDGE,
)
def test_chat_cmd_does_not_import_raw_input_assets() -> None:
assert not _imports_module_from_package(
CHAT_CMD,
"opensquilla.cli.repl",
"input_assets",
)
assert not _imports_from_module(
CHAT_CMD,
"opensquilla.cli.repl.input_assets",
)
def test_chat_cmd_does_not_import_backend_helper_bridges() -> None:
for module_name in ("input_bridge", "slash_bridge", "turn_bridge"):
assert not _imports_module_from_package(
CHAT_CMD,
"opensquilla.cli.repl",
module_name,
)
assert not _imports_from_module(
CHAT_CMD,
f"opensquilla.cli.repl.{module_name}",
)
def test_chat_cmd_private_compat_surface_is_dynamic_legacy_exports() -> None:
tree = ast.parse(CHAT_CMD.read_text())
assigned_names = {
target.id
for node in tree.body
if isinstance(node, ast.Assign)
for target in node.targets
if isinstance(target, ast.Name)
}
assert _defined_function_names(CHAT_CMD) == {"__dir__", "__getattr__", "run_chat"}
assert not any(
name.startswith("_") and not name.startswith("__")
for name in assigned_names
)
assert not _imports_module_from_package(
CHAT_CMD,
"opensquilla.cli.repl",
"chat_compat",
)
assert not _imports_from_module(
CHAT_CMD,
"opensquilla.cli.repl.chat_compat",
)
def test_chat_cmd_uses_typed_launch_request_instead_of_private_runner_names() -> None:
source = CHAT_CMD.read_text()
assert "_ChatCommandRequest" in source
assert "_ChatCommandLaunchOverrides" in source
assert "_run_chat_request" in source
assert "legacy_overrides" not in source
assert "_launch_bridge" not in source
assert "_standalone_repl" not in source
assert "_gateway_chat" not in source
def test_chat_cmd_star_import_keeps_legacy_public_names_visible() -> None:
chat_cmd = importlib.import_module("opensquilla.cli.chat_cmd")
assert "run_chat" in chat_cmd.__all__
assert "GATEWAY_SLASH_HANDLER_WORDS" in chat_cmd.__all__
assert "TurnResult" in chat_cmd.__all__
assert "_file_prompt_and_attachments" not in chat_cmd.__all__
assert "GATEWAY_SLASH_HANDLER_WORDS" in dir(chat_cmd)
assert "_file_prompt_and_attachments" in dir(chat_cmd)
namespace: dict[str, object] = {}
exec("from opensquilla.cli.chat_cmd import *", namespace)
assert "run_chat" in namespace
assert "GATEWAY_SLASH_HANDLER_WORDS" in namespace
assert "_file_prompt_and_attachments" not in namespace
def test_chat_cmd_only_defines_typer_entrypoint_and_export_hooks() -> None:
assert _defined_function_names(CHAT_CMD) == {"__dir__", "__getattr__", "run_chat"}
def test_chat_cmd_does_not_import_launch_presentation_details() -> None:
assert not _imports_module(CHAT_CMD, "asyncio")
assert not _imports_module(CHAT_CMD, "os")
assert not _imports_module(CHAT_CMD, "sys")
assert not _imports_name_from_module(CHAT_CMD, "rich.panel", "Panel")
assert not _imports_name_from_module(CHAT_CMD, "opensquilla.cli.ui", "ACCENT")
def test_chat_cmd_does_not_import_cli_presentation_defaults() -> None:
assert not _imports_from_module(CHAT_CMD, "opensquilla.cli.ui")
def test_chat_cmd_does_not_import_raw_turn_stream_facade() -> None:
assert not _imports_module_from_package(
CHAT_CMD,
"opensquilla.cli.repl",
"turn_stream",
)
assert not _imports_from_module(
CHAT_CMD,
"opensquilla.cli.repl.turn_stream",
)
assert not _imports_from_module(
CHAT_CMD,
"opensquilla.cli.repl.stream",
)
def test_turn_stream_does_not_import_raw_input_assets() -> None:
assert not _imports_module_from_package(
TURN_STREAM,
"opensquilla.cli.repl",
"input_assets",
)
assert not _imports_from_module(
TURN_STREAM,
"opensquilla.cli.repl.input_assets",
)
def test_turn_stream_does_not_import_frontend_default_dependencies() -> None:
assert not _imports_module_from_package(
TURN_STREAM,
"opensquilla.cli.repl",
"input_bridge",
)
assert not _imports_from_module(
TURN_STREAM,
"opensquilla.cli.repl.approval",
)
assert not _imports_name_from_module(
TURN_STREAM,
"opensquilla.cli.repl.stream",
"StreamingRenderer",
)
assert not _imports_from_module(
TURN_STREAM,
f"opensquilla.cli.repl.{REMOVED_TERMINAL_BRIDGE}",
)
assert not _imports_from_module(
TURN_STREAM,
"opensquilla.cli.ui",
)
assert not _imports_from_module(
TURN_STREAM,
"opensquilla.cli.repl.slash_adapter",
)
assert not _imports_from_module(
TURN_STREAM,
"opensquilla.cli.tui.contracts",
)
assert not _imports_from_module(
TURN_STREAM,
"opensquilla.engine.commands",
)
def test_turn_bridge_does_not_import_concrete_streaming_renderer() -> None:
assert not _imports_name_from_module(
TURN_BRIDGE,
"opensquilla.cli.repl.stream",
"StreamingRenderer",
)
def test_turn_stream_defaults_uses_opentui_renderer_default() -> None:
assert _imports_name_from_module(
TURN_STREAM_DEFAULTS,
OPENTUI_RENDERER,
"OpenTuiStreamRenderer",
)
assert not _imports_name_from_module(
TURN_STREAM_DEFAULTS,
"opensquilla.cli.repl.approval",
"maybe_handle_approval",
)
def test_turn_bridge_delegates_tui_approval_defaults() -> None:
assert _imports_from_module(
TURN_BRIDGE,
"opensquilla.cli.tui.adapters.turn_stream_defaults",
)
assert not _imports_name_from_module(
TURN_BRIDGE,
OPENTUI_RENDERER,
"OpenTuiStreamRenderer",
)
def test_slash_adapters_do_not_import_raw_input_assets() -> None:
for path in (GATEWAY_SLASH_ADAPTER, STANDALONE_SLASH_ADAPTER):
assert not _imports_module_from_package(
path,
"opensquilla.cli.repl",
"input_assets",
)
assert not _imports_from_module(
path,
"opensquilla.cli.repl.input_assets",
)
+148
View File
@@ -0,0 +1,148 @@
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class ReplayEvent:
kind: str
payload: dict[str, object]
timestamp_ms: int
def _router_decision_payload(index: int, *, tier: str) -> dict[str, object]:
model = f"openrouter/{tier}-model-{index}"
return {
"tier": tier,
"model": model,
"baseline_model": "openrouter/frontier-baseline",
"source": "synthetic-replay",
"confidence": round(0.92 - index * 0.07, 2),
"savings_pct": 18 + index * 9,
"fallback": index == 3,
"thinking_mode": "observe" if index % 2 == 0 else "full",
"prompt_policy": "standard",
"routing_applied": index != 3,
"rollout_phase": f"phase-{index}",
}
def build_long_stream_events() -> list[ReplayEvent]:
events: list[ReplayEvent] = [
ReplayEvent(
"user_input",
{"text": "Explain the OpenSquilla routing decision stream."},
0,
),
ReplayEvent("router_decision", _router_decision_payload(0, tier="standard"), 1),
]
timestamp_ms = 2
tool_index = 0
for index in range(4_000):
section = index // 800
delta = f"stream-section-{section:02d}-delta-{index:04d}-"[:40]
events.append(ReplayEvent("text_delta", {"text": delta.ljust(40, ".")}, timestamp_ms))
timestamp_ms += 1
if (index + 1) % 800 == 0 and tool_index < 4:
tool_id = f"tool-{tool_index}"
events.append(
ReplayEvent(
"tool_start",
{
"name": "synthetic_tool",
"args": {"section": section},
"tool_use_id": tool_id,
},
timestamp_ms,
)
)
timestamp_ms += 1
events.append(
ReplayEvent(
"tool_finished",
{
"tool_use_id": tool_id,
"success": True,
"elapsed": 0.01 + tool_index * 0.001,
},
timestamp_ms,
)
)
timestamp_ms += 1
tool_index += 1
events.append(
ReplayEvent(
"done",
{
"usage": {
"input_tokens": 1_024,
"output_tokens": 42_000,
"cache_read_tokens": 512,
}
},
timestamp_ms,
)
)
return events
def build_dense_history_events() -> list[ReplayEvent]:
events: list[ReplayEvent] = []
timestamp_ms = 0
for index, tier in enumerate(("spark", "standard", "frontier", "fallback")):
events.append(
ReplayEvent(
"router_decision",
_router_decision_payload(index, tier=tier),
timestamp_ms,
)
)
timestamp_ms += 1
user_text = "user historical prompt line ".ljust(96, "u")
assistant_text = "assistant historical answer line ".ljust(160, "a")
for index in range(250):
events.append(
ReplayEvent(
"history_message",
{
"role": "user",
"content": f"{index:03d}: {user_text}",
},
timestamp_ms,
)
)
timestamp_ms += 1
events.append(
ReplayEvent(
"history_message",
{
"role": "assistant",
"content": f"{index:03d}: {assistant_text}",
},
timestamp_ms,
)
)
timestamp_ms += 1
for index in range(120):
events.append(
ReplayEvent(
"tool_card",
{
"tool_use_id": f"dense-tool-{index}",
"name": "synthetic_tool",
"summary": f"summary for dense tool card {index}",
"expanded_candidate": index < 20,
"line_count": 12 + index,
"rendered_bytes": 1_024 + index * 8,
},
timestamp_ms,
)
)
timestamp_ms += 1
return events
+569
View File
@@ -0,0 +1,569 @@
from __future__ import annotations
import asyncio
import json
from typing import Any
import pytest
from opensquilla.cli.tui.adapters.approvals import (
ApprovalChoice,
decide_from_response,
deny_decision,
parse_approval_envelope,
tui_approval_handler,
)
from opensquilla.engine.commands import Surface
class _RecordingRenderer:
"""Minimal async renderer capturing status lines."""
def __init__(self, output_handle: Any | None = None, *, expose_handle: bool = True) -> None:
if expose_handle:
self.output_handle = output_handle
self.statuses: list[tuple[str, str]] = []
async def astatus(self, message: str, *, style: str = "dim") -> None:
self.statuses.append((message, style))
class _HeadlessRenderer:
"""Renderer with no output handle attribute at all (replay/eval shape)."""
def __init__(self) -> None:
self.statuses: list[tuple[str, str]] = []
async def astatus(self, message: str, *, style: str = "dim") -> None:
self.statuses.append((message, style))
class _HostOutputHandle:
"""Output handle exposing the OpenTUI approval round-trip."""
def __init__(self, response: Any) -> None:
self._response = response
self.requests: list[dict[str, object]] = []
async def request_approval(self, request: dict[str, object]) -> Any:
self.requests.append(request)
if isinstance(self._response, Exception):
raise self._response
return self._response
class _WrappedHandle:
"""Plugin-wrapper shape: request_approval always callable, flag tells truth."""
def __init__(self, *, supports: bool) -> None:
self.supports_request_approval = supports
self.requests: list[dict[str, object]] = []
async def request_approval(self, request: dict[str, object]) -> Any:
self.requests.append(request)
return None
async def write_through(self, payload: str) -> None:
return None
class _NativeOutputHandle:
"""Native terminal handle shape: write-through only, no host IPC."""
async def write_through(self, payload: str) -> None:
return None
class _FakeConsole:
def __init__(self, answers: list[str] | None = None, *, raise_eof: bool = False) -> None:
self._answers = list(answers or [])
self._raise_eof = raise_eof
self.printed: list[str] = []
self.prompts: list[str] = []
def print(self, message: str) -> None:
self.printed.append(message)
def input(self, prompt: str) -> str:
self.prompts.append(prompt)
if self._raise_eof or not self._answers:
raise EOFError
return self._answers.pop(0)
class _FakeResponse:
def __init__(self, approved: bool, choice: str | None = None) -> None:
self.approved = approved
self.choice = choice
class _Resolver:
def __init__(self, error: Exception | None = None) -> None:
self.calls: list[tuple[str, bool, str | None]] = []
self._error = error
async def __call__(
self,
approval_id: str,
approved: bool,
*,
choice: str | None = None,
) -> None:
self.calls.append((approval_id, approved, choice))
if self._error is not None:
raise self._error
def _sandbox_envelope_payload() -> dict[str, Any]:
return {
"status": "approval_required",
"approval_id": "appr-1",
"message": "Network access needs approval.",
"approvalKind": "sandbox_network",
"host": "packages.example.test",
"choices": [
{"id": "allow_once", "label": "Allow once", "approved": True, "style": "primary"},
{"id": "allow_same_type", "label": "Allow same type", "approved": True},
{"id": "deny", "label": "Deny", "approved": False, "style": "danger"},
],
}
def _exec_envelope_payload() -> dict[str, Any]:
return {
"status": "approval_required",
"approval_id": "appr-2",
"tool": "shell",
"command": "touch demo.txt",
"message": "This command needs approval.",
}
# ---------------------------------------------------------------------------
# Envelope parsing
# ---------------------------------------------------------------------------
def test_parse_approval_envelope_from_dict_and_json_string() -> None:
payload = _sandbox_envelope_payload()
from_dict = parse_approval_envelope(payload)
from_string = parse_approval_envelope(json.dumps(payload))
assert from_dict == from_string
assert from_dict is not None
assert from_dict.status == "approval_required"
assert from_dict.approval_id == "appr-1"
assert from_dict.tool == "sandbox_network"
assert from_dict.summary == "network access to packages.example.test"
assert from_dict.choices == (
ApprovalChoice(id="allow_once", label="Allow once", approved=True),
ApprovalChoice(id="allow_same_type", label="Allow same type", approved=True),
ApprovalChoice(id="deny", label="Deny", approved=False),
)
assert from_dict.actionable
def test_parse_approval_envelope_exec_command_summary() -> None:
envelope = parse_approval_envelope(_exec_envelope_payload())
assert envelope is not None
assert envelope.tool == "shell"
assert envelope.summary == "touch demo.txt"
def test_parse_approval_envelope_blocked_and_pending() -> None:
blocked = parse_approval_envelope(
{
"status": "blocked",
"reason": "workspace_write_deny",
"tool": "write_file",
"path": "src/protected.py",
"message": "write_file blocked by workspace write deny policy.",
}
)
assert blocked is not None
assert blocked.status == "blocked"
assert not blocked.actionable
pending = parse_approval_envelope(
{"status": "approval_pending", "approval_id": "appr-9", "command": "ls"}
)
assert pending is not None
assert pending.actionable
@pytest.mark.parametrize(
"result",
[
None,
42,
["not", "an", "envelope"],
{"status": "success", "output": "ok"},
{"status": "approval_denied", "approval_id": "appr-1"},
"plain tool output",
"{not valid json",
'"a json string, not an object"',
json.dumps({"status": "error", "message": "boom"}),
],
)
def test_parse_approval_envelope_rejects_non_approval_results(result: Any) -> None:
assert parse_approval_envelope(result) is None
def test_decide_from_response_maps_choices_authoritatively() -> None:
envelope = parse_approval_envelope(_sandbox_envelope_payload())
assert envelope is not None
# A named choice wins, and its approved flag is authoritative.
assert decide_from_response(envelope, approved=True, choice="deny") == (False, "deny")
# A bare approve maps to the first approving choice.
assert decide_from_response(envelope, approved=True, choice=None) == (True, "allow_once")
# A bare deny maps to the denying choice.
assert decide_from_response(envelope, approved=False, choice=None) == (False, "deny")
# Unknown choices fall back to polarity matching.
assert decide_from_response(envelope, approved=False, choice="bogus") == (False, "deny")
assert deny_decision(envelope) == (False, "deny")
def test_decide_from_response_without_choices_uses_boolean() -> None:
envelope = parse_approval_envelope(_exec_envelope_payload())
assert envelope is not None
assert decide_from_response(envelope, approved=True, choice=None) == (True, None)
assert decide_from_response(envelope, approved=False, choice=None) == (False, None)
# ---------------------------------------------------------------------------
# Handler behaviour
# ---------------------------------------------------------------------------
async def test_handler_is_noop_for_non_approval_results() -> None:
handler = tui_approval_handler(output_console=_FakeConsole())
resolver = _Resolver()
renderer = _RecordingRenderer(_HostOutputHandle(_FakeResponse(True)))
await handler({"status": "success", "output": "done"}, renderer, resolver)
await handler("plain text result", renderer, resolver, surface=Surface.CLI_GATEWAY)
await handler(None, renderer, resolver, elevated_state={"mode": None})
assert resolver.calls == []
assert renderer.statuses == []
assert renderer.output_handle.requests == []
async def test_handler_renders_blocked_results_without_resolving() -> None:
handler = tui_approval_handler(output_console=_FakeConsole())
resolver = _Resolver()
renderer = _RecordingRenderer(_HostOutputHandle(_FakeResponse(True)))
await handler(
{
"status": "blocked",
"tool": "write_file",
"message": "write blocked by policy",
},
renderer,
resolver,
)
assert resolver.calls == []
assert renderer.output_handle.requests == []
assert len(renderer.statuses) == 1
message, _style = renderer.statuses[0]
assert "blocked" in message
assert "write_file" in message
async def test_handler_skips_resolution_when_envelope_has_no_id() -> None:
handler = tui_approval_handler(output_console=_FakeConsole())
resolver = _Resolver()
renderer = _RecordingRenderer(_HostOutputHandle(_FakeResponse(True)))
await handler(
{"status": "approval_pending", "approval_id": "", "command": "ls"},
renderer,
resolver,
)
assert resolver.calls == []
assert renderer.output_handle.requests == []
assert len(renderer.statuses) == 1
async def test_handler_routes_through_host_overlay_and_resolves_approval() -> None:
handler = tui_approval_handler(output_console=_FakeConsole())
resolver = _Resolver()
handle = _HostOutputHandle(_FakeResponse(True, choice=None))
renderer = _RecordingRenderer(handle)
await handler(_sandbox_envelope_payload(), renderer, resolver)
assert handle.requests == [
{
"id": "appr-1",
"tool": "sandbox_network",
"summary": "network access to packages.example.test",
"choices": ["allow_once", "allow_same_type", "deny"],
}
]
assert resolver.calls == [("appr-1", True, "allow_once")]
async def test_handler_host_choice_response_is_authoritative() -> None:
handler = tui_approval_handler(output_console=_FakeConsole())
resolver = _Resolver()
handle = _HostOutputHandle(_FakeResponse(True, choice="deny"))
renderer = _RecordingRenderer(handle)
await handler(_sandbox_envelope_payload(), renderer, resolver)
assert resolver.calls == [("appr-1", False, "deny")]
async def test_handler_denies_on_host_timeout() -> None:
handler = tui_approval_handler(output_console=_FakeConsole())
resolver = _Resolver()
# request_approval returns None on timeout/teardown — the handler must deny.
handle = _HostOutputHandle(None)
renderer = _RecordingRenderer(handle)
await handler(_sandbox_envelope_payload(), renderer, resolver)
assert resolver.calls == [("appr-1", False, "deny")]
async def test_handler_denies_on_dead_bridge() -> None:
handler = tui_approval_handler(output_console=_FakeConsole())
resolver = _Resolver()
handle = _HostOutputHandle(RuntimeError("bridge is gone"))
renderer = _RecordingRenderer(handle)
await handler(_exec_envelope_payload(), renderer, resolver)
assert resolver.calls == [("appr-2", False, None)]
async def test_handler_survives_resolver_failure() -> None:
handler = tui_approval_handler(output_console=_FakeConsole())
resolver = _Resolver(error=RuntimeError("rpc down"))
renderer = _RecordingRenderer(_HostOutputHandle(_FakeResponse(True)))
await handler(_exec_envelope_payload(), renderer, resolver)
assert resolver.calls == [("appr-2", True, None)]
assert any("failed to resolve" in message for message, _style in renderer.statuses)
async def test_handler_ignores_wrapped_handle_without_host_capability() -> None:
"""Plugin wrappers always expose a callable request_approval; the handler
must honour the capability flag and fall back to the console prompt."""
console = _FakeConsole(answers=["y"])
handler = tui_approval_handler(output_console=console)
resolver = _Resolver()
handle = _WrappedHandle(supports=False)
renderer = _RecordingRenderer(handle)
await handler(_exec_envelope_payload(), renderer, resolver)
assert handle.requests == []
assert resolver.calls == [("appr-2", True, None)]
assert console.prompts == ["Approve? [y/N]: "]
async def test_native_console_prompt_approves_and_denies() -> None:
resolver = _Resolver()
renderer = _RecordingRenderer(_NativeOutputHandle())
approve_console = _FakeConsole(answers=["y"])
await tui_approval_handler(output_console=approve_console)(
_exec_envelope_payload(), renderer, resolver
)
deny_console = _FakeConsole(answers=["n"])
await tui_approval_handler(output_console=deny_console)(
_exec_envelope_payload(), renderer, resolver
)
default_console = _FakeConsole(answers=[""])
await tui_approval_handler(output_console=default_console)(
_exec_envelope_payload(), renderer, resolver
)
assert resolver.calls == [
("appr-2", True, None),
("appr-2", False, None),
("appr-2", False, None), # bare Enter must never approve
]
assert any("approval required: shell" in line for line in approve_console.printed)
assert any("touch demo.txt" in line for line in approve_console.printed)
async def test_native_console_prompt_supports_numbered_choices() -> None:
console = _FakeConsole(answers=["2"])
handler = tui_approval_handler(output_console=console)
resolver = _Resolver()
renderer = _RecordingRenderer(_NativeOutputHandle())
await handler(_sandbox_envelope_payload(), renderer, resolver)
assert resolver.calls == [("appr-1", True, "allow_same_type")]
assert console.prompts == ["Approve? [y/N/1-3]: "]
assert any("1) Allow once" in line for line in console.printed)
async def test_native_console_prompt_denies_on_eof() -> None:
console = _FakeConsole(raise_eof=True)
handler = tui_approval_handler(output_console=console)
resolver = _Resolver()
renderer = _RecordingRenderer(_NativeOutputHandle())
await handler(_sandbox_envelope_payload(), renderer, resolver)
assert resolver.calls == [("appr-1", False, "deny")]
async def test_native_console_prompt_denies_on_out_of_range_choice() -> None:
console = _FakeConsole(answers=["9"])
handler = tui_approval_handler(output_console=console)
resolver = _Resolver()
renderer = _RecordingRenderer(_NativeOutputHandle())
await handler(_sandbox_envelope_payload(), renderer, resolver)
assert resolver.calls == [("appr-1", False, "deny")]
async def test_cancelled_console_prompt_denies_and_reaps_the_reader() -> None:
"""Cancelling the turn mid-prompt must deny the pending approval and wait
for the in-flight reader before propagating, so the runtime's next stdin
reader never races an orphaned one for the user's next line."""
reader_started = asyncio.Event()
release_reader = asyncio.Event()
reader_finished = asyncio.Event()
async def blocking_reader(prompt: str) -> str:
reader_started.set()
try:
await release_reader.wait()
finally:
reader_finished.set()
return "y"
handler = tui_approval_handler(
output_console=_FakeConsole(), prompt_reader=blocking_reader
)
resolver = _Resolver()
renderer = _RecordingRenderer(_NativeOutputHandle())
turn = asyncio.create_task(handler(_exec_envelope_payload(), renderer, resolver))
await reader_started.wait()
turn.cancel()
# The cancelled handler stays alive until the reader is reaped.
done, _pending = await asyncio.wait({turn}, timeout=0.05)
assert not done
assert resolver.calls == [("appr-2", False, None)]
release_reader.set()
with pytest.raises(asyncio.CancelledError):
await turn
assert reader_finished.is_set()
# The reaped reader's late answer is discarded, never re-resolved.
assert resolver.calls == [("appr-2", False, None)]
async def test_cancelled_console_prompt_reaps_reader_even_when_deny_rpc_fails() -> None:
release_reader = asyncio.Event()
async def blocking_reader(prompt: str) -> str:
await release_reader.wait()
return "y"
handler = tui_approval_handler(
output_console=_FakeConsole(), prompt_reader=blocking_reader
)
resolver = _Resolver(error=RuntimeError("rpc down"))
renderer = _RecordingRenderer(_NativeOutputHandle())
turn = asyncio.create_task(handler(_exec_envelope_payload(), renderer, resolver))
await asyncio.sleep(0)
turn.cancel()
done, _pending = await asyncio.wait({turn}, timeout=0.05)
assert not done
assert resolver.calls == [("appr-2", False, None)]
release_reader.set()
with pytest.raises(asyncio.CancelledError):
await turn
async def test_headless_renderer_gets_notice_and_no_resolution() -> None:
handler = tui_approval_handler(output_console=_FakeConsole())
resolver = _Resolver()
renderer = _HeadlessRenderer()
await handler(_exec_envelope_payload(), renderer, resolver)
assert resolver.calls == []
assert len(renderer.statuses) == 1
message, _style = renderer.statuses[0]
assert "approval required" in message
async def test_renderer_with_none_output_handle_gets_notice() -> None:
handler = tui_approval_handler(output_console=_FakeConsole())
resolver = _Resolver()
renderer = _RecordingRenderer(None)
await handler(_exec_envelope_payload(), renderer, resolver)
assert resolver.calls == []
assert len(renderer.statuses) == 1
# ---------------------------------------------------------------------------
# Default wiring
# ---------------------------------------------------------------------------
def test_default_turn_stream_dependencies_wires_interactive_handler() -> None:
from opensquilla.cli.tui.adapters import turn_stream_defaults
deps = turn_stream_defaults.default_turn_stream_dependencies()
assert deps.approval_handler is not turn_stream_defaults._noop_approval_handler
assert deps.approval_handler.__module__ == "opensquilla.cli.tui.adapters.approvals"
def test_default_turn_stream_dependencies_keeps_explicit_handler() -> None:
from opensquilla.cli.tui.adapters import turn_stream_defaults
async def explicit_handler(*_args: Any, **_kwargs: Any) -> None:
return None
deps = turn_stream_defaults.default_turn_stream_dependencies(
approval_handler=explicit_handler
)
assert deps.approval_handler is explicit_handler
async def test_default_handler_resolves_through_host_capable_renderer() -> None:
"""End-to-end over the default wiring: an approval envelope reaching the
handler via a host-capable renderer is presented and resolved."""
from opensquilla.cli.tui.adapters import turn_stream_defaults
deps = turn_stream_defaults.default_turn_stream_dependencies()
resolver = _Resolver()
handle = _HostOutputHandle(_FakeResponse(True, choice=None))
renderer = _RecordingRenderer(handle)
await deps.approval_handler(
json.dumps(_exec_envelope_payload()),
renderer,
resolver,
elevated_state=None,
surface=Surface.CLI_GATEWAY,
)
assert [request["id"] for request in handle.requests] == ["appr-2"]
assert resolver.calls == [("appr-2", True, None)]
@@ -0,0 +1,774 @@
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator, Callable
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any
import pytest
from opensquilla.cli.chat.turn_stream import _drain_stalled_planes
from opensquilla.cli.tui.backend import domain_events, plugins
from opensquilla.cli.tui.backend import runtime as backend_runtime
from opensquilla.cli.tui.backend.contracts import (
TuiInputKind,
TuiRuntimeConfig,
TuiRuntimeHooks,
)
from opensquilla.cli.tui.backend.directives import StreamDirectiveFilter
from opensquilla.cli.tui.backend.domain_events import TuiDomainEvent, now_ms
from opensquilla.cli.tui.backend.events import (
TUI_DOMAIN_EVENT_KINDS,
TuiEvent,
TuiEventKind,
)
from opensquilla.cli.tui.backend.plugins import TuiPluginContext, TuiPluginManager
from opensquilla.cli.tui.backend.render_summary import (
TOOL_SUMMARY_ARG_KEYS,
clip_arg,
sanitize_terminal_text,
summarize_args,
summarize_result,
)
from opensquilla.cli.tui.backend.runtime import run_tui_runtime
from opensquilla.cli.tui.backend.state import TuiRuntimeState
from opensquilla.cli.tui.backend.streaming import StreamingFlushPolicy, StreamingPlane
from opensquilla.cli.tui.backend.transcript import (
ToolItem,
ToolPreviewPolicy,
TranscriptStore,
build_args_preview,
build_output_preview,
)
class _FakeSurface:
def __init__(self, inputs: asyncio.Queue[str | None]) -> None:
self._inputs = inputs
self.cancel_callbacks: list[Any] = []
self.shutdown_callbacks: list[Any] = []
self.writes: list[str] = []
async def next_line(self) -> str | None:
return await self._inputs.get()
def set_cancel_callback(self, cb) -> None: # noqa: ANN001
self.cancel_callbacks.append(cb)
def set_shutdown_callback(self, cb) -> None: # noqa: ANN001
self.shutdown_callbacks.append(cb)
def emit_eof(self) -> None:
self._inputs.put_nowait(None)
async def write_through(self, payload: str) -> None:
self.writes.append(payload)
@property
def redraw_callback(self) -> Callable[[], None]:
return lambda: None
def _surface_factory(surface: _FakeSurface):
@asynccontextmanager
async def _factory() -> AsyncIterator[_FakeSurface]:
yield surface
return _factory
async def _echo(surface: Any, text: str) -> None:
await surface.write_through(f"echo:{text}")
async def _queued_echo(surface: Any) -> None:
await surface.write_through("queued")
async def _wait_until(predicate: Callable[[], bool], *, timeout: float = 2.0) -> None:
loop = asyncio.get_running_loop()
deadline = loop.time() + timeout
while not predicate():
if loop.time() >= deadline:
raise AssertionError("timed out waiting for runtime condition")
await asyncio.sleep(0)
def _runtime_config(**kwargs: Any) -> TuiRuntimeConfig:
return TuiRuntimeConfig(task_name="chat-turn-hardening", **kwargs)
def _runtime_hooks(**kwargs: Any) -> TuiRuntimeHooks:
return TuiRuntimeHooks(
on_user_input_echo=_echo,
on_queued_turn_start=_queued_echo,
**kwargs,
)
class MutableClock:
def __init__(self) -> None:
self.value = 0
def __call__(self) -> int:
return self.value
def advance(self, milliseconds: int) -> None:
self.value += milliseconds
# ---------------------------------------------------------------------------
# runtime loop survival
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_runtime_survives_dispatch_exception_and_keeps_looping() -> None:
inputs: asyncio.Queue[str | None] = asyncio.Queue()
surface = _FakeSurface(inputs)
executed: list[str] = []
notices: list[str] = []
async def _dispatch(user_input: str) -> bool:
executed.append(user_input)
if user_input == "boom":
raise ConnectionError("gateway lost: [conn]")
return True
task = asyncio.create_task(
run_tui_runtime(
dispatch=_dispatch,
surface_factory=_surface_factory(surface),
config=_runtime_config(),
hooks=_runtime_hooks(notice=notices.append),
)
)
await inputs.put("boom")
await _wait_until(lambda: any("Turn failed" in notice for notice in notices))
await inputs.put("after")
await _wait_until(lambda: "after" in executed)
await inputs.put(None)
result = await asyncio.wait_for(task, timeout=2.0)
assert isinstance(result, TuiRuntimeState)
assert executed == ["boom", "after"]
# dynamic error text is markup-escaped so the notice itself cannot crash.
assert any("\\[conn]" in notice for notice in notices)
@pytest.mark.asyncio
async def test_runtime_survives_dispatch_exception_on_destructive_command() -> None:
inputs: asyncio.Queue[str | None] = asyncio.Queue()
surface = _FakeSurface(inputs)
executed: list[str] = []
notices: list[str] = []
async def _dispatch(user_input: str) -> bool:
executed.append(user_input)
if user_input == "/clear":
raise RuntimeError("rpc unavailable")
return True
task = asyncio.create_task(
run_tui_runtime(
dispatch=_dispatch,
surface_factory=_surface_factory(surface),
config=_runtime_config(
classify_input=lambda text: (
TuiInputKind.DESTRUCTIVE if text == "/clear" else TuiInputKind.NORMAL
)
),
hooks=_runtime_hooks(notice=notices.append),
)
)
await inputs.put("/clear")
await _wait_until(lambda: any("Turn failed" in notice for notice in notices))
await inputs.put("after")
await _wait_until(lambda: "after" in executed)
await inputs.put(None)
await asyncio.wait_for(task, timeout=2.0)
assert executed == ["/clear", "after"]
@pytest.mark.asyncio
async def test_runtime_emits_turn_cancelled_for_destructive_cancel() -> None:
inputs: asyncio.Queue[str | None] = asyncio.Queue()
surface = _FakeSurface(inputs)
events: list[TuiEvent] = []
first_started = asyncio.Event()
clear_done = asyncio.Event()
async def _dispatch(user_input: str) -> bool:
if user_input == "first":
first_started.set()
await asyncio.sleep(5)
if user_input == "/clear":
clear_done.set()
return True
task = asyncio.create_task(
run_tui_runtime(
dispatch=_dispatch,
surface_factory=_surface_factory(surface),
config=_runtime_config(
event_sink=events.append,
classify_input=lambda text: (
TuiInputKind.DESTRUCTIVE if text == "/clear" else TuiInputKind.NORMAL
),
),
hooks=_runtime_hooks(),
)
)
await inputs.put("first")
await asyncio.wait_for(first_started.wait(), timeout=2.0)
await inputs.put("/clear")
await asyncio.wait_for(clear_done.wait(), timeout=2.0)
await inputs.put(None)
await asyncio.wait_for(task, timeout=2.0)
assert TuiEventKind.TURN_CANCELLED in [event.kind for event in events]
@pytest.mark.asyncio
async def test_runtime_degrades_cleanly_when_echo_hook_fails() -> None:
inputs: asyncio.Queue[str | None] = asyncio.Queue()
surface = _FakeSurface(inputs)
notices: list[str] = []
executed: list[str] = []
async def _dispatch(user_input: str) -> bool:
executed.append(user_input)
return True
async def _raising_echo(_surface: Any, _text: str) -> None:
raise RuntimeError("host write failed: [io]")
await inputs.put("hello")
result = await asyncio.wait_for(
run_tui_runtime(
dispatch=_dispatch,
surface_factory=_surface_factory(surface),
config=_runtime_config(),
hooks=TuiRuntimeHooks(
on_user_input_echo=_raising_echo,
on_queued_turn_start=_queued_echo,
notice=notices.append,
),
),
timeout=2.0,
)
assert isinstance(result, TuiRuntimeState)
assert executed == []
assert any("Input surface error" in notice for notice in notices)
assert any("\\[io]" in notice for notice in notices)
@pytest.mark.asyncio
async def test_runtime_drains_pending_abort_task_before_returning() -> None:
inputs: asyncio.Queue[str | None] = asyncio.Queue()
surface = _FakeSurface(inputs)
aborts: list[str] = []
dispatch_started = asyncio.Event()
async def _dispatch(_user_input: str) -> bool:
dispatch_started.set()
await asyncio.sleep(5)
return True
async def _cancel_active_turn() -> None:
await asyncio.sleep(0.05)
aborts.append("delivered")
task = asyncio.create_task(
run_tui_runtime(
dispatch=_dispatch,
surface_factory=_surface_factory(surface),
config=_runtime_config(),
hooks=_runtime_hooks(on_cancel_active_turn=_cancel_active_turn),
)
)
await inputs.put("hello")
await asyncio.wait_for(dispatch_started.wait(), timeout=2.0)
active_cb = next(cb for cb in reversed(surface.cancel_callbacks) if cb is not None)
active_cb()
await inputs.put(None)
await asyncio.wait_for(task, timeout=2.0)
# The cancel-then-exit abort RPC must complete before the runtime returns,
# not be abandoned as an unreferenced task.
assert aborts == ["delivered"]
@pytest.mark.asyncio
async def test_runtime_bounds_shutdown_drain_when_abort_rpc_never_resolves(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(backend_runtime, "_ABORT_DRAIN_TIMEOUT_S", 0.05)
inputs: asyncio.Queue[str | None] = asyncio.Queue()
surface = _FakeSurface(inputs)
dispatch_started = asyncio.Event()
abort_cancelled = asyncio.Event()
async def _dispatch(_user_input: str) -> bool:
dispatch_started.set()
await asyncio.sleep(5)
return True
async def _wedged_abort() -> None:
# An abort RPC whose response frame never arrives (wedged gateway).
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
abort_cancelled.set()
raise
task = asyncio.create_task(
run_tui_runtime(
dispatch=_dispatch,
surface_factory=_surface_factory(surface),
config=_runtime_config(),
hooks=_runtime_hooks(on_cancel_active_turn=_wedged_abort),
)
)
await inputs.put("hello")
await asyncio.wait_for(dispatch_started.wait(), timeout=2.0)
active_cb = next(cb for cb in reversed(surface.cancel_callbacks) if cb is not None)
active_cb()
await inputs.put(None)
# Exit must complete promptly despite the unanswered abort RPC, and the
# straggler abort task must be cancelled rather than awaited forever.
result = await asyncio.wait_for(task, timeout=2.0)
assert isinstance(result, TuiRuntimeState)
await asyncio.wait_for(abort_cancelled.wait(), timeout=2.0)
# ---------------------------------------------------------------------------
# streaming plane
# ---------------------------------------------------------------------------
def test_streaming_plane_size_overflow_keeps_buffer_bounded_and_tail_drainable() -> None:
clock = MutableClock()
plane = StreamingPlane(
policy=StreamingFlushPolicy(max_delay_ms=10_000, max_chars=10, newline_min_chars=1_000),
clock_ms=clock,
)
assert plane.append("abcdefgh") is None
flush = plane.append("ijklm")
assert flush is not None
assert flush.text == "abcdefgh"
assert flush.reason == "size"
assert flush.delta_count == 1
assert plane.max_buffer_chars <= 10
tail = plane.finish()
assert tail is not None
assert tail.text == "ijklm"
assert tail.delta_count == 1
def test_streaming_plane_overflow_delta_with_own_flush_condition_drains_immediately() -> None:
clock = MutableClock()
plane = StreamingPlane(
policy=StreamingFlushPolicy(max_delay_ms=10_000, max_chars=10, newline_min_chars=2),
clock_ms=clock,
)
assert plane.append("abcdefgh") is None
flush = plane.append("ij\n")
# The delta carries its own flush condition (newline past the minimum), so
# it must not be retained unrendered behind the size flush.
assert flush is not None
assert flush.text == "abcdefghij\n"
assert flush.delta_count == 2
assert plane.finish() is None
def test_streaming_plane_flush_drains_stale_buffer_for_heartbeats() -> None:
clock = MutableClock()
plane = StreamingPlane(
policy=StreamingFlushPolicy(max_delay_ms=33, max_chars=100, newline_min_chars=100),
clock_ms=clock,
)
assert plane.append("tail") is None
assert plane.flush() is None # delay budget not exhausted yet
clock.advance(34)
flush = plane.flush()
assert flush is not None
assert flush.text == "tail"
assert flush.reason == "delay"
assert plane.flush() is None # buffer drained
def test_streaming_flush_reports_deltas_coalesced_per_flush() -> None:
clock = MutableClock()
events: list[TuiDomainEvent] = []
plane = StreamingPlane(
policy=StreamingFlushPolicy(max_delay_ms=10_000, max_chars=6, newline_min_chars=1_000),
clock_ms=clock,
event_sink=events.append,
)
assert plane.append("ab") is None
assert plane.append("cd") is None
first = plane.append("ef")
assert plane.append("gh") is None
second = plane.append("ijkl")
assert first is not None and first.delta_count == 3
assert second is not None and second.delta_count == 2
assert plane.delta_count == 5 # plane attribute stays cumulative
assert [event.payload["delta_count"] for event in events] == [3, 2]
assert [event.payload["flush_count"] for event in events] == [1, 2]
class _PlaneRecordingRenderer:
def __init__(self) -> None:
self.calls: list[tuple[str, str]] = []
async def aappend_text(self, text: str, presentation: str = "answer") -> None:
self.calls.append(("text", text))
async def aappend_reasoning(self, text: str) -> None:
self.calls.append(("reasoning", text))
@pytest.mark.asyncio
async def test_heartbeat_drain_holds_text_tail_while_reasoning_is_mid_stream() -> None:
clock = MutableClock()
policy = StreamingFlushPolicy(max_delay_ms=33, max_chars=2_048, newline_min_chars=256)
text_plane = StreamingPlane(policy=policy, clock_ms=clock)
reasoning_plane = StreamingPlane(policy=policy, clock_ms=clock)
renderer = _PlaneRecordingRenderer()
assert text_plane.append("answer tail") is None
assert reasoning_plane.append("mid-thought") is None
clock.advance(34) # both tails now satisfy the stalled-delay condition
# While reasoning is mid-stream the buffered text tail must stay buffered:
# rendering it would close the open thinking marker and split the thought
# into two blocks.
await _drain_stalled_planes(renderer, text_plane, reasoning_plane, reasoning_mid_stream=True)
assert renderer.calls == [("reasoning", "mid-thought")]
# Once reasoning is no longer the active stream the tail drains normally.
clock.advance(34)
await _drain_stalled_planes(renderer, text_plane, reasoning_plane, reasoning_mid_stream=False)
assert renderer.calls == [("reasoning", "mid-thought"), ("text", "answer tail")]
# ---------------------------------------------------------------------------
# render_summary sanitization
# ---------------------------------------------------------------------------
def test_sanitizer_strips_unterminated_string_sequences() -> None:
assert sanitize_terminal_text("\x1b]0;evil title") == ""
assert sanitize_terminal_text("\x1bPq#0;2;0;0;0 payload") == ""
assert sanitize_terminal_text("\x1bXsos payload") == ""
# a sequence cut mid-way must not swallow the lines that follow it
assert sanitize_terminal_text("\x1b]0;cut\nreal output") == "\nreal output"
def test_sanitizer_strips_terminated_apc_pm_sos_payloads() -> None:
assert sanitize_terminal_text("\x1b_Ga=T,f=100;QUJDREVGRw==\x1b\\after") == "after"
assert sanitize_terminal_text("\x1b^privacy message\x1b\\ok") == "ok"
assert sanitize_terminal_text("\x1bXstart of string\x1b\\done") == "done"
def test_sanitizer_strips_8bit_c1_controls() -> None:
assert sanitize_terminal_text("\x9b31mred\x9b0m plain") == "red plain"
assert sanitize_terminal_text("\x9d0;title\x9cok") == "ok"
assert sanitize_terminal_text("a\x85b\x9cc") == "abc"
assert summarize_result("\x9b31mred\x9b0m plain") == "red plain"
# ---------------------------------------------------------------------------
# render_summary tool-arg summaries
# ---------------------------------------------------------------------------
def test_summarize_args_apply_patch_shows_first_target_file() -> None:
patch = (
"*** Begin Patch\n"
"*** Update File: src/app.py\n"
"@@@ hunk\n"
"*** Delete File: src/old.py\n"
"*** End Patch"
)
assert summarize_args("apply_patch", {"patch": patch}) == "src/app.py"
assert (
summarize_args("apply_patch", {"patch": "*** Begin Patch\n*** Add File: pkg/new.py"})
== "pkg/new.py"
)
assert summarize_args("apply_patch", {"patch": "no file markers"}) == ""
assert summarize_args("apply_patch", {}) == ""
def test_summarize_args_generic_fallback_covers_unlisted_tools() -> None:
assert summarize_args("edit_file", {"path": "/ws/notes.txt", "old_text": "a"}) == (
"/ws/notes.txt"
)
assert summarize_args("grep_search", {"pattern": "TODO"}) == "TODO"
assert summarize_args("http_request", {"url": "https://example.com"}) == (
"https://example.com"
)
assert summarize_args("unknown_tool", {"blob": "x"}) == ""
def test_summarize_args_generic_fallback_flattens_multiline_values() -> None:
# The tool row is a single line: multiline code/command values must not
# leak raw newlines into it.
assert summarize_args("run_python", {"code": "print(1)\nprint(2)"}) == "print(1) print(2)"
assert summarize_args("custom_exec", {"command": "ls -a\n\techo done"}) == "ls -a echo done"
long_summary = summarize_args("custom_exec", {"command": "a\n" + "b" * 200})
assert "\n" not in long_summary
assert summarize_args("custom_exec", {"command": " \n \n "}) == ""
def test_summarize_args_names_conform_to_builtin_registry() -> None:
import opensquilla.tools.builtin # noqa: F401 - registers the builtin tools
from opensquilla.tools.registry import get_default_registry
registry = get_default_registry()
samples = {"patch": "*** Begin Patch\n*** Update File: pkg/mod.py\n*** End Patch"}
for name, keys in TOOL_SUMMARY_ARG_KEYS.items():
registered = registry.get(name)
assert registered is not None, f"summarize_args names unknown tool {name!r}"
for key in keys:
assert key in registered.spec.parameters, (
f"tool {name!r} does not declare summarized argument {key!r}"
)
value = samples.get(keys[0], "sample-value")
assert summarize_args(name, {keys[0]: value}), (
f"tool {name!r} renders an empty arg summary"
)
# ---------------------------------------------------------------------------
# render_summary cell-aware clipping
# ---------------------------------------------------------------------------
def test_clip_arg_clips_by_display_cells_not_code_points() -> None:
wide = "" * 60 # 120 terminal cells
clipped = clip_arg(wide, limit=90)
assert clipped == "" * 43 + "..."
tail_clipped = clip_arg(wide, limit=90, keep_end=True)
assert tail_clipped == "..." + "" * 43
ascii_value = "a" * 90
assert clip_arg(ascii_value, limit=90) == ascii_value
def test_clip_arg_never_splits_emoji_clusters() -> None:
cluster = "\U0001f469\u200d\U0001f469"
clipped = clip_arg("a" * 88 + cluster, limit=90)
assert clipped == "a" * 87 + "..."
assert "\u200d" not in clipped
leading = clip_arg(cluster + "a" * 100, limit=20)
assert leading.startswith(cluster)
assert leading == cluster + "a" * 13 + "..."
# ---------------------------------------------------------------------------
# render_summary short results
# ---------------------------------------------------------------------------
def test_summarize_result_keeps_sole_short_results() -> None:
assert summarize_result("7\nexit_code=0") == "7"
assert summarize_result("y") == "y"
assert summarize_result("!!") == "!!"
def test_summarize_result_still_drops_noise_next_to_meaningful_lines() -> None:
assert summarize_result("exit_code=0\n.\nagents") == "agents"
assert summarize_result("exit_code=0") == ""
def test_summarize_result_prefers_meaningful_short_line_over_punctuation_noise() -> None:
# The computed value usually trails the noise: a spinner/separator line
# must not win over the short result the fallback exists to preserve.
assert summarize_result("...\n7") == "7"
assert summarize_result("exit_code=0\n...\n7") == "7"
assert summarize_result("7\n...") == "7"
# With nothing but punctuation on offer, the first line still renders.
assert summarize_result("...\n??") == "..."
# ---------------------------------------------------------------------------
# domain event kind registry
# ---------------------------------------------------------------------------
def test_domain_event_kind_registry_contains_every_kind_constant() -> None:
kind_values = {
value
for name, value in vars(domain_events).items()
if name.startswith("KIND_") and isinstance(value, str)
}
assert kind_values == set(TUI_DOMAIN_EVENT_KINDS)
assert domain_events.KIND_REASONING_FLUSH in TUI_DOMAIN_EVENT_KINDS
assert domain_events.KIND_REASONING_DELTA in TUI_DOMAIN_EVENT_KINDS
# ---------------------------------------------------------------------------
# plugin error ledger
# ---------------------------------------------------------------------------
class _ExplodingPlugin:
plugin_id = "exploding"
slots: frozenset[str] = frozenset()
def on_event(self, event: TuiDomainEvent, context: TuiPluginContext) -> None:
raise RuntimeError("plugin exploded")
def snapshot(self, slot: str) -> object | None:
return None
class _LogStub:
def __init__(self) -> None:
self.warnings: list[tuple[str, dict[str, Any]]] = []
def warning(self, event: str, **kwargs: Any) -> None:
self.warnings.append((event, kwargs))
def _status_event() -> TuiDomainEvent:
return TuiDomainEvent(
kind="status",
source="runtime",
payload={},
turn_id=None,
timestamp_ms=now_ms(),
)
def test_plugin_error_ledger_is_bounded_and_warns_once_per_plugin(
monkeypatch: pytest.MonkeyPatch,
) -> None:
stub = _LogStub()
monkeypatch.setattr(plugins, "log", stub)
manager = TuiPluginManager([_ExplodingPlugin()])
for _ in range(plugins._MAX_RECORDED_ERRORS + 50):
manager.dispatch(_status_event())
assert len(manager.errors) == plugins._MAX_RECORDED_ERRORS
assert [event for event, _ in stub.warnings] == ["tui_plugin.error"]
assert stub.warnings[0][1]["plugin_id"] == "exploding"
# ---------------------------------------------------------------------------
# transcript previews and ids
# ---------------------------------------------------------------------------
def test_tool_previews_degrade_on_non_json_serializable_values() -> None:
policy = ToolPreviewPolicy()
args_preview = build_args_preview({"path": Path("/ws/sample.txt")}, policy)
assert "sample.txt" in args_preview.text
output_preview = build_output_preview(b"binary blob", policy)
assert "binary blob" in output_preview.text
circular: dict[str, object] = {}
circular["self"] = circular
assert build_args_preview(circular, policy).text
def test_tool_previews_strip_terminal_controls() -> None:
policy = ToolPreviewPolicy()
output_preview = build_output_preview("\x1b[31mred\x1b[0m done", policy)
assert output_preview.text == "red done"
args_preview = build_args_preview({"note": "\x9b31mred"}, policy)
assert "red" in args_preview.text
assert "\x9b" not in args_preview.text
def _tool_item(tool_id: str, status: str) -> ToolItem:
return ToolItem(
tool_id=tool_id,
name="search",
status=status,
args_preview="{}",
output_preview="",
expanded=False,
timestamp_ms=1,
)
def test_transcript_store_uniquifies_repeated_tool_ids() -> None:
store = TranscriptStore()
first = store.append(_tool_item("call-1", "running"))
second = store.append(_tool_item("call-1", "done"))
third = store.append(_tool_item("call-1", "done"))
assert first.item_id == "tool-call-1"
assert second.item_id == "tool-call-1-2"
assert third.item_id == "tool-call-1-3"
assert len({item.item_id for item in store.snapshot()}) == 3
store.clear()
again = store.append(_tool_item("call-1", "running"))
assert again.item_id == "tool-call-1"
# ---------------------------------------------------------------------------
# StreamDirectiveFilter (backend/directives.py)
# ---------------------------------------------------------------------------
def _filtered(deltas: list[str]) -> str:
stream_filter = StreamDirectiveFilter()
visible = "".join(stream_filter.feed(delta) for delta in deltas)
return visible + stream_filter.flush()
def test_directive_filter_strips_whole_and_split_tags() -> None:
assert _filtered(["[[reply_to_current]]\nhello"]) == "hello"
assert _filtered(["[reply_to_current]\nhello"]) == "hello"
assert _filtered(["[[reply_to", "_current]]", " hi"]) == "hi"
assert _filtered(["[[reply_to_current]", "]", "answer"]) == "answer"
assert _filtered(["[[reply_to: chat-42]] body"]) == "body"
assert _filtered(["pre [[reply_to_current]] post"]) == "pre post"
assert _filtered(list("[[reply_to_current]] char by char")) == "char by char"
def test_directive_filter_keeps_ordinary_bracketed_text() -> None:
assert _filtered(["a [link](url) stays"]) == "a [link](url) stays"
assert _filtered(["array[0] = 1"]) == "array[0] = 1"
assert _filtered(["[re", "gular text]"]) == "[regular text]"
assert _filtered(["[reply_to_curious]"]) == "[reply_to_curious]"
# An unbalanced close can no longer become a tag — rendered literally.
assert _filtered(["[[reply_to_current]x leak"]) == "[[reply_to_current]x leak"
def test_directive_filter_flush_releases_unfinished_prefix() -> None:
stream_filter = StreamDirectiveFilter()
assert stream_filter.feed("ends with [[re") == "ends with "
assert stream_filter.flush() == "[[re"
+123
View File
@@ -0,0 +1,123 @@
from __future__ import annotations
import ast
import importlib
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[4]
SRC_ROOT = PROJECT_ROOT / "src/opensquilla/cli"
TUI_ROOT = SRC_ROOT / "tui"
REMOVED_TEXT_BACKEND = "text" + "ual"
REMOVED_TERMINAL = "terminal"
PROMPT_TOOLKIT = "prompt" + "_toolkit"
REMOVED_FRONTEND_PATHS = (
TUI_ROOT / "terminal",
TUI_ROOT / REMOVED_TEXT_BACKEND,
TUI_ROOT / "app.py",
TUI_ROOT / "prompt.py",
TUI_ROOT / "paste.py",
TUI_ROOT / "stream.py",
TUI_ROOT / "signal_handlers.py",
TUI_ROOT / f"{REMOVED_TERMINAL}_bridge.py",
TUI_ROOT / f"{REMOVED_TERMINAL}_chat_adapter.py",
TUI_ROOT / f"{REMOVED_TERMINAL}_renderer.py",
TUI_ROOT / f"{REMOVED_TERMINAL}_surface.py",
TUI_ROOT / f"adapters/{REMOVED_TERMINAL}_bridge.py",
TUI_ROOT / f"adapters/{REMOVED_TERMINAL}_chat_adapter.py",
TUI_ROOT / f"adapters/{REMOVED_TEXT_BACKEND}_bridge.py",
TUI_ROOT / f"renderers/{REMOVED_TEXT_BACKEND}_backend.py",
SRC_ROOT / f"repl/{REMOVED_TERMINAL}_bridge.py",
SRC_ROOT / f"repl/{REMOVED_TERMINAL}_chat_adapter.py",
SRC_ROOT / f"repl/{REMOVED_TERMINAL}_renderer.py",
SRC_ROOT / f"repl/{REMOVED_TERMINAL}_surface.py",
)
def _imported_modules(path: Path) -> set[str]:
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
modules: set[str] = set()
for node in ast.walk(tree):
if isinstance(node, ast.Import):
modules.update(alias.name for alias in node.names)
continue
if isinstance(node, ast.ImportFrom) and node.module is not None:
modules.add(node.module)
return modules
def _live_tui_python_paths() -> list[Path]:
return [
path
for path in TUI_ROOT.rglob("*.py")
if "__pycache__" not in path.parts
]
def test_removed_frontend_files_are_absent() -> None:
assert [path for path in REMOVED_FRONTEND_PATHS if path.exists()] == []
def test_live_tui_modules_do_not_import_removed_frontends() -> None:
forbidden_prefixes = (
f"opensquilla.cli.tui.{REMOVED_TERMINAL}",
f"opensquilla.cli.tui.{REMOVED_TEXT_BACKEND}",
f"opensquilla.cli.tui.adapters.{REMOVED_TERMINAL}_bridge",
f"opensquilla.cli.tui.adapters.{REMOVED_TERMINAL}_chat_adapter",
f"opensquilla.cli.tui.adapters.{REMOVED_TEXT_BACKEND}_bridge",
f"opensquilla.cli.repl.{REMOVED_TERMINAL}_bridge",
f"opensquilla.cli.repl.{REMOVED_TERMINAL}_chat_adapter",
f"opensquilla.cli.repl.{REMOVED_TERMINAL}_renderer",
f"opensquilla.cli.repl.{REMOVED_TERMINAL}_surface",
PROMPT_TOOLKIT,
REMOVED_TEXT_BACKEND,
)
offenders: dict[str, list[str]] = {}
for path in _live_tui_python_paths():
imports = sorted(
module
for module in _imported_modules(path)
if module == REMOVED_TEXT_BACKEND
or any(
module == prefix or module.startswith(f"{prefix}.")
for prefix in forbidden_prefixes
)
)
if imports:
offenders[str(path.relative_to(PROJECT_ROOT))] = imports
assert offenders == {}
def test_shared_tui_contracts_remain_importable() -> None:
modules = (
"opensquilla.cli.tui.backend.contracts",
"opensquilla.cli.tui.backend.runtime",
"opensquilla.cli.tui.backend.streaming",
"opensquilla.cli.tui.backend.transcript",
"opensquilla.cli.tui.backend.render_summary",
"opensquilla.cli.tui.plugins",
"opensquilla.cli.tui.plugins.router_hud",
"opensquilla.cli.tui.adapters.runtime_helpers",
"opensquilla.cli.tui.adapters.runtime_bridge",
"opensquilla.cli.tui.opentui.runtime",
"opensquilla.cli.tui.opentui.renderer",
)
for module in modules:
assert importlib.import_module(module)
def test_tui_package_exports_only_neutral_and_opentui_surfaces() -> None:
import opensquilla.cli.tui as tui
exported = set(tui.__all__)
assert "backend" in exported
assert "opentui" in exported
assert "turn_bridge" in exported
assert "terminal" not in exported
assert REMOVED_TEXT_BACKEND not in exported
assert not any(name.startswith("terminal_") for name in exported)
@@ -0,0 +1,290 @@
from __future__ import annotations
import importlib
from collections.abc import AsyncIterator, Callable
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any
import pytest
from opensquilla.cli.tui.adapters import runtime_helpers
from opensquilla.cli.tui.backend.domain_events import (
KIND_ROUTER_DECISION,
TuiDomainEvent,
now_ms,
)
from opensquilla.cli.tui.native import runtime as native_runtime
from opensquilla.cli.tui.native.renderer import status_markup
from opensquilla.cli.tui.plugins.router_hud import (
ROUTER_HUD_SLOT,
RouterHudSnapshot,
build_router_hud_snapshot,
)
from opensquilla.engine.commands import Surface
class _FakeOutputHandle:
approval_surface = Surface.CLI_GATEWAY
def __init__(self) -> None:
self.writes: list[str] = []
async def write_through(self, payload: str) -> None:
self.writes.append(payload)
def stream_output(self):
@asynccontextmanager
async def _cm() -> AsyncIterator[Callable[[str], None]]:
yield lambda _payload: None
return _cm()
class _FakeNativeSurface:
def __init__(self) -> None:
self.output_handle = _FakeOutputHandle()
async def next_line(self) -> str | None:
return None
def set_cancel_callback(self, cb: Callable[[], None] | None) -> None:
return None
def set_shutdown_callback(self, cb: Callable[[], None] | None) -> None:
return None
def emit_eof(self) -> None:
return None
async def write_through(self, payload: str) -> None:
await self.output_handle.write_through(payload)
@property
def redraw_callback(self) -> Callable[[], None]:
return lambda: None
def _surface_factory_for(fake_surface: _FakeNativeSurface):
@asynccontextmanager
async def _factory() -> AsyncIterator[_FakeNativeSurface]:
yield fake_surface
return _factory
async def _dispatch(_value: str) -> bool:
return True
def _router_event(payload: dict[str, object]) -> TuiDomainEvent:
return TuiDomainEvent(
kind=KIND_ROUTER_DECISION,
source="gateway",
payload=payload,
turn_id="agent:main:test",
timestamp_ms=now_ms(),
)
@pytest.mark.asyncio
async def test_native_chat_runtime_exposes_tui_output_and_blocks_concurrent_input(
monkeypatch: pytest.MonkeyPatch,
) -> None:
scope: dict[str, Any] = {"model": "model-a", "session_key": "session-a"}
captured: dict[str, Any] = {}
fake_surface = _FakeNativeSurface()
async def fake_run_tui_runtime(**kwargs: Any) -> None:
captured["runtime_kwargs"] = kwargs
async with kwargs["surface_factory"]() as yielded:
assert yielded is fake_surface
captured["provider_during_run"] = scope.get("pending_input_provider")
hooks = kwargs["hooks"]
assert runtime_helpers.get_tui_output(scope) is None
hooks.expose_surface(fake_surface)
output = runtime_helpers.get_tui_output(scope)
captured["output"] = output
captured["manager"] = getattr(output, "plugin_manager", None)
hooks.clear_exposed_surface()
monkeypatch.setattr(native_runtime, "run_tui_runtime", fake_run_tui_runtime)
await native_runtime.run_native_chat_runtime(
surface=Surface.CLI_GATEWAY,
scope=scope,
dispatch=_dispatch,
queue_max_size=8,
surface_factory=_surface_factory_for(fake_surface),
)
config = captured["runtime_kwargs"]["config"]
assert config.concurrent_input_during_turn is False
assert config.task_name == "chat-turn-cli_gateway"
assert config.queue_max_size == 8
assert captured["runtime_kwargs"]["dispatch"] is _dispatch
assert captured["provider_during_run"] is config.state
assert "pending_input_provider" not in scope
assert runtime_helpers.get_tui_output(scope) is None
assert getattr(captured["output"], "_output_handle", None) is fake_surface.output_handle
assert captured["manager"] is not None
@pytest.mark.asyncio
async def test_native_notice_before_surface_exposure_is_a_noop(
monkeypatch: pytest.MonkeyPatch,
) -> None:
scope: dict[str, Any] = {}
fake_surface = _FakeNativeSurface()
async def fake_run_tui_runtime(**kwargs: Any) -> None:
kwargs["hooks"].notice("[yellow]too early[/yellow]")
monkeypatch.setattr(native_runtime, "run_tui_runtime", fake_run_tui_runtime)
await native_runtime.run_native_chat_runtime(
surface=Surface.CLI_STANDALONE,
scope=scope,
dispatch=_dispatch,
queue_max_size=4,
surface_factory=_surface_factory_for(fake_surface),
)
assert fake_surface.output_handle.writes == []
@pytest.mark.asyncio
async def test_native_exit_notice_scheduled_without_await_is_flushed(
monkeypatch: pytest.MonkeyPatch,
) -> None:
scope: dict[str, Any] = {}
fake_surface = _FakeNativeSurface()
async def fake_run_tui_runtime(**kwargs: Any) -> None:
hooks = kwargs["hooks"]
hooks.expose_surface(fake_surface)
# Goodbye is emitted right before the backend returns, with no further
# suspension point: the adapter must drain the scheduled write itself.
hooks.notice("[yellow]Goodbye.[/yellow]")
monkeypatch.setattr(native_runtime, "run_tui_runtime", fake_run_tui_runtime)
await native_runtime.run_native_chat_runtime(
surface=Surface.CLI_GATEWAY,
scope=scope,
dispatch=_dispatch,
queue_max_size=8,
surface_factory=_surface_factory_for(fake_surface),
)
assert fake_surface.output_handle.writes == ["[yellow]Goodbye.[/yellow]"]
@pytest.mark.asyncio
async def test_native_chat_runtime_drains_notices_and_pops_provider_on_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
scope: dict[str, Any] = {}
fake_surface = _FakeNativeSurface()
async def fake_run_tui_runtime(**kwargs: Any) -> None:
hooks = kwargs["hooks"]
hooks.expose_surface(fake_surface)
hooks.notice("[red]Input surface error[/red]")
raise RuntimeError("surface crashed")
monkeypatch.setattr(native_runtime, "run_tui_runtime", fake_run_tui_runtime)
with pytest.raises(RuntimeError, match="surface crashed"):
await native_runtime.run_native_chat_runtime(
surface=Surface.CLI_GATEWAY,
scope=scope,
dispatch=_dispatch,
queue_max_size=8,
surface_factory=_surface_factory_for(fake_surface),
)
assert "pending_input_provider" not in scope
assert fake_surface.output_handle.writes == ["[red]Input surface error[/red]"]
@pytest.mark.asyncio
async def test_native_router_decision_renders_status_line_through_output_handle(
monkeypatch: pytest.MonkeyPatch,
) -> None:
scope: dict[str, Any] = {}
captured: dict[str, Any] = {}
fake_surface = _FakeNativeSurface()
async def fake_run_tui_runtime(**kwargs: Any) -> None:
hooks = kwargs["hooks"]
hooks.expose_surface(fake_surface)
output = runtime_helpers.get_tui_output(scope)
assert output is not None
manager = output.plugin_manager
# Canonical tier without tier_index: the gateway variant that used to
# yield tier_index -1 must resolve through the shared tier helpers.
manager.dispatch(
_router_event(
{
"tier": "c2",
"model": "provider-x/model-fast",
"baseline_model": "provider-x/model-big",
"source": "router",
"confidence": 0.7,
"savings_pct": 40.0,
}
)
)
captured["snapshot"] = manager.snapshot(ROUTER_HUD_SLOT)
monkeypatch.setattr(native_runtime, "run_tui_runtime", fake_run_tui_runtime)
await native_runtime.run_native_chat_runtime(
surface=Surface.CLI_GATEWAY,
scope=scope,
dispatch=_dispatch,
queue_max_size=8,
surface_factory=_surface_factory_for(fake_surface),
)
snapshot = captured["snapshot"]
assert isinstance(snapshot, RouterHudSnapshot)
assert snapshot.tier_index == 2
assert fake_surface.output_handle.writes == [
"[default]route c2 -> model-fast 70% save 40%[/default]\n"
]
def test_router_snapshot_tier_index_accepts_canonical_and_legacy_tiers() -> None:
assert build_router_hud_snapshot({"tier": "c2"}).tier_index == 2
assert build_router_hud_snapshot({"tier": "C0"}).tier_index == 0
assert build_router_hud_snapshot({"tier": "t1"}).tier_index == 1
assert build_router_hud_snapshot({"tier": "mystery"}).tier_index == -1
assert build_router_hud_snapshot({"tier": "c2", "tier_index": 3}).tier_index == 3
def test_status_markup_escapes_message_and_maps_styles() -> None:
assert status_markup("route c2 -> m", style="normal") == "[default]route c2 -> m[/default]\n"
assert status_markup("fallback [x]", style="warning") == "[yellow]fallback \\[x][/yellow]\n"
assert status_markup("note", style="mystery") == "[dim]note[/dim]\n"
def test_tui_alias_surface_matches_disk_and_every_export_imports() -> None:
import opensquilla.cli.tui as tui
package_root = Path(tui.__file__).resolve().parent
modules = {
path.stem for path in package_root.glob("*.py") if path.name != "__init__.py"
}
subpackages = {
path.name
for path in package_root.iterdir()
if path.is_dir() and (path / "__init__.py").exists()
}
assert set(tui.__all__) == modules | subpackages
assert "events" not in tui.__all__
assert "runtime" not in tui.__all__
for name in tui.__all__:
assert importlib.import_module(f"opensquilla.cli.tui.{name}")
+91
View File
@@ -0,0 +1,91 @@
"""Native-backend Ctrl+C handling: a SIGINT handler cancels the in-flight turn
and never exits the session (Ctrl+D still owns exit), fixing the old behavior
where a single Ctrl+C at the prompt was treated as EOF and quit chat."""
from __future__ import annotations
import asyncio
import os
import signal
import sys
import pytest
from opensquilla.cli.tui.adapters import native_bridge
from opensquilla.cli.tui.adapters.native_bridge import (
NativeTerminalSurface,
open_native_terminal_surface,
)
from opensquilla.engine.commands import Surface
@pytest.mark.asyncio
async def test_ctrl_d_ends_the_session(monkeypatch: pytest.MonkeyPatch) -> None:
def fake_input(_prompt: str) -> str:
raise EOFError
monkeypatch.setattr(native_bridge.console, "input", fake_input)
surface = NativeTerminalSurface(approval_surface=Surface.CLI_GATEWAY)
shutdown: list[bool] = []
surface.set_shutdown_callback(lambda: shutdown.append(True))
assert await surface.next_line() is None
assert shutdown == [True]
@pytest.mark.asyncio
async def test_keyboardinterrupt_fallback_cancels_and_reprompts(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# The fallback path (used where add_signal_handler is unavailable): a
# KeyboardInterrupt must cancel the in-flight turn and re-prompt, never exit.
calls = 0
def fake_input(_prompt: str) -> str:
nonlocal calls
calls += 1
if calls == 1:
raise KeyboardInterrupt
return "after the interrupt"
monkeypatch.setattr(native_bridge.console, "input", fake_input)
surface = NativeTerminalSurface(approval_surface=Surface.CLI_GATEWAY)
cancelled: list[bool] = []
shutdown: list[bool] = []
surface.set_cancel_callback(lambda: cancelled.append(True))
surface.set_shutdown_callback(lambda: shutdown.append(True))
assert await surface.next_line() == "after the interrupt"
assert cancelled == [True]
assert shutdown == [] # never shuts down on Ctrl+C
def test_on_sigint_cancels_turn_without_exiting() -> None:
surface = NativeTerminalSurface(approval_surface=Surface.CLI_GATEWAY)
cancelled: list[bool] = []
shutdown: list[bool] = []
surface.set_cancel_callback(lambda: cancelled.append(True))
surface.set_shutdown_callback(lambda: shutdown.append(True))
surface._on_sigint()
assert cancelled == [True]
assert shutdown == []
@pytest.mark.asyncio
@pytest.mark.skipif(
sys.platform.startswith("win"),
reason="Windows delivers SIGINT to pytest itself; fallback behavior is covered separately",
)
async def test_real_sigint_cancels_turn_and_does_not_exit() -> None:
cancelled: list[bool] = []
shutdown: list[bool] = []
async with open_native_terminal_surface(surface=Surface.CLI_GATEWAY) as surface:
surface.set_cancel_callback(lambda: cancelled.append(True))
surface.set_shutdown_callback(lambda: shutdown.append(True))
os.kill(os.getpid(), signal.SIGINT) # the real Ctrl+C signal
await asyncio.sleep(0.05) # let the loop run the installed handler
assert cancelled == [True] # the turn was cancelled
assert shutdown == [] # the session did NOT exit
+177
View File
@@ -0,0 +1,177 @@
from __future__ import annotations
import pytest
from opensquilla.cli.tui.adapters.native_bridge import NativeTerminalOutputHandle
from opensquilla.cli.tui.native.renderer import NativeStreamRenderer
from opensquilla.engine.commands import Surface
from opensquilla.ui import ACCENT
class _RecordingOutputHandle:
approval_surface = object()
def __init__(self) -> None:
self.writes: list[str] = []
async def write_through(self, payload: str) -> None:
self.writes.append(payload)
def stream_output(self):
raise AssertionError("native renderer writes through directly")
@pytest.mark.asyncio
async def test_native_renderer_writes_answer_text_to_terminal_output() -> None:
output = _RecordingOutputHandle()
renderer = NativeStreamRenderer(output_handle=output)
renderer.__enter__()
await renderer.aappend_text("hello", presentation="answer")
await renderer.aappend_text(" there", presentation="answer")
await renderer.afinalize(None)
assert renderer.buffer == "hello there"
assert output.writes == ["hello", " there", "\n"]
@pytest.mark.asyncio
async def test_native_tool_start_separates_from_midline_answer_text() -> None:
# A common "I'll check…" + tool-call sequence: aappend_text streams prose with
# no trailing newline, so atool_start must break to a fresh line or the glyph
# collides with the text ("I'll check⚙ grep").
output = _RecordingOutputHandle()
renderer = NativeStreamRenderer(output_handle=output)
renderer.__enter__()
await renderer.aappend_text("I'll check", presentation="answer")
await renderer.atool_start("grep", tool_use_id="t1")
joined = "".join(output.writes)
assert "I'll check⚙" not in joined # no collision
assert "I'll check\n" in joined # a separator was inserted before the tool row
assert f"[{ACCENT}]⚙ grep[/]" in joined
@pytest.mark.asyncio
async def test_native_tool_start_adds_no_blank_line_when_already_at_line_start() -> None:
# When prose already ended with a newline, the separator must not add a second
# (a spurious blank line before the tool row).
output = _RecordingOutputHandle()
renderer = NativeStreamRenderer(output_handle=output)
renderer.__enter__()
await renderer.aappend_text("done\n", presentation="answer")
await renderer.atool_start("grep", tool_use_id="t1")
assert "\n\n" not in "".join(output.writes)
@pytest.mark.asyncio
async def test_native_renderer_pulse_is_a_safe_no_op() -> None:
# The shared turn-stream loop calls renderer.pulse() unconditionally on every
# heartbeat; the native renderer must define it so a quiet turn does not raise
# AttributeError and tear down the session.
renderer = NativeStreamRenderer(output_handle=_RecordingOutputHandle())
assert renderer.pulse() is None
@pytest.mark.asyncio
async def test_native_renderer_escapes_bracketed_text_without_markup_error() -> None:
# Real console + handle: bracketed model output (paths, markup-like tokens)
# must render literally instead of raising MarkupError or being restyled.
from opensquilla.ui import console
handle = NativeTerminalOutputHandle(approval_surface=Surface.CLI_STANDALONE)
renderer = NativeStreamRenderer(output_handle=handle)
renderer.__enter__()
payload = "see [/usr/local/bin] then [dim]styled[/dim] and arr[i]"
with console.capture() as capture:
await renderer.aappend_text(payload)
renderer.pulse()
await renderer.afinalize(None)
rendered = capture.get()
assert "[/usr/local/bin]" in rendered
assert "[dim]styled[/dim]" in rendered
assert "arr[i]" in rendered
# buffer keeps the raw assistant text for TurnResult, unescaped.
assert renderer.buffer == payload
@pytest.mark.asyncio
async def test_native_renderer_renders_reasoning_dimmed_then_separates_answer() -> None:
output = _RecordingOutputHandle()
renderer = NativeStreamRenderer(output_handle=output)
await renderer.aappend_reasoning("pondering")
await renderer.aappend_text("answer")
assert output.writes == [
"[dim]✻ Thinking[/dim]\n",
"[dim]pondering[/dim]",
"\n",
"answer",
]
# reasoning is not part of the assistant answer buffer.
assert renderer.buffer == "answer"
@pytest.mark.asyncio
async def test_native_renderer_renders_status_with_mapped_style_and_escaping() -> None:
output = _RecordingOutputHandle()
renderer = NativeStreamRenderer(output_handle=output)
await renderer.astatus("Still working", style="dim")
await renderer.astatus("oops [x]", style="error")
assert output.writes[0] == "[dim]Still working[/dim]\n"
assert output.writes[1] == "[red]oops \\[x][/red]\n"
@pytest.mark.asyncio
async def test_native_renderer_reports_tool_completion_with_elapsed() -> None:
output = _RecordingOutputHandle()
renderer = NativeStreamRenderer(output_handle=output)
await renderer.atool_start("grep", tool_use_id="t1")
await renderer.atool_finished("t1", success=True, elapsed=1.25)
await renderer.atool_start("rm", tool_use_id="t2")
await renderer.atool_finished("t2", success=False, error="bad [path]")
assert output.writes == [
f"[{ACCENT}]⚙ grep[/]\n",
"[dim] ✓ grep[/dim] [dim](1.2s)[/dim]\n",
f"[{ACCENT}]⚙ rm[/]\n",
"[red] ✗ rm: bad \\[path][/red]\n",
]
@pytest.mark.asyncio
async def test_native_renderer_strips_routing_directive_tags() -> None:
output = _RecordingOutputHandle()
renderer = NativeStreamRenderer(output_handle=output)
await renderer.aappend_text("[[reply_to")
await renderer.aappend_text("_current]]\n")
await renderer.aappend_text("My name is OpenSquilla.")
await renderer.afinalize()
joined = "".join(output.writes)
assert "reply_to_current" not in joined
assert "My name is OpenSquilla." in joined
# The logical buffer (TurnResult text) keeps the model's exact output.
assert "[[reply_to_current]]" in renderer.buffer
@pytest.mark.asyncio
async def test_native_renderer_flushes_held_bracket_prefix_before_a_tool_row() -> None:
output = _RecordingOutputHandle()
renderer = NativeStreamRenderer(output_handle=output)
await renderer.aappend_text("see [[re") # held: could still become a tag
await renderer.atool_start("grep", tool_use_id="t1")
await renderer.afinalize()
joined = "".join(output.writes)
# The held prefix proved to be ordinary text and prints BEFORE the tool row.
assert joined.index("[[re") < joined.index("grep")
+401
View File
@@ -0,0 +1,401 @@
from __future__ import annotations
import asyncio
import io
import json
import os
import sys
import threading
import pytest
from opensquilla.cli.tui import opentui as _opentui_pkg # noqa: F401 (ensure package import)
from opensquilla.cli.tui.opentui import bridge as bridge_module
from opensquilla.cli.tui.opentui.bridge import (
OpenTuiBridge,
OpenTuiBridgeError,
OpenTuiHostPaths,
check_opentui_host_available,
)
from opensquilla.cli.tui.opentui.messages import (
HostInputSubmit,
HostToPythonMessageError,
ScrollbackWrite,
)
from opensquilla.cli.tui.renderers.selection import RendererBackendAvailability
def test_missing_opentui_host_dependencies_report_install_command(tmp_path, monkeypatch) -> None:
package_dir = tmp_path / "package"
package_dir.mkdir()
monkeypatch.setattr(bridge_module.os, "name", "posix")
monkeypatch.setattr(bridge_module.shutil, "which", lambda cmd: f"/usr/bin/{cmd}")
availability = check_opentui_host_available(package_dir=package_dir, runtime_bin="bun")
assert availability.available is False
assert availability.reason is not None
assert "@opentui/core" in availability.reason
assert f"bun install --cwd {package_dir}" in availability.reason
def test_opentui_host_reports_fd_bridge_unsupported_on_windows(tmp_path, monkeypatch) -> None:
package_dir = tmp_path / "package"
(package_dir / "node_modules" / "@opentui" / "core").mkdir(parents=True)
(package_dir / "src").mkdir()
(package_dir / "src" / "main.mjs").write_text("", encoding="utf-8")
monkeypatch.setattr(bridge_module.os, "name", "nt")
availability = check_opentui_host_available(package_dir=package_dir, runtime_bin="bun")
assert availability.available is False
assert availability.reason is not None
assert "Windows" in availability.reason
assert "file-descriptor" in availability.reason
async def _attach_exited_process(bridge: OpenTuiBridge, *, code: int, stderr: str) -> None:
"""Attach a real, already-spawned child that exits with ``code`` to the bridge."""
script = f"import sys; sys.stderr.write({stderr!r}); sys.exit({code})"
process = await asyncio.create_subprocess_exec(
sys.executable, "-c", script, stderr=asyncio.subprocess.PIPE
)
bridge._process = process
bridge._stderr_task = asyncio.create_task(bridge._drain_stderr())
bridge._from_host_file = io.StringIO("") # read pipe is at EOF
@pytest.mark.asyncio
async def test_next_message_raises_with_stderr_when_host_crashes() -> None:
bridge = OpenTuiBridge()
await _attach_exited_process(bridge, code=3, stderr="fatal: boom\n")
with pytest.raises(OpenTuiBridgeError) as exc_info:
await bridge.next_message()
message = str(exc_info.value)
assert "code 3" in message
assert "fatal: boom" in message
@pytest.mark.asyncio
async def test_next_message_returns_none_on_clean_host_exit() -> None:
bridge = OpenTuiBridge()
await _attach_exited_process(bridge, code=0, stderr="")
assert await bridge.next_message() is None
@pytest.mark.asyncio
async def test_next_message_tolerates_invalid_utf8_and_skips_garbage() -> None:
"""A corrupted / non-JSON host line must not crash the session — it is skipped
and the next valid message is delivered."""
import os
from opensquilla.cli.tui.opentui.messages import HostInputSubmit
read_fd, write_fd = os.pipe()
os.write(write_fd, b"\xff\xfe invalid utf-8 bytes\n") # would crash a strict reader
os.write(write_fd, b"plain text, not json\n") # unparseable -> skipped
os.write(write_fd, b'{"type":"input.submit","text":"survived"}\n') # valid
os.close(write_fd)
bridge = OpenTuiBridge()
# Mirror bridge.start()'s read-pipe configuration (errors="replace").
bridge._from_host_file = os.fdopen(read_fd, "r", encoding="utf-8", errors="replace")
try:
message = await bridge.next_message()
assert isinstance(message, HostInputSubmit)
assert message.text == "survived"
finally:
bridge._from_host_file.close()
@pytest.mark.asyncio
async def test_next_message_tolerates_malformed_line_logging_failure(monkeypatch) -> None:
"""Diagnostic logging failures must not turn skipped garbage into a crash."""
from opensquilla.cli.tui.opentui.messages import HostInputSubmit
def raise_closed_file(*_args: object, **_kwargs: object) -> None:
raise ValueError("I/O operation on closed file")
monkeypatch.setattr(bridge_module.log, "warning", raise_closed_file)
bridge = OpenTuiBridge()
bridge._from_host_file = io.StringIO(
'plain text, not json\n{"type":"input.submit","text":"survived"}\n'
)
message = await bridge.next_message()
assert isinstance(message, HostInputSubmit)
assert message.text == "survived"
@pytest.mark.asyncio
async def test_close_does_not_treat_intentional_shutdown_as_crash() -> None:
bridge = OpenTuiBridge()
await _attach_exited_process(bridge, code=7, stderr="ignored\n")
# close() flips the closing guard, reaps the child, and cancels stderr draining
# without raising even though the child exited non-zero.
await bridge.close()
assert bridge._stderr_task is None
assert bridge._process is None
@pytest.mark.asyncio
@pytest.mark.skipif(
sys.platform.startswith("win"),
reason="OpenTUI bridge currently uses POSIX fd inheritance via pass_fds",
)
async def test_start_surfaces_reason_and_cleans_up_when_host_crashes_on_launch(
tmp_path, monkeypatch
) -> None:
# A stand-in "host" that crashes immediately, exercising the real start()
# handshake, fd plumbing, stderr capture, and crash detection without Bun.
host_script = tmp_path / "fake_host.py"
host_script.write_text(
"import sys\nsys.stderr.write('startup boom\\n')\nsys.exit(1)\n",
encoding="utf-8",
)
monkeypatch.setattr(
bridge_module,
"check_opentui_host_available",
lambda **_kwargs: RendererBackendAvailability(available=True),
)
bridge = OpenTuiBridge(runtime_bin=sys.executable, package_dir=tmp_path, ready_timeout=5.0)
bridge.paths = OpenTuiHostPaths(package_dir=tmp_path, main_script=host_script)
with pytest.raises(OpenTuiBridgeError) as exc_info:
await bridge.start()
message = str(exc_info.value)
assert "code 1" in message
assert "startup boom" in message
# start() must not leak the child process or stderr drain task on failure.
assert bridge._process is None
assert bridge._stderr_task is None
@pytest.mark.asyncio
async def test_next_message_gives_up_after_a_malformed_line_flood() -> None:
"""A wedged sidecar flooding garbage must escalate to a raise instead of
spinning the read loop forever."""
bridge = OpenTuiBridge()
bridge._from_host_file = io.StringIO("plain text, not json\n" * 65)
with pytest.raises(HostToPythonMessageError):
await bridge.next_message()
@pytest.mark.asyncio
async def test_next_message_delivers_after_exactly_the_flood_limit() -> None:
"""The escalation threshold is strict: 64 consecutive garbage lines are
still skipped and the following valid message is delivered."""
bridge = OpenTuiBridge()
bridge._from_host_file = io.StringIO(
"plain text, not json\n" * 64 + '{"type":"input.submit","text":"survived"}\n'
)
message = await bridge.next_message()
assert message == HostInputSubmit(text="survived")
@pytest.mark.asyncio
@pytest.mark.skipif(
sys.platform.startswith("win"),
reason="OpenTUI bridge currently uses POSIX fd inheritance via pass_fds",
)
async def test_close_kills_wedged_host_instead_of_deadlocking(tmp_path, monkeypatch) -> None:
"""A host that never becomes ready, ignores SIGTERM, and never writes keeps
a reader thread parked in readline holding the file lock. close() must kill
the child (EOF-ing the pipe) BEFORE closing the read file, otherwise
file.close() blocks the event loop forever waiting for that lock."""
host_script = tmp_path / "wedged_host.py"
host_script.write_text(
"import signal, time\n"
"signal.signal(signal.SIGTERM, signal.SIG_IGN)\n"
"time.sleep(60)\n",
encoding="utf-8",
)
monkeypatch.setattr(
bridge_module,
"check_opentui_host_available",
lambda **_kwargs: RendererBackendAvailability(available=True),
)
bridge = OpenTuiBridge(runtime_bin=sys.executable, package_dir=tmp_path, ready_timeout=0.5)
bridge.paths = OpenTuiHostPaths(package_dir=tmp_path, main_script=host_script)
with pytest.raises(OpenTuiBridgeError, match="did not become ready"):
await asyncio.wait_for(bridge.start(), timeout=20.0)
assert bridge._process is None
assert bridge._from_host_file is None
@pytest.mark.asyncio
async def test_send_nowait_escapes_lone_surrogates_instead_of_raising() -> None:
"""Serialized frames keep non-ASCII text verbatim, so a lone surrogate
(e.g. a surrogateescape-decoded filename in a completion item) must be
escaped by the pipe encoding, not raise an unwrapped UnicodeEncodeError."""
read_fd, write_fd = os.pipe()
bridge = OpenTuiBridge()
# Mirror bridge.start()'s write-pipe configuration (errors="backslashreplace").
bridge._to_host_file = os.fdopen(
write_fd, "w", encoding="utf-8", errors="backslashreplace", buffering=1
)
bridge.send_nowait("scrollback.write", ScrollbackWrite(text="file_\udc80.txt"))
bridge._to_host_file.close()
bridge._to_host_file = None
with os.fdopen(read_fd, "rb") as reader:
data = reader.read()
assert data.endswith(b"\n")
assert b"\\udc80" in data
@pytest.mark.asyncio
async def test_send_nowait_wraps_closed_pipe_write_as_bridge_error() -> None:
bridge = OpenTuiBridge()
closed = io.StringIO()
closed.close()
bridge._to_host_file = closed
with pytest.raises(OpenTuiBridgeError, match="IPC write failed"):
bridge.send_nowait("shutdown")
@pytest.mark.asyncio
async def test_start_reports_missing_bun_reason_instead_of_spawn_error(monkeypatch) -> None:
monkeypatch.setattr(bridge_module.os, "name", "posix")
monkeypatch.setattr(bridge_module.shutil, "which", lambda _cmd: None)
bridge = OpenTuiBridge()
assert bridge.runtime_bin is None
with pytest.raises(OpenTuiBridgeError, match="Bun is not installed"):
await bridge.start()
@pytest.mark.asyncio
async def test_start_reports_bogus_runtime_bin_with_actionable_reason(
tmp_path, monkeypatch
) -> None:
package_dir = tmp_path / "package"
(package_dir / "node_modules" / "@opentui" / "core").mkdir(parents=True)
(package_dir / "src").mkdir()
(package_dir / "src" / "main.mjs").write_text("", encoding="utf-8")
monkeypatch.setattr(bridge_module.os, "name", "posix")
bridge = OpenTuiBridge(
runtime_bin=str(tmp_path / "no-such-runtime"), package_dir=package_dir
)
with pytest.raises(OpenTuiBridgeError, match="not executable"):
await bridge.start()
@pytest.mark.asyncio
@pytest.mark.skipif(
sys.platform.startswith("win"),
reason="OpenTUI bridge currently uses POSIX fd inheritance via pass_fds",
)
async def test_start_wraps_vanished_runtime_as_bridge_error(tmp_path, monkeypatch) -> None:
"""A runtime that disappears between the availability check and the spawn
must still surface as a catchable OpenTuiBridgeError, not FileNotFoundError."""
monkeypatch.setattr(
bridge_module,
"check_opentui_host_available",
lambda **_kwargs: RendererBackendAvailability(available=True),
)
bridge = OpenTuiBridge(runtime_bin=str(tmp_path / "vanished-bin"), package_dir=tmp_path)
with pytest.raises(OpenTuiBridgeError, match="not executable"):
await bridge.start()
@pytest.mark.asyncio
async def test_writer_task_keeps_loop_responsive_and_preserves_frame_order() -> None:
gate = threading.Event()
written: list[str] = []
class _StalledPipe:
def write(self, frame: str) -> None:
gate.wait(timeout=10.0)
written.append(frame)
def flush(self) -> None:
return None
def close(self) -> None:
return None
bridge = OpenTuiBridge()
bridge._to_host_file = _StalledPipe()
bridge._write_queue = asyncio.Queue(maxsize=64)
bridge._writer_task = asyncio.create_task(bridge._drain_writes())
for index in range(3):
bridge.send_nowait("scrollback.write", ScrollbackWrite(text=f"frame-{index}"))
await asyncio.sleep(0.05)
# The writer thread is parked on the stalled pipe, yet the loop kept
# running and enqueueing stayed instant.
assert written == []
bridge.send_nowait("scrollback.write", ScrollbackWrite(text="frame-3"))
gate.set()
await bridge._flush_writes(timeout=5.0)
texts = [json.loads(frame)["text"] for frame in written]
assert texts == [f"frame-{index}" for index in range(4)]
@pytest.mark.asyncio
async def test_host_crash_triggers_terminal_restore(monkeypatch) -> None:
bridge = OpenTuiBridge()
restored: list[bool] = []
monkeypatch.setattr(bridge, "_restore_terminal", lambda: restored.append(True))
await _attach_exited_process(bridge, code=3, stderr="fatal: boom\n")
with pytest.raises(OpenTuiBridgeError):
await bridge.next_message()
assert restored == [True]
@pytest.mark.asyncio
async def test_clean_host_exit_skips_terminal_restore(monkeypatch) -> None:
bridge = OpenTuiBridge()
restored: list[bool] = []
monkeypatch.setattr(bridge, "_restore_terminal", lambda: restored.append(True))
await _attach_exited_process(bridge, code=0, stderr="")
assert await bridge.next_message() is None
await bridge.close()
assert restored == []
def test_restore_terminal_writes_reset_sequence_once() -> None:
bridge = OpenTuiBridge()
read_fd, write_fd = os.pipe()
try:
bridge._tty_fd = write_fd
bridge._restore_terminal()
bridge._restore_terminal()
finally:
os.close(write_fd)
with os.fdopen(read_fd, "rb") as reader:
data = reader.read()
assert data == bridge_module._TERMINAL_RESET_SEQUENCE
assert b"\x1b[?1049l" in data
assert b"\x1b[?25h" in data
@@ -0,0 +1,95 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
import pytest
from opensquilla.cli.tui.opentui import completion
from opensquilla.cli.tui.opentui.completion import (
CompletionCandidate,
build_completion_catalog,
)
from opensquilla.engine.commands import Surface
@dataclass(frozen=True)
class FakeSkill:
name: str
description: str
disable_model_invocation: bool = False
class FakeSkillLoader:
def get_user_invocable(self) -> list[FakeSkill]:
return [
FakeSkill("code-review", "Review code for regressions."),
FakeSkill("internal-only", "Hidden from model.", disable_model_invocation=True),
]
def _by_label(items: list[CompletionCandidate]) -> dict[str, CompletionCandidate]:
return {item.label: item for item in items}
def test_build_completion_catalog_includes_commands_and_skills() -> None:
catalog = build_completion_catalog(surface="tui", skill_loader=FakeSkillLoader())
items = _by_label(catalog)
assert items["/compact"].category == "command"
assert items["/compact"].insert_text == "/compact "
assert items["/compact"].description
assert items["/code-review"].category == "skill"
assert items["/code-review"].insert_text == "use the code-review skill: "
assert "/internal-only" not in items
def test_build_completion_catalog_dedups_setting_toggles_against_commands() -> None:
# The gateway surface registry already exposes /model, /permissions, /cost,
# and /resume; keeping the setting-toggle twins would render each command
# twice in the slash menu under a second label.
catalog = build_completion_catalog(
surface=Surface.CLI_GATEWAY, skill_loader=FakeSkillLoader()
)
items = _by_label(catalog)
assert items["/model"].category == "command"
assert items["/cost"].category == "command"
assert "Model" not in items
assert "Permissions" not in items
assert "Cost" not in items
assert "Resume" not in items
inserts = [candidate.insert_text.strip() for candidate in catalog]
assert len(inserts) == len(set(inserts)), "duplicate insert targets in catalog"
def test_build_completion_catalog_keeps_toggles_missing_from_surface_registry() -> None:
# The standalone surface has no /permissions or /resume commands, so those
# toggles are NOT duplicates there and must survive the dedup.
catalog = build_completion_catalog(
surface=Surface.CLI_STANDALONE, skill_loader=FakeSkillLoader()
)
items = _by_label(catalog)
assert items["Permissions"].category == "setting"
assert items["Resume"].category == "setting"
assert "Model" not in items # /model exists on standalone -> deduped
assert "Cost" not in items # /cost exists on standalone -> deduped
def test_build_completion_catalog_keeps_commands_and_settings_when_skill_loader_fails(
monkeypatch: pytest.MonkeyPatch,
) -> None:
def fail_loader(*, workspace_dir: Path | None = None) -> object:
raise RuntimeError("no config")
monkeypatch.setattr(completion, "_build_skill_loader", fail_loader)
catalog = build_completion_catalog(surface=Surface.CLI_STANDALONE)
items = _by_label(catalog)
assert items["/compact"].category == "command"
assert items["Permissions"].category == "setting"
assert all(item.category != "skill" for item in catalog)
@@ -0,0 +1,51 @@
from __future__ import annotations
from opensquilla.cli.tui.opentui.completion import build_completion_context
from opensquilla.engine.commands import Surface
from opensquilla.skills.types import SkillLayer, SkillSpec
class _SkillLoader:
def get_user_invocable(self) -> list[SkillSpec]:
return [
SkillSpec(
name="visible-skill",
description="Complete visible work.",
layer=SkillLayer.WORKSPACE,
always=False,
triggers=["visible"],
content="",
),
SkillSpec(
name="hidden-from-model",
description="Do not surface to model-driven completion.",
layer=SkillLayer.WORKSPACE,
always=False,
triggers=["hidden"],
content="",
disable_model_invocation=True,
),
]
def test_completion_context_includes_surface_commands_skills_and_safe_files(tmp_path) -> None:
(tmp_path / "src").mkdir()
(tmp_path / "src" / "main.py").write_text("x", encoding="utf-8")
(tmp_path / ".env").write_text("secret", encoding="utf-8")
context = build_completion_context(
Surface.CLI_GATEWAY,
skill_loader=_SkillLoader(),
workspace_dir=tmp_path,
)
catalog = {candidate.label: candidate for candidate in context.catalog}
assert "/compact" in catalog
assert catalog["/compact"].insert_text == "/compact "
assert "/visible-skill" in catalog
assert catalog["/visible-skill"].insert_text == "use the visible-skill skill: "
assert "/hidden-from-model" not in catalog
assert context.files == ("src/main.py",)
assert context.filters_sensitive_paths is True
@@ -0,0 +1,167 @@
from __future__ import annotations
import os
import shutil
import subprocess
import sys
import unicodedata
from pathlib import Path
from types import SimpleNamespace
import pytest
from opensquilla.cli.tui.opentui import completion
from opensquilla.cli.tui.opentui.completion import enumerate_workspace_files
def _touch(path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("x", encoding="utf-8")
def test_enumerate_workspace_files_fallback_filters_ignored_hidden_and_sensitive_paths(
tmp_path: Path,
) -> None:
(tmp_path / ".gitignore").write_text("ignored.txt\nbuild/\n*.tmp\n", encoding="utf-8")
_touch(tmp_path / "src" / "main.py")
_touch(tmp_path / "src" / "compact.py")
_touch(tmp_path / "docs" / "reset.md")
_touch(tmp_path / "ignored.txt")
_touch(tmp_path / "build" / "artifact.log")
_touch(tmp_path / ".hidden" / "shadow.py")
_touch(tmp_path / "__pycache__" / "cached.pyc")
_touch(tmp_path / "scratch.tmp")
_touch(tmp_path / ".env")
assert enumerate_workspace_files(tmp_path) == [
".gitignore",
"docs/reset.md",
"src/compact.py",
"src/main.py",
]
def test_enumerate_workspace_files_fallback_honors_gitignore_negations(tmp_path: Path) -> None:
# git semantics: "!keep.log" AFTER "*.log" re-includes the file.
(tmp_path / ".gitignore").write_text("*.log\n!keep.log\n", encoding="utf-8")
_touch(tmp_path / "keep.log")
_touch(tmp_path / "drop.log")
_touch(tmp_path / "main.py")
assert enumerate_workspace_files(tmp_path) == [".gitignore", "keep.log", "main.py"]
def test_enumerate_workspace_files_fallback_negation_last_match_wins(tmp_path: Path) -> None:
# A later exclude overrides an earlier re-include, mirroring git's
# last-match-wins rule ordering.
(tmp_path / ".gitignore").write_text("!keep.log\n*.log\n", encoding="utf-8")
_touch(tmp_path / "keep.log")
assert enumerate_workspace_files(tmp_path) == [".gitignore"]
def test_enumerate_workspace_files_applies_query_and_max_results(tmp_path: Path) -> None:
_touch(tmp_path / "src" / "compact.py")
_touch(tmp_path / "src" / "component.py")
_touch(tmp_path / "docs" / "commands.md")
_touch(tmp_path / "docs" / "reset.md")
assert enumerate_workspace_files(tmp_path, query="cmp", max_results=2) == [
"src/compact.py",
"src/component.py",
]
def test_enumerate_workspace_files_uses_git_ls_files_when_available(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
(tmp_path / ".git").mkdir()
_touch(tmp_path / "src" / "compact.py")
_touch(tmp_path / "src" / ".env")
_touch(tmp_path / "docs" / "reset.md")
calls: dict[str, list[str]] = {}
def fake_run(cmd: list[str], **kwargs: object) -> SimpleNamespace:
calls["cmd"] = cmd
return SimpleNamespace(
returncode=0,
stdout=b"src/compact.py\0src/.env\0docs/reset.md\0",
stderr=b"",
)
monkeypatch.setattr(completion.shutil, "which", lambda name: "/usr/bin/git")
monkeypatch.setattr(completion.subprocess, "run", fake_run)
assert enumerate_workspace_files(tmp_path, query="cmp") == ["src/compact.py"]
# NUL-separated output keeps non-ASCII paths verbatim (no core.quotePath
# C-quoting), so -z is load-bearing.
assert "-z" in calls["cmd"]
def test_git_files_returns_non_ascii_paths_verbatim(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
(tmp_path / ".git").mkdir()
_touch(tmp_path / "café.md")
_touch(tmp_path / "日本語.txt")
stdout = "café.md".encode() + b"\0" + "日本語.txt".encode() + b"\0"
monkeypatch.setattr(completion.shutil, "which", lambda name: "/usr/bin/git")
monkeypatch.setattr(
completion.subprocess,
"run",
lambda *args, **kwargs: SimpleNamespace(returncode=0, stdout=stdout, stderr=b""),
)
assert enumerate_workspace_files(tmp_path) == ["café.md", "日本語.txt"]
def test_git_files_tolerates_undecodable_path_bytes(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# A non-UTF-8 filename byte (e.g. latin-1 0xE9) must never crash completion;
# it decodes through the filesystem rules (surrogateescape) instead.
(tmp_path / ".git").mkdir()
monkeypatch.setattr(completion.shutil, "which", lambda name: "/usr/bin/git")
# Windows uses surrogatepass for filesystem decoding, which still raises
# for this malformed UTF-8 byte. Simulate that handler on every platform so
# the fallback remains covered outside the Windows CI job too.
monkeypatch.setattr(
completion.os,
"fsdecode",
lambda entry: entry.decode("utf-8", errors="surrogatepass"),
)
monkeypatch.setattr(
completion.subprocess,
"run",
lambda *args, **kwargs: SimpleNamespace(
returncode=0, stdout=b"caf\xe9.md\0plain.txt\0", stderr=b""
),
)
names = enumerate_workspace_files(tmp_path)
assert "plain.txt" in names
expected = b"caf\xe9.md".decode(
sys.getfilesystemencoding(), errors="surrogateescape"
)
assert expected in names
@pytest.mark.skipif(shutil.which("git") is None, reason="git is not on PATH")
def test_enumerate_workspace_files_real_git_does_not_quote_non_ascii_paths(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# Isolate from user/system git config so the default core.quotePath=true
# applies — the exact setting that C-quotes non-ASCII names without -z.
monkeypatch.setenv("GIT_CONFIG_GLOBAL", os.devnull)
monkeypatch.setenv("GIT_CONFIG_SYSTEM", os.devnull)
subprocess.run(
["git", "init", "-q", str(tmp_path)], check=True, capture_output=True
)
_touch(tmp_path / "café.md")
names = enumerate_workspace_files(tmp_path)
assert all(not name.startswith('"') for name in names)
assert any(
unicodedata.normalize("NFC", name) == "café.md" for name in names
), f"expected café.md in {names!r}"
@@ -0,0 +1,116 @@
from __future__ import annotations
import json
import shutil
import subprocess
from pathlib import Path
import pytest
from opensquilla.cli.tui.opentui.completion import fuzzy_filter, fuzzy_rank
_COMPOSER_MJS = (
Path(__file__).resolve().parents[4]
/ "src/opensquilla/cli/tui/opentui/package/src/composer.mjs"
)
# Shared query/candidate fixtures fed to BOTH the Python scorer and the JS
# host's fuzzyScore (via filterCatalog). The host ranks its local snapshot
# first and the Python response then replaces the menu, so any ordering drift
# between the two scorers shows up as a visible reorder flicker.
_PARITY_FIXTURES: list[tuple[str, list[str]]] = [
("x", ["gate.txt", "late.txt", "index/tests"]),
("m", ["make.go", "map/beta.txt", "src/domain.py"]),
("cmp", ["compress", "compact", "compare", "model"]),
("re", ["compact", "resume", "reset", "render"]),
("ma", ["src/domain.py", "src/cli/main.py", "docs/manual.md", "src/tui/messages.py"]),
("mod", ["models.py", "/model", "/mode-off"]),
("co", ["/compact", "/cost", "Cost", "config.py"]),
("main", ["src/main.py", "main.py", "domain/main_window.py", "remains.txt"]),
("", ["b.txt", "a.txt"]),
("zz", ["alpha", "beta"]),
]
def test_fuzzy_filter_empty_query_preserves_input_order() -> None:
items = ["resume", "compact", "reset"]
assert fuzzy_filter("", items) == items
def test_fuzzy_filter_is_case_insensitive() -> None:
assert fuzzy_filter("CMp", ["Reset", "COMPACT", "models"]) == ["COMPACT"]
def test_fuzzy_filter_ranks_compact_before_other_cmp_matches() -> None:
ranked = fuzzy_filter("cmp", ["compress", "compact", "compare", "model"])
assert ranked[0] == "compact"
assert "compact" in ranked
assert "model" not in ranked
def test_fuzzy_filter_matches_reset_and_resume_for_re() -> None:
ranked = fuzzy_filter("re", ["compact", "resume", "reset", "render"])
assert ranked[:2] == ["reset", "resume"]
assert "compact" not in ranked
def test_fuzzy_filter_prefers_segment_start_and_early_matches() -> None:
ranked = fuzzy_filter(
"ma",
[
"src/domain.py",
"src/cli/main.py",
"docs/manual.md",
"src/tui/messages.py",
],
)
assert ranked[:2] == ["src/cli/main.py", "docs/manual.md"]
def test_fuzzy_filter_gives_slash_commands_a_command_segment_bonus() -> None:
# Mirrors the JS scorer's +90 command-segment bonus: a query naming the
# command (without the slash) ranks the command above plain-file matches.
ranked = fuzzy_filter("mod", ["models.py", "/model"])
assert ranked[0] == "/model"
def test_fuzzy_rank_returns_candidate_indexes_and_scores() -> None:
ranked = fuzzy_rank("re", ["compact", "reset", "resume"])
assert [index for index, _score in ranked] == [1, 2]
assert ranked[0][1] >= ranked[1][1]
@pytest.mark.skipif(shutil.which("bun") is None, reason="bun runtime is not on PATH")
def test_fuzzy_rank_order_matches_js_filter_catalog(tmp_path: Path) -> None:
script = tmp_path / "parity.mjs"
script.write_text(
f'import {{ filterCatalog }} from {json.dumps(_COMPOSER_MJS.as_uri())};\n'
"const fixtures = JSON.parse(process.argv[2]);\n"
"const out = fixtures.map(([query, candidates]) =>\n"
" filterCatalog(candidates.map((label) => ({ label })), query)\n"
" .map((item) => item.label));\n"
"console.log(JSON.stringify(out));\n",
encoding="utf-8",
)
result = subprocess.run(
["bun", str(script), json.dumps(_PARITY_FIXTURES)],
capture_output=True,
text=True,
check=False,
timeout=60,
)
assert result.returncode == 0, result.stderr
js_orders = json.loads(result.stdout)
for (query, candidates), js_order in zip(_PARITY_FIXTURES, js_orders, strict=True):
python_order = fuzzy_filter(query, candidates)
assert python_order == js_order, (
f"scorer drift for query {query!r}: python={python_order} js={js_order}"
)
@@ -0,0 +1,291 @@
from __future__ import annotations
import json
import subprocess
from pathlib import Path
SRC = (
Path(__file__).resolve().parents[4]
/ "src"
/ "opensquilla"
/ "cli"
/ "tui"
/ "opentui"
/ "package"
/ "src"
)
def _read(rel: str) -> str:
return (SRC / rel).read_text(encoding="utf-8")
def _node_json(script: str) -> object:
result = subprocess.run(
["node", "--input-type=module", "-e", script],
check=True,
cwd=Path(__file__).resolve().parents[4],
text=True,
encoding="utf-8",
capture_output=True,
)
return json.loads(result.stdout)
def test_host_split_into_block_modules() -> None:
for f in [
"theme.mjs",
"primitives.mjs",
"blockRegistry.mjs",
"turnView.mjs",
"composer.mjs",
"ipc.mjs",
"blocks/promptBlock.mjs",
"blocks/thinkingBlock.mjs",
"blocks/toolBlock.mjs",
"blocks/answerBlock.mjs",
"blocks/usageBlock.mjs",
"blocks/errorBlock.mjs",
"main.mjs",
]:
assert (SRC / f).exists(), f"missing host module {f}"
def test_registry_covers_seven_kinds() -> None:
reg = _read("blockRegistry.mjs")
for kind in ["prompt", "thinking", "reasoning", "tool", "answer", "usage", "error"]:
assert f"{kind}:" in reg, f"registry missing kind {kind}"
assert "createBlock" in reg
def test_turn_card_owns_one_continuous_gutter() -> None:
# The assistant turn renders as ONE card: a single left-border gutter runs
# unbroken through narration + tool calls, so the timeline reads as one
# assistant block (opencode/codex style). That gutter is the turn card's
# border (answerFrame) owned by turnView — in-card blocks no longer redraw
# their own "│" rail nodes. The chrome is width-independent: a short
# "╭ squilla" label on top and a bare "╰ …" footer, never a full-width rule
# (which wraps a stray dash when the scrollbar steals a viewport column).
tv = _read("turnView.mjs")
tool = _read("blocks/toolBlock.mjs")
thinking = _read("blocks/thinkingBlock.mjs")
assert 'border: ["left"]' in tv
assert "borderColor: THEME.answerFrame" in tv
assert '"╭ squilla"' in tv
assert "" in tv
assert "cardHeaderRule" not in tv
# the card opens once and closes once per turn (header + footer drawn once)
assert "openCard" in tv and "closeCard" in tv
# in-card blocks defer the gutter to the turn card — no per-block rail node
assert "-rail" not in tool
assert "-gt" not in thinking
def test_answer_block_is_streaming_markdown_in_the_turn_card() -> None:
answer = _read("blocks/answerBlock.mjs")
assert "MarkdownRenderable" in answer
assert "streaming: true" in answer
# streaming stops on end()
assert "md.streaming = false" in answer
# the card chrome (left border, header label, footer) now belongs to the TURN,
# not the answer block — the answer is purely the streamed markdown body.
assert 'border: ["left"]' not in answer
assert "" not in answer and "" not in answer
# the retype mechanism is gone: no teardown contract with turnView
assert "teardown" not in answer
def test_thinking_block_is_purple_glyph_timeline() -> None:
thinking = _read("blocks/thinkingBlock.mjs")
assert "" in thinking
assert "THEME.thinkingAccent" in thinking
# reasoning renders incrementally as it streams (render called from append)
assert "append(delta)" in thinking
assert "render()" in thinking
# clips to viewport so continuation lines never wrap past the rail
assert "clipToCells" in thinking
assert "timelineAvailCells" in thinking
def test_tool_block_groups_detail_and_pulses() -> None:
tool = _read("blocks/toolBlock.mjs")
assert "" in tool and "" in tool
assert "setGlyph" in tool
# result preview clipped to viewport (no rail-breaking wrap)
assert "clipToCells" in tool
assert "timelineAvailCells" in tool
# Run-state colors come from the shared STATUS vocabulary (opencode/codex
# alignment): soft-orange while running, green/red on resolution, dim result.
assert "STATUS.running" in tool
assert "STATUS.ok" in tool and "STATUS.error" in tool
assert "STATUS.detail" in tool
# codex-style: ONE "└ " result corner (cap), a " · {duration}" suffix on
# completion, and args inline after the name (no separate args node).
assert "RESULT_CORNER" in tool and "DURATION_SEP" in tool
assert "resultAdded" in tool # the single-result-corner guard
assert "duration" in tool
def test_prompt_and_usage_and_error_blocks() -> None:
prompt = _read("blocks/promptBlock.mjs")
usage = _read("blocks/usageBlock.mjs")
error = _read("blocks/errorBlock.mjs")
# the prompt is a compact chrome-free row set: a border-left rail box with
# one muted text node per line — no header rule, no footer
assert 'border: ["left"]' in prompt
assert "THEME.promptAccent" in prompt
assert "THEME.muted" in prompt
assert "" not in prompt and "" not in prompt
assert "·" in usage
assert "THEME.muted" in usage
assert "" in error
assert "THEME.error" in error
def test_turnview_routes_block_messages() -> None:
tv = _read("turnView.mjs")
assert "createTurnView" in tv
for method in ["begin(", "append(", "update(", "end(", "refreshPulse("]:
assert method in tv, f"turnView missing {method}"
# the retype mechanism is gone: blocks keep their kind for life
assert "retype" not in tv
assert "teardown" not in tv
assert "seedText" not in tv
# running-tool pulse set is maintained (no dangling animated nodes)
assert "runningTools" in tv
def test_dispatcher_routes_block_and_legacy_messages() -> None:
ipc = _read("ipc.mjs")
assert "createDispatcher" in ipc
for t in [
"turn.begin",
"turn.end",
"turn.status",
"composer.set",
"completion.context",
"completion.response",
"router.update",
"block.begin",
"block.append",
"block.update",
"block.end",
"prompt.echo",
"model.text",
"scrollback.write",
"notice.write",
"theme.set",
"theme.pick",
"shutdown",
]:
assert f'"{t}"' in ipc, f"dispatcher missing case {t}"
def test_composer_input_region_behaviors() -> None:
composer = _read("composer.mjs")
assert "createComposer" in composer
assert "inputHistory" in composer
assert "syncTerminalCursorToCaret" in composer
assert "scrollBy" in composer
# esc cancels the turn; ctrl+C clears-or-eofs; option/meta+return inserts newline
assert '"escape"' in composer
assert "input.cancel" in composer
assert "input.eof" in composer
assert 'insertAtCursor("\\n")' in composer
assert ("pageup" in composer) or ("pagedown" in composer)
def test_composer_router_state_carries_structured_fields() -> None:
composer = _read("composer.mjs")
# routerState seeds the new structured fields.
assert "baselineModel" in composer
assert "rolloutPhase" in composer
# setRouterState reads the snake_case keys Python sends via asdict.
assert "baseline_model" in composer
assert "routing_applied" in composer
assert "rollout_phase" in composer
# the model row can render a downgrade marker and source markers exist.
assert "shortModel" in composer
assert "" in composer
assert "setCompletionContext" in composer
def test_composer_router_model_downgrade_keeps_target_model_visible() -> None:
module_path = (
"./src/opensquilla/cli/tui/opentui/package/src/"
"composer.mjs"
)
prim_path = (
"./src/opensquilla/cli/tui/opentui/package/src/"
"primitives.mjs"
)
data = _node_json(
f"""
const {{ routerStripValue, formatRouterModelValue }} = await import("{module_path}");
const {{ textWidth }} = await import("{prim_path}");
const target = "vendor/small-fast";
const baseline = "vendor/big-heavy";
const downgrade = formatRouterModelValue(target, baseline);
const unchanged = formatRouterModelValue(target, target);
const row = routerStripValue(downgrade);
const clippedLong = routerStripValue("x".repeat(30));
console.log(JSON.stringify({{
downgrade, unchanged, row,
clippedLong, clippedWidth: textWidth(clippedLong),
}}));
"""
)
assert data["downgrade"] == "↓ small-fast"
assert data["unchanged"] == "small-fast"
assert "small-fast" in data["row"]
assert "big-heavy" not in data["row"]
# The live strip clips every value cell to 18 display cells.
assert data["clippedLong"].endswith("")
assert data["clippedWidth"] <= 18
def test_main_is_thin_entry_with_mouse_and_alt_screen() -> None:
main = _read("main.mjs")
assert 'screenMode: "alternate-screen"' in main
assert "useMouse: true" in main
assert "ScrollBoxRenderable" in main
assert 'stickyStart: "bottom"' in main
assert "viewportCulling" in main
assert "createTurnView" in main
assert "createComposer" in main
assert "createDispatcher" in main
# old monolith artifacts must be gone
assert "class TurnView" not in main
assert "OPENTUI_DAILY_THEME" not in main
assert "answer.demote" not in main
def test_resize_reflows_width_clipped_block_content() -> None:
# The card chrome is width-independent, so a resize only re-clips block
# content (tool result corners, narration wraps): main tracks every turn
# and calls relayout(), which skips entirely on a height-only resize and
# otherwise defers to each block. No module bakes a width-dependent header
# rule anymore.
main = _read("main.mjs")
turn = _read("turnView.mjs")
tool = _read("blocks/toolBlock.mjs")
prompt = _read("blocks/promptBlock.mjs")
assert "turns" in main and "relayout" in main
assert "relayout()" in turn and "lastRelayoutWidth" in turn
assert "relayout()" in tool
# the compact prompt has nothing width-dependent left to reflow
assert "relayout()" not in prompt
# A resize must force a FULL repaint, else OpenTUI's diff-render leaves the old
# (wider) layout's cells uncleared — the router box bleeds through as stale
# glyphs when the window shrinks.
assert "forceFullRepaintRequested" in main
def test_no_legacy_optimistic_demote_in_host() -> None:
# The optimistic-render + demote/retype model is gone entirely: reasoning
# and answer arrive as distinct streams, so no block ever changes kind.
for f in ["main.mjs", "turnView.mjs"]:
src = _read(f)
assert "demoteAnswerToTimeline" not in src
assert "promoteAnswerToCard" not in src
assert "retype" not in src
+286
View File
@@ -0,0 +1,286 @@
from __future__ import annotations
import dataclasses
import re
from pathlib import Path
import pytest
from opensquilla.cli.tui.opentui.messages import (
HOST_TO_PYTHON_TYPES,
PYTHON_TO_HOST_TYPES,
ApprovalDismiss,
CompletionCandidate,
CompletionContext,
HostApprovalResponse,
HostInputCancel,
HostInputEof,
HostInputSubmit,
HostProtocolUnknown,
HostReady,
HostResize,
HostToPythonMessageError,
RouterPluginState,
host_message_from_json,
python_message_to_json,
)
_PACKAGE_SRC = (
Path(__file__).resolve().parents[4] / "src/opensquilla/cli/tui/opentui/package/src"
)
def _production_host_sources() -> list[Path]:
return [
path
for path in sorted(_PACKAGE_SRC.glob("*.mjs"))
if not path.name.endswith(".test.mjs")
]
def test_python_message_to_json_serializes_router_update() -> None:
payload = python_message_to_json(
"router.update",
RouterPluginState(
model="gpt-5.5",
route="T3 | 91%",
saving="42% | -$0.021",
context="128k | 37%",
style="normal",
),
)
assert payload.endswith("\n")
assert '"type":"router.update"' in payload
assert '"model":"gpt-5.5"' in payload
assert '"route":"T3 | 91%"' in payload
def test_python_message_to_json_serializes_completion_context() -> None:
payload = python_message_to_json(
"completion.context",
CompletionContext(
catalog=(
CompletionCandidate(
label="/compact",
description="Compact chat context.",
insert_text="/compact",
category="command",
),
CompletionCandidate(
label="/code-review",
description="Run a comprehensive code review",
insert_text="use the code-review skill: ",
category="skill",
),
),
files=("src/main.py",),
filters_sensitive_paths=True,
),
)
assert payload.endswith("\n")
assert '"type":"completion.context"' in payload
assert '"label":"/compact"' in payload
assert '"category":"command"' in payload
assert '"insert_text":"use the code-review skill: "' in payload
assert '"files":["src/main.py"]' in payload
assert '"filters_sensitive_paths":true' in payload
def test_host_message_from_json_parses_ready_and_submit() -> None:
assert host_message_from_json('{"type":"ready"}') == HostReady()
assert host_message_from_json(
'{"type":"input.submit","text":"中文 prompt"}'
) == HostInputSubmit(text="中文 prompt")
def test_host_message_from_json_parses_control_messages() -> None:
assert host_message_from_json('{"type":"input.cancel"}') == HostInputCancel()
assert host_message_from_json('{"type":"input.eof"}') == HostInputEof()
assert host_message_from_json('{"type":"resize","width":120,"height":36}') == (
HostResize(width=120, height=36)
)
def test_host_message_rejects_malformed_control_payloads() -> None:
with pytest.raises(HostToPythonMessageError, match="input.submit.text"):
host_message_from_json('{"type":"input.submit"}')
with pytest.raises(HostToPythonMessageError, match="resize.width"):
host_message_from_json('{"type":"resize","height":36}')
with pytest.raises(HostToPythonMessageError, match="Unknown OpenTUI host"):
host_message_from_json('{"type":"surprise"}')
def test_host_message_from_json_parses_protocol_unknown() -> None:
parsed = host_message_from_json('{"type":"protocol.unknown","messageType":"tool.call"}')
assert parsed == HostProtocolUnknown(message_type="tool.call")
with pytest.raises(HostToPythonMessageError, match="protocol.unknown.messageType"):
host_message_from_json('{"type":"protocol.unknown"}')
def test_host_message_from_json_parses_approval_response() -> None:
parsed = host_message_from_json(
'{"type":"approval.response","id":"appr-1","approved":true,"choice":"allow_once"}'
)
assert parsed == HostApprovalResponse(id="appr-1", approved=True, choice="allow_once")
denied = host_message_from_json(
'{"type":"approval.response","id":"appr-2","approved":false,"choice":null}'
)
assert denied == HostApprovalResponse(id="appr-2", approved=False, choice=None)
def test_host_message_rejects_malformed_approval_response() -> None:
with pytest.raises(HostToPythonMessageError, match="approval.response.id"):
host_message_from_json('{"type":"approval.response","approved":true}')
with pytest.raises(HostToPythonMessageError, match="approval.response.approved"):
host_message_from_json('{"type":"approval.response","id":"appr-1"}')
with pytest.raises(HostToPythonMessageError, match="approval.response.approved"):
host_message_from_json('{"type":"approval.response","id":"appr-1","approved":"yes"}')
def test_python_message_to_json_serializes_approval_request() -> None:
payload = python_message_to_json(
"approval.request",
{
"id": "appr-1",
"tool": "shell",
"summary": "touch demo.txt",
"choices": ["allow_once", "deny"],
},
)
assert payload.endswith("\n")
assert '"type":"approval.request"' in payload
assert '"id":"appr-1"' in payload
assert '"choices":["allow_once","deny"]' in payload
def test_python_message_to_json_serializes_approval_dismiss() -> None:
payload = python_message_to_json("approval.dismiss", ApprovalDismiss(id="appr-1"))
assert payload.endswith("\n")
assert '"type":"approval.dismiss"' in payload
assert '"id":"appr-1"' in payload
def test_python_message_to_json_serializes_structured_blocks() -> None:
from opensquilla.cli.tui.opentui.messages import (
ModelText,
PromptEcho,
TurnBegin,
TurnEnd,
TurnStatusState,
)
assert '"type":"turn.begin"' in python_message_to_json("turn.begin", TurnBegin(id="t1"))
assert '"type":"prompt.echo"' in python_message_to_json(
"prompt.echo", PromptEcho(text="帮我分析架构")
)
assert '"id":"t1"' in python_message_to_json("turn.end", TurnEnd(id="t1", cancelled=False))
model = python_message_to_json("model.text", ModelText(text="先扫描结构"))
assert '"type":"model.text"' in model and '"text":"先扫描结构"' in model
status = python_message_to_json(
"turn.status", TurnStatusState(phase="tool", label="read_file", active=True)
)
assert '"phase":"tool"' in status and '"active":true' in status
def test_block_messages_serialize_with_kind_and_fields() -> None:
from opensquilla.cli.tui.opentui.messages import (
BlockAppend,
BlockBegin,
BlockEnd,
BlockUpdate,
python_message_to_json,
)
begin = python_message_to_json(
"block.begin",
BlockBegin(id="b1", kind="tool", meta={"name": "ls", "args": "src"}),
)
assert '"type":"block.begin"' in begin
assert '"kind":"tool"' in begin
assert '"name":"ls"' in begin
append = python_message_to_json("block.append", BlockAppend(id="b1", delta="line"))
assert '"delta":"line"' in append
update = python_message_to_json("block.update", BlockUpdate(id="b1", patch={"status": "ok"}))
assert '"status":"ok"' in update
end = python_message_to_json("block.end", BlockEnd(id="b1"))
assert '"type":"block.end"' in end
def test_python_to_host_registry_matches_js_dispatcher_cases() -> None:
"""Every outbound type must have a dispatcher case in the host, and vice
versa — a type without a handler is one release skew away from a live
protocol error, and a handler without a sender is dead contract."""
text = (_PACKAGE_SRC / "ipc.mjs").read_text(encoding="utf-8")
dispatcher = text.split("createDispatcher", 1)[1]
cases = set(re.findall(r'case "([a-z][a-z0-9._-]*)":', dispatcher))
assert cases, "could not parse dispatcher cases from ipc.mjs"
assert set(PYTHON_TO_HOST_TYPES) == cases
def test_host_emitted_types_are_all_parseable_by_python() -> None:
"""Every type literal the host can emit must have a Python parse branch
(extra Python branches are harmless forward tolerance)."""
emitted: set[str] = set()
for path in _production_host_sources():
emitted.update(
re.findall(
r'\{\s*type:\s*"([a-z][a-z0-9._-]*)"',
path.read_text(encoding="utf-8"),
)
)
assert emitted, "could not parse emitter literals from the host sources"
unparseable = emitted - set(HOST_TO_PYTHON_TYPES)
assert not unparseable, f"host emits types Python cannot parse: {sorted(unparseable)}"
def test_host_to_python_registry_round_trips_canonical_frames() -> None:
samples = {
"ready": '{"type":"ready"}',
"input.submit": '{"type":"input.submit","text":"hi"}',
"input.cancel": '{"type":"input.cancel"}',
"input.eof": '{"type":"input.eof"}',
"resize": '{"type":"resize","width":80,"height":24}',
"completion.request": (
'{"type":"completion.request","kind":"file","query":"a","request_id":1}'
),
"error": '{"type":"error","message":"boom"}',
"protocol.unknown": '{"type":"protocol.unknown","messageType":"mystery.type"}',
"approval.response": (
'{"type":"approval.response","id":"appr-1","approved":true,"choice":"allow_once"}'
),
}
assert set(samples) == set(HOST_TO_PYTHON_TYPES)
for wire_type, raw in samples.items():
assert isinstance(host_message_from_json(raw), HOST_TO_PYTHON_TYPES[wire_type])
def test_js_snake_case_payload_reads_match_python_dataclass_fields() -> None:
"""Payloads travel via dataclasses.asdict and JS reads fall back with `??`,
so a renamed Python field silently blanks the host UI. Pin every snake_case
payload read in the host against the registered dataclass fields."""
reads: set[str] = set()
for path in _production_host_sources():
reads.update(
re.findall(
r"\b[a-zA-Z_$][a-zA-Z0-9_$]*\.([a-z][a-z0-9]*_[a-z0-9_]+)\b",
path.read_text(encoding="utf-8"),
)
)
assert reads, "could not find any snake_case payload reads in the host sources"
allowed: set[str] = set()
for payload_cls in PYTHON_TO_HOST_TYPES.values():
if payload_cls is not None:
allowed.update(field.name for field in dataclasses.fields(payload_cls))
# Nested completion candidates plus the ad-hoc mapping payloads
# (completion.response and theme.set carry dicts, not dataclasses).
allowed.update(field.name for field in dataclasses.fields(CompletionCandidate))
allowed.update({"request_id", "kind", "items", "name"})
stale = reads - allowed
assert not stale, f"host reads payload fields Python does not send: {sorted(stale)}"
@@ -0,0 +1,71 @@
from __future__ import annotations
import json
import pytest
from opensquilla.cli.tui.opentui.messages import (
CompletionCandidate,
HostCompletionRequest,
HostToPythonMessageError,
host_message_from_json,
python_message_to_json,
)
def test_host_completion_request_parses_required_fields() -> None:
assert host_message_from_json(
'{"type":"completion.request","kind":"file","query":"src/cli","request_id":42}'
) == HostCompletionRequest(kind="file", query="src/cli", request_id=42)
@pytest.mark.parametrize(
("raw", "message"),
[
('{"type":"completion.request","query":"x","request_id":1}', "completion.kind"),
('{"type":"completion.request","kind":"slash","request_id":1}', "completion.query"),
('{"type":"completion.request","kind":"slash","query":"x"}', "completion.request_id"),
],
)
def test_host_completion_request_rejects_missing_fields(raw: str, message: str) -> None:
with pytest.raises(HostToPythonMessageError, match=message):
host_message_from_json(raw)
@pytest.mark.parametrize(
("raw", "message"),
[
('{"type":"completion.request","kind":7,"query":"x","request_id":1}', "completion.kind"),
(
'{"type":"completion.request","kind":"slash","query":7,"request_id":1}',
"completion.query",
),
(
'{"type":"completion.request","kind":"slash","query":"x","request_id":"1"}',
"completion.request_id",
),
],
)
def test_host_completion_request_rejects_wrong_field_types(raw: str, message: str) -> None:
with pytest.raises(HostToPythonMessageError, match=message):
host_message_from_json(raw)
def test_completion_candidate_serializes_as_json_payload() -> None:
raw = python_message_to_json(
"completion.item",
CompletionCandidate(
label="/compact",
description="Compact older context.",
insert_text="/compact ",
category="command",
),
)
assert json.loads(raw) == {
"type": "completion.item",
"label": "/compact",
"description": "Compact older context.",
"insert_text": "/compact ",
"category": "command",
}
@@ -0,0 +1,234 @@
"""Tests for the OpenTUI stdout->notice capture used to keep command notices
inside the host frame instead of bleeding raw onto the terminal."""
from __future__ import annotations
import asyncio
import io
import sys
from typing import Any
import pytest
from opensquilla.cli.tui.backend.output_binding import TuiOutputBinding
from opensquilla.cli.tui.opentui.notice_capture import (
NoticeForwardingStream,
capture_stdout_as_notices,
real_stderr,
)
from opensquilla.cli.tui.opentui.runtime import forward_console_notice
def test_stream_forwards_only_complete_lines() -> None:
lines: list[str] = []
stream = NoticeForwardingStream(lines.append)
stream.write("compact")
assert lines == [] # partial line buffered, not forwarded
stream.write(" skipped\nnext line\npartial")
assert lines == ["compact skipped", "next line"]
stream.flush()
assert lines == ["compact skipped", "next line", "partial"]
def test_stream_keeps_only_latest_cr_frame_per_line() -> None:
lines: list[str] = []
stream = NoticeForwardingStream(lines.append)
stream.write("frame 1\rframe 2\rframe 3\n")
assert lines == ["frame 3"]
def test_stream_crlf_is_a_plain_newline_even_split_across_writes() -> None:
lines: list[str] = []
stream = NoticeForwardingStream(lines.append)
stream.write("one\r\n")
stream.write("two\r")
stream.write("\nthree\n")
assert lines == ["one", "two", "three"]
def test_stream_cr_repaints_do_not_accumulate_without_newline() -> None:
lines: list[str] = []
stream = NoticeForwardingStream(lines.append)
for index in range(1000):
stream.write(f"progress {index}\r")
# superseded frames are dropped, not buffered for the whole session
assert len(stream._buffer) < 64
assert lines == []
stream.flush()
assert lines == ["progress 999"]
def test_stream_caps_buffer_for_newline_free_output() -> None:
lines: list[str] = []
stream = NoticeForwardingStream(lines.append)
stream.write("x" * 10_000)
assert lines and lines[0].startswith("xxx")
assert len(stream._buffer) <= 8192
def test_flush_holds_back_incomplete_escape_fragment() -> None:
lines: list[str] = []
stream = NoticeForwardingStream(lines.append)
stream.write("hello \x1b[3")
stream.flush()
# the dangling CSI prefix must not reach the host as raw control bytes
assert lines == ["hello "]
stream.write("1mred\x1b[0m\n")
assert lines == ["hello ", "\x1b[31mred\x1b[0m"]
def test_flush_emits_complete_escape_sequences_verbatim() -> None:
lines: list[str] = []
stream = NoticeForwardingStream(lines.append)
stream.write("\x1b[31mred\x1b[0m")
stream.flush()
assert lines == ["\x1b[31mred\x1b[0m"]
def test_stream_reports_tty_so_rich_keeps_color() -> None:
stream = NoticeForwardingStream(lambda _line: None)
# isatty must be True or Rich would strip the ANSI the host needs to recolor.
assert stream.isatty() is True
assert stream.writable() is True
def test_capture_restores_stdout_and_forwards_console_output() -> None:
original_stdout = sys.stdout
original_stderr = sys.stderr
lines: list[str] = []
from opensquilla.cli.ui import console
with capture_stdout_as_notices(lines.append):
assert sys.stdout is not original_stdout
console.print("[yellow]hello world[/yellow]")
# Restored on exit...
assert sys.stdout is original_stdout
assert sys.stderr is original_stderr
# ...and the markup was rendered out, not forwarded verbatim.
joined = "".join(lines)
assert "hello world" in joined
assert "[yellow]" not in joined
def test_capture_restores_stdout_on_exception() -> None:
original_stdout = sys.stdout
with pytest.raises(RuntimeError):
with capture_stdout_as_notices(lambda _line: None):
raise RuntimeError("boom")
assert sys.stdout is original_stdout
def test_capture_keeps_real_stderr_accessible_while_active() -> None:
original_stderr = sys.stderr
with capture_stdout_as_notices(lambda _line: None):
assert sys.stderr is not original_stderr
assert real_stderr() is original_stderr
assert real_stderr() is sys.stderr
class _SendRecordingOutput:
"""Minimal handle satisfying the runtime-checkable TuiOutputHandle protocol."""
def __init__(self) -> None:
from opensquilla.engine.commands import Surface
self.approval_surface = Surface.CLI_GATEWAY
self.sent: list[tuple[str, dict[str, Any]]] = []
async def write_through(self, payload: str) -> None: # unused here
raise AssertionError("notices must not go through write_through")
def stream_output(self): # pragma: no cover - never called
from contextlib import asynccontextmanager
@asynccontextmanager
async def _cm():
raise AssertionError("stream_output should not be called")
yield
return _cm()
async def send_message(self, message_type: str, payload: dict[str, Any]) -> None:
self.sent.append((message_type, payload))
class _DeadBridgeOutput(_SendRecordingOutput):
async def send_message(self, message_type: str, payload: dict[str, Any]) -> None:
raise RuntimeError("OpenTUI bridge is not started")
@pytest.mark.asyncio
async def test_forward_console_notice_sends_notice_write() -> None:
output = _SendRecordingOutput()
scope: dict[str, Any] = {}
TuiOutputBinding(scope).expose(output)
forward_console_notice(scope, "compact skipped")
await asyncio.sleep(0) # let the scheduled send run
assert output.sent == [("notice.write", {"text": "compact skipped"})]
@pytest.mark.asyncio
async def test_forward_console_notice_retains_pending_task_references() -> None:
output = _SendRecordingOutput()
scope: dict[str, Any] = {}
TuiOutputBinding(scope).expose(output)
pending: set[asyncio.Task[None]] = set()
forward_console_notice(scope, "queued line", pending_tasks=pending)
assert len(pending) == 1
await asyncio.gather(*pending)
assert output.sent == [("notice.write", {"text": "queued line"})]
@pytest.mark.asyncio
async def test_forward_console_notice_without_output_falls_back_to_real_stderr(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# No exposed output handle -> the line must still reach the terminal (and
# never raise): after teardown this is the only way diagnostics survive.
fake_stderr = io.StringIO()
monkeypatch.setattr(sys, "stderr", fake_stderr)
forward_console_notice({}, "leftover diagnostic")
await asyncio.sleep(0)
assert "leftover diagnostic" in fake_stderr.getvalue()
@pytest.mark.asyncio
async def test_forward_console_notice_falls_back_when_bridge_send_fails(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# The sidecar-crash sequence: the crash notice is scheduled, the bridge is
# torn down before the task runs, and the send raises. The line must land
# on the REAL stderr saved by the active capture, not the dead sink.
output = _DeadBridgeOutput()
scope: dict[str, Any] = {}
TuiOutputBinding(scope).expose(output)
fake_stderr = io.StringIO()
monkeypatch.setattr(sys, "stderr", fake_stderr)
with capture_stdout_as_notices(lambda _line: None):
forward_console_notice(scope, "Input surface error: host exited with code 7")
await asyncio.sleep(0) # task runs while the capture is still active
assert "Input surface error: host exited with code 7" in fake_stderr.getvalue()
+561
View File
@@ -0,0 +1,561 @@
from __future__ import annotations
import pytest
from opensquilla.cli.chat.turn import UsageSummary
from opensquilla.cli.tui.backend.render_summary import summarize_args, summarize_result
from opensquilla.cli.tui.opentui.renderer import OpenTuiStreamRenderer, _format_tokens
from opensquilla.engine.usage import SessionTotalsSnapshot
def test_web_search_args_render_query_summary() -> None:
assert summarize_args("web_search", {"query": "OpenSquilla canonical search"}) == (
"OpenSquilla canonical search"
)
assert summarize_args("web_discover", {"query": "OpenSquilla discover links"}) == (
"OpenSquilla discover links"
)
class _RecordingHandle:
def __init__(self) -> None:
self.sent: list[tuple[str, dict]] = []
async def send_message(self, message_type: str, payload: dict) -> None:
self.sent.append((message_type, payload))
class _ToolbarRecordingHandle(_RecordingHandle):
def __init__(self) -> None:
super().__init__()
self.toolbar: dict[str, object] = {}
self.toolbar_updates: list[tuple[str, object | None]] = []
self.invalidated = 0
def set_toolbar(self, key: str, value: object | None) -> None:
self.toolbar_updates.append((key, value))
if value is None:
self.toolbar.pop(key, None)
return
self.toolbar[key] = value
def invalidate(self) -> None:
self.invalidated += 1
@pytest.mark.asyncio
async def test_intermediate_text_is_thinking_final_text_is_answer_card() -> None:
"""Intermediate narration before a tool (presentation="intermediate") opens
a purple thinking block; the final answer (presentation="answer") opens a
cyan answer card. Each is the right kind from its first delta — no retype."""
handle = _RecordingHandle()
r = OpenTuiStreamRenderer(output_handle=handle)
r.__enter__()
await r.aappend_text("Let me check", presentation="intermediate")
await r.atool_start("web_search", {"query": "x"}, "c1")
await r.atool_finished("c1", success=True, result="result line")
await r.aappend_text("Final answer", presentation="answer")
await r.afinalize(None)
assert not [t for t, _ in handle.sent if t == "block.retype"]
begins = [
(p["kind"], p["id"])
for t, p in handle.sent
if t == "block.begin" and p.get("kind") in {"thinking", "answer"}
]
# intermediate -> thinking block, final -> answer card, in that order
assert [kind for kind, _id in begins] == ["thinking", "answer"]
tool_begins = [p for t, p in handle.sent if t == "block.begin" and p.get("kind") == "tool"]
assert tool_begins and tool_begins[0]["meta"]["name"] == "web_search"
@pytest.mark.asyncio
async def test_final_answer_is_a_card_from_the_first_delta() -> None:
"""A pure-answer turn opens an answer card on the very first delta and
streams into it — never a thinking block, never a retype."""
handle = _RecordingHandle()
r = OpenTuiStreamRenderer(output_handle=handle)
r.__enter__()
await r.aappend_text("The ", presentation="answer")
await r.aappend_text("answer.", presentation="answer")
await r.afinalize(None)
assert not [t for t, _ in handle.sent if t == "block.retype"]
answer_begins = [p for t, p in handle.sent if t == "block.begin" and p.get("kind") == "answer"]
assert len(answer_begins) == 1
assert not [
p for t, p in handle.sent if t == "block.begin" and p.get("kind") == "thinking"
]
@pytest.mark.asyncio
async def test_reasoning_streams_into_its_own_block_and_closes_before_text() -> None:
"""Reasoning (the model's extended-thinking process) streams live into a
dedicated 'reasoning' block — the host shows a dim rolling peek and
collapses it to 'Thought for Ns' on block.end — and the block closes
before the answer text opens so the two never interleave."""
handle = _RecordingHandle()
r = OpenTuiStreamRenderer(output_handle=handle)
r.__enter__()
await r.aappend_reasoning("let me think ")
await r.aappend_reasoning("step by step about the internals")
await r.aappend_text("the answer")
await r.afinalize(None)
# the reasoning block is its own kind, distinct from intermediate "thinking"
# text and from the "answer" card
reasoning_begins = [
p for t, p in handle.sent if t == "block.begin" and p.get("kind") == "reasoning"
]
assert len(reasoning_begins) == 1
reasoning_id = reasoning_begins[0]["id"]
# every reasoning delta streams into that block (the host renders the peek)
reasoning_appends = [
p["delta"] for t, p in handle.sent if t == "block.append" and p["id"] == reasoning_id
]
assert reasoning_appends == ["let me think ", "step by step about the internals"]
# the reasoning block closes before the answer block opens
events = [
(t, p.get("id"), p.get("kind"))
for t, p in handle.sent
if t in ("block.begin", "block.end")
]
end_reasoning = events.index(("block.end", reasoning_id, None))
begin_answer = next(
i for i, (t, _i, kind) in enumerate(events) if t == "block.begin" and kind == "answer"
)
assert end_reasoning < begin_answer
assert not [t for t, _ in handle.sent if t == "block.retype"]
# the reasoning marker is closed before the answer block opens
ends = [p["id"] for t, p in handle.sent if t == "block.end"]
assert reasoning_id in ends
answer_begins = [
p for t, p in handle.sent if t == "block.begin" and p.get("kind") == "answer"
]
assert len(answer_begins) == 1
@pytest.mark.asyncio
async def test_answer_only_turn_has_no_retype() -> None:
handle = _RecordingHandle()
r = OpenTuiStreamRenderer(output_handle=handle)
r.__enter__()
await r.aappend_text("Direct answer")
await r.afinalize(None)
assert not [t for t, _ in handle.sent if t == "block.retype"]
answer_begins = [p for t, p in handle.sent if t == "block.begin" and p.get("kind") == "answer"]
assert len(answer_begins) == 1
@pytest.mark.asyncio
async def test_renderer_marks_tool_error_and_cancel() -> None:
handle = _RecordingHandle()
r = OpenTuiStreamRenderer(output_handle=handle)
r.__enter__()
await r.atool_start("grep", {"pattern": "x"}, "c2")
await r.atool_finished("c2", success=False, error="boom")
await r.aerror("turn-level failure")
await r.afinalize(None, cancelled=True)
updates = [p for t, p in handle.sent if t == "block.update"]
assert any(p["patch"].get("status") == "error" for p in updates)
error_begins = [p for t, p in handle.sent if t == "block.begin" and p.get("kind") == "error"]
assert error_begins and error_begins[0]["meta"]["text"] == "turn-level failure"
end = [p for t, p in handle.sent if t == "turn.end"][0]
assert end["cancelled"] is True
# the failed tool's detail line was appended into the tool block
appends = [p for t, p in handle.sent if t == "block.append" and p["id"] == "c2"]
assert any("boom" in p["delta"] for p in appends)
@pytest.mark.asyncio
async def test_cancel_midtool_closes_open_tool_block() -> None:
handle = _RecordingHandle()
r = OpenTuiStreamRenderer(output_handle=handle)
r.__enter__()
await r.atool_start("grep", {"pattern": "x"}, "c9")
# NO atool_finished — simulate cancellation
await r.afinalize(None, cancelled=True)
# the open tool must be force-closed: an error update + an end for its id
updates = [p for t, p in handle.sent if t == "block.update" and p["id"] == "c9"]
ends = [p for t, p in handle.sent if t == "block.end" and p["id"] == "c9"]
assert updates and updates[-1]["patch"].get("status") == "error"
assert ends, "cancelled in-flight tool block was never closed"
@pytest.mark.asyncio
async def test_aclose_without_finalize_tears_down_errored_turn() -> None:
"""Error paths end the turn without afinalize; the guaranteed aclose must
still end the turn, idle the pill, and re-enable the composer so the UI
never stays busy and the next turn never merges into the errored card."""
handle = _RecordingHandle()
r = OpenTuiStreamRenderer(output_handle=handle)
r.__enter__()
await r.aappend_text("partial answer")
await r.aerror("provider exploded")
await r.aclose()
types = [t for t, _ in handle.sent]
assert "turn.end" in types
statuses = [p for t, p in handle.sent if t == "turn.status"]
assert statuses[-1]["phase"] == "idle"
assert statuses[-1]["active"] is False
composer_sets = [p for t, p in handle.sent if t == "composer.set"]
assert composer_sets[-1] == {"disabled": False}
# the open answer block was force-closed
begins = {p["id"] for t, p in handle.sent if t == "block.begin" and p.get("kind") == "answer"}
ends = {p["id"] for t, p in handle.sent if t == "block.end"}
assert begins <= ends
@pytest.mark.asyncio
async def test_aclose_force_closes_inflight_tool_block() -> None:
handle = _RecordingHandle()
r = OpenTuiStreamRenderer(output_handle=handle)
r.__enter__()
await r.atool_start("grep", {"pattern": "x"}, "c7")
# NO atool_finished, NO afinalize — e.g. a provider timeout mid-tool
await r.aclose()
updates = [p for t, p in handle.sent if t == "block.update" and p["id"] == "c7"]
ends = [p for t, p in handle.sent if t == "block.end" and p["id"] == "c7"]
assert updates and updates[-1]["patch"].get("status") == "error"
assert ends
assert [t for t, _ in handle.sent if t == "turn.end"]
@pytest.mark.asyncio
async def test_aclose_after_afinalize_emits_no_second_teardown() -> None:
handle = _RecordingHandle()
r = OpenTuiStreamRenderer(output_handle=handle)
r.__enter__()
await r.aappend_text("done")
await r.afinalize(None)
await r.aclose()
assert len([t for t, _ in handle.sent if t == "turn.end"]) == 1
@pytest.mark.asyncio
async def test_aclose_before_any_output_is_a_noop() -> None:
handle = _RecordingHandle()
r = OpenTuiStreamRenderer(output_handle=handle)
r.__enter__()
await r.aclose()
assert handle.sent == []
@pytest.mark.asyncio
async def test_aclose_tolerates_dead_output_handle() -> None:
class _DeadHandle:
async def send_message(self, message_type: str, payload: dict) -> None:
raise RuntimeError("OpenTUI bridge is not started")
handle = _RecordingHandle()
r = OpenTuiStreamRenderer(output_handle=handle)
r.__enter__()
await r.aappend_text("partial")
# the bridge dies before teardown; aclose must not raise from its emits
r.output_handle = _DeadHandle()
await r.aclose()
@pytest.mark.asyncio
async def test_status_pill_returns_to_output_when_text_resumes_after_tool() -> None:
"""In the narrate-then-act flow the pill must not stay stuck on the
finished tool's name while the final answer streams."""
handle = _RecordingHandle()
r = OpenTuiStreamRenderer(output_handle=handle)
r.__enter__()
await r.aappend_text("Let me look", presentation="intermediate")
await r.atool_start("grep", {"pattern": "x"}, "c1")
await r.atool_finished("c1", success=True, result="hit")
await r.aappend_text("Final answer", presentation="answer")
await r.afinalize(None)
phases = [p["phase"] for t, p in handle.sent if t == "turn.status"]
assert phases == ["thinking", "output", "tool", "output", "idle"]
@pytest.mark.asyncio
async def test_astatus_updates_pill_and_renders_dim_status_line() -> None:
"""Status messages (artifact saved, task-group progress) must be visible:
a transient pill label plus a dim in-card status line."""
handle = _RecordingHandle()
r = OpenTuiStreamRenderer(output_handle=handle)
r.__enter__()
await r.aappend_text("working")
await r.astatus("artifact written: report.md")
await r.aappend_text(" more")
await r.afinalize(None)
status_blocks = [
p for t, p in handle.sent if t == "block.begin" and p.get("kind") == "status"
]
assert status_blocks and status_blocks[0]["meta"]["text"] == "artifact written: report.md"
assert status_blocks[0]["meta"]["style"] == "dim"
ends = {p["id"] for t, p in handle.sent if t == "block.end"}
assert status_blocks[0]["id"] in ends
labels = [(p["phase"], p["label"]) for t, p in handle.sent if t == "turn.status"]
assert ("output", "artifact written: report.md") in labels
# the pill label is transient: the next text delta restores the phase label
status_index = labels.index(("output", "artifact written: report.md"))
assert ("output", "output") in labels[status_index + 1 :]
@pytest.mark.asyncio
async def test_astatus_ignores_blank_messages() -> None:
handle = _RecordingHandle()
r = OpenTuiStreamRenderer(output_handle=handle)
r.__enter__()
await r.astatus(" ")
assert not [p for t, p in handle.sent if t == "block.begin"]
@pytest.mark.asyncio
async def test_usage_block_emitted_before_turn_end() -> None:
handle = _RecordingHandle()
r = OpenTuiStreamRenderer(output_handle=handle)
r.__enter__()
await r.aappend_text("done")
await r.afinalize(None)
types = [t for t, _ in handle.sent]
usage_begin = next(
i
for i, (t, p) in enumerate(handle.sent)
if t == "block.begin" and p.get("kind") == "usage"
)
turn_end = types.index("turn.end")
assert usage_begin < turn_end, "usage block must render in the active turn, before turn.end"
# answer card still closes (its block.end) before usage
answer_end = next(
i
for i, (t, p) in enumerate(handle.sent)
if t == "block.end" and p["id"].endswith("-b1")
)
assert answer_end < usage_begin
@pytest.mark.asyncio
async def test_anonymous_tools_each_close_independently() -> None:
handle = _RecordingHandle()
r = OpenTuiStreamRenderer(output_handle=handle)
r.__enter__()
await r.atool_start("a", {}, None)
await r.atool_finished(None, success=True, result="ra")
await r.atool_start("b", {}, None)
await r.atool_finished(None, success=True, result="rb")
begins = [p for t, p in handle.sent if t == "block.begin" and p.get("kind") == "tool"]
ends = [p for t, p in handle.sent if t == "block.end"]
assert len(begins) == 2
# each tool block gets its own end (distinct ids), so neither overwrites
# the other and no dangling block is left without a close.
begin_ids = {p["id"] for p in begins}
end_ids = {p["id"] for p in ends}
assert len(begin_ids) == 2
assert begin_ids <= end_ids
def test_format_tokens_abbreviates_thousands() -> None:
assert _format_tokens(856) == "856"
assert _format_tokens(1234) == "1.2k"
assert _format_tokens(0) == "0"
assert _format_tokens(None) == "0"
@pytest.mark.asyncio
async def test_afinalize_writes_usage_to_toolbar_and_invalidates() -> None:
handle = _ToolbarRecordingHandle()
r = OpenTuiStreamRenderer(output_handle=handle)
r.__enter__()
await r.aappend_text("done")
await r.afinalize(UsageSummary(input_tokens=1234, output_tokens=856))
assert handle.toolbar.get("router_usage") == "1.2k/856"
assert handle.invalidated == 2
@pytest.mark.asyncio
async def test_afinalize_writes_session_input_to_toolbar() -> None:
handle = _ToolbarRecordingHandle()
r = OpenTuiStreamRenderer(output_handle=handle)
r.__enter__()
await r.aappend_text("done")
await r.afinalize(
UsageSummary(
input_tokens=1,
output_tokens=2,
session_totals=SessionTotalsSnapshot(input_tokens=84_000),
)
)
assert handle.toolbar.get("router_usage") == "1/2"
assert handle.toolbar.get("router_session_input") == 84_000
assert handle.invalidated == 2
@pytest.mark.asyncio
async def test_afinalize_clears_stale_session_input_when_snapshot_missing() -> None:
handle = _ToolbarRecordingHandle()
handle.toolbar["router_session_input"] = 84_000
r = OpenTuiStreamRenderer(output_handle=handle)
r.__enter__()
await r.aappend_text("done")
await r.afinalize(UsageSummary(input_tokens=1, output_tokens=2))
assert handle.toolbar.get("router_usage") == "1/2"
assert "router_session_input" not in handle.toolbar
assert handle.invalidated == 2
@pytest.mark.asyncio
async def test_turn_begin_clears_stale_router_usage_before_finalize_writes_current_usage() -> None:
handle = _ToolbarRecordingHandle()
handle.toolbar["router_usage"] = "999/888"
r = OpenTuiStreamRenderer(output_handle=handle)
r.__enter__()
await r.astatus("thinking")
assert ("router_usage", None) in handle.toolbar_updates
assert "router_usage" not in handle.toolbar
assert handle.invalidated == 1
await r.afinalize(UsageSummary(input_tokens=5, output_tokens=7))
assert handle.toolbar.get("router_usage") == "5/7"
assert handle.toolbar_updates[-1] == ("router_usage", "5/7")
assert handle.invalidated == 2
@pytest.mark.asyncio
async def test_no_usage_turn_keeps_router_usage_cleared() -> None:
handle = _ToolbarRecordingHandle()
handle.toolbar["router_usage"] = "999/888"
r = OpenTuiStreamRenderer(output_handle=handle)
r.__enter__()
await r.aappend_text("done")
await r.afinalize(None)
assert "router_usage" not in handle.toolbar
assert handle.invalidated == 1
@pytest.mark.asyncio
async def test_afinalize_tolerates_handle_without_set_toolbar() -> None:
# The plain recording handle has no set_toolbar/invalidate — afinalize must
# not crash when wiring usage into the router toolbar.
handle = _RecordingHandle()
r = OpenTuiStreamRenderer(output_handle=handle)
r.__enter__()
await r.aappend_text("done")
await r.afinalize(UsageSummary(input_tokens=5, output_tokens=7))
assert [t for t, _ in handle.sent if t == "turn.end"]
def test_tool_result_summary_keeps_meaningful_lines_without_banners() -> None:
summary = summarize_result(
"exit_code=0\n"
".\n"
"·\n"
"...\n"
"═══ 一级模块 ═══\n"
"agents\n"
"────────\n"
"exit_code=1\n"
"================\n"
"src/opensquilla/main.py\n"
)
assert summary == "agents\nexit_code=1\nsrc/opensquilla/main.py"
assert "exit_code=0" not in summary
assert " / " not in summary
assert "═══" not in summary
def test_tool_result_summary_stringifies_single_structured_msg_payload() -> None:
summary = summarize_result(
{
"type": "msg",
"msg": [
{"kind": "text", "text": "first"},
{"kind": "data", "value": {"rows": [1, 2]}},
],
}
)
assert summary.startswith("[")
assert '"type": "msg"' not in summary
assert '"rows": [1, 2]' in summary
def test_tool_result_summary_stringifies_structured_msg_payloads() -> None:
summary = summarize_result(
[
{"type": "msg", "msg": {"files": ["main.py"], "count": 1}},
{"type": "msg", "msg": ["ok", {"status": "done"}]},
]
)
assert summary == (
'{"count": 1, "files": ["main.py"]}\n'
'["ok", {"status": "done"}]'
)
async def test_aturn_started_announces_thinking_before_any_provider_event() -> None:
"""The stream loop announces the turn as soon as it starts, so the pill
pulses "thinking" through the silent model-thinking window instead of the
UI sitting on "ready" until the first token."""
handle = _RecordingHandle()
renderer = OpenTuiStreamRenderer(output_handle=handle)
await renderer.aturn_started()
types = [message_type for message_type, _payload in handle.sent]
assert "turn.begin" in types
status = next(p for t, p in handle.sent if t == "turn.status")
assert status["phase"] == "thinking"
assert status["active"] is True
# Idempotent: the first real event must not begin a second turn.
await renderer.aturn_started()
assert types.count("turn.begin") == 1
async def test_answer_stream_strips_routing_directive_tags() -> None:
handle = _RecordingHandle()
renderer = OpenTuiStreamRenderer(output_handle=handle)
# Tag split across deltas, then the real answer.
await renderer.aappend_text("[[reply_to", presentation="answer")
await renderer.aappend_text("_current]]\n", presentation="answer")
await renderer.aappend_text("My name is OpenSquilla.", presentation="answer")
await renderer.afinalize()
appends = [p["delta"] for t, p in handle.sent if t == "block.append"]
joined = "".join(appends)
assert "reply_to_current" not in joined
assert joined == "My name is OpenSquilla."
# The raw logical buffer (TurnResult text) keeps the model's exact output.
assert "[[reply_to_current]]" in renderer.buffer
async def test_tag_only_delta_opens_no_answer_block() -> None:
handle = _RecordingHandle()
renderer = OpenTuiStreamRenderer(output_handle=handle)
await renderer.aappend_text("[[reply_to_current]]", presentation="answer")
await renderer.afinalize()
kinds = [p["kind"] for t, p in handle.sent if t == "block.begin"]
assert "answer" not in kinds
async def test_held_bracket_prefix_flushes_into_the_block_on_close() -> None:
handle = _RecordingHandle()
renderer = OpenTuiStreamRenderer(output_handle=handle)
await renderer.aappend_text("see ", presentation="answer")
# A bracket run that never completes into a directive is ordinary text.
await renderer.aappend_text("[[re", presentation="answer")
await renderer.afinalize()
appends = [p["delta"] for t, p in handle.sent if t == "block.append"]
assert "".join(appends) == "see [[re"
+832
View File
@@ -0,0 +1,832 @@
from __future__ import annotations
import asyncio
from dataclasses import asdict
from typing import Any
import pytest
from opensquilla.cli.tui.opentui.messages import (
CompletionContext,
HostApprovalResponse,
HostCompletionRequest,
HostError,
HostInputCancel,
HostInputEof,
HostInputSubmit,
HostProtocolUnknown,
HostResize,
RouterPluginState,
ScrollbackWrite,
)
from opensquilla.cli.tui.opentui.surface import (
OpenTuiOutputHandle,
OpenTuiSurface,
open_opentui_surface,
)
from opensquilla.engine.commands import Surface
class FakeOpenTuiBridge:
def __init__(self) -> None:
self.messages: asyncio.Queue[object] = asyncio.Queue()
self.sent: list[tuple[str, dict[str, Any] | None]] = []
async def send(self, message_type: str, payload: object | None = None) -> None:
if payload is None:
self.sent.append((message_type, None))
return
self.sent.append(
(message_type, payload if isinstance(payload, dict) else asdict(payload))
)
async def next_message(self) -> object | None:
return await self.messages.get()
async def start(self) -> None:
return None
async def close(self) -> None:
return None
@pytest.mark.asyncio
async def test_opentui_surface_returns_submitted_lines_and_eof() -> None:
bridge = FakeOpenTuiBridge()
surface = OpenTuiSurface(bridge, approval_surface=Surface.CLI_GATEWAY)
bridge.messages.put_nowait(HostInputSubmit(text="中文 prompt"))
assert await surface.next_line() == "中文 prompt"
bridge.messages.put_nowait(HostInputEof())
assert await surface.next_line() is None
@pytest.mark.asyncio
async def test_opentui_surface_delegates_cancel_and_keeps_waiting() -> None:
bridge = FakeOpenTuiBridge()
surface = OpenTuiSurface(bridge, approval_surface=Surface.CLI_GATEWAY)
cancelled: list[str] = []
surface.set_cancel_callback(lambda: cancelled.append("cancel"))
bridge.messages.put_nowait(HostInputCancel())
bridge.messages.put_nowait(HostInputSubmit(text="after cancel"))
assert await surface.next_line() == "after cancel"
assert cancelled == ["cancel"]
@pytest.mark.asyncio
async def test_host_error_frames_are_non_fatal_diagnostics() -> None:
"""The host recovers from per-message failures and keeps running; one
error frame must not end the session."""
bridge = FakeOpenTuiBridge()
surface = OpenTuiSurface(bridge, approval_surface=Surface.CLI_GATEWAY)
for _ in range(3):
bridge.messages.put_nowait(HostError(message="render hiccup"))
bridge.messages.put_nowait(HostInputSubmit(text="still alive"))
assert await surface.next_line() == "still alive"
@pytest.mark.asyncio
async def test_uninterrupted_host_error_flood_tears_the_session_down() -> None:
bridge = FakeOpenTuiBridge()
surface = OpenTuiSurface(bridge, approval_surface=Surface.CLI_GATEWAY)
for _ in range(8):
bridge.messages.put_nowait(HostError(message="dispatch is broken"))
with pytest.raises(RuntimeError, match="OpenTUI host error: dispatch is broken"):
await surface.next_line()
@pytest.mark.asyncio
async def test_host_error_streak_resets_on_any_other_frame() -> None:
bridge = FakeOpenTuiBridge()
surface = OpenTuiSurface(bridge, approval_surface=Surface.CLI_GATEWAY)
for _ in range(7):
bridge.messages.put_nowait(HostError(message="hiccup"))
bridge.messages.put_nowait(HostResize(width=100, height=30))
for _ in range(7):
bridge.messages.put_nowait(HostError(message="hiccup"))
bridge.messages.put_nowait(HostInputSubmit(text="survived"))
assert await surface.next_line() == "survived"
@pytest.mark.asyncio
async def test_protocol_unknown_frames_are_logged_and_skipped() -> None:
bridge = FakeOpenTuiBridge()
surface = OpenTuiSurface(bridge, approval_surface=Surface.CLI_GATEWAY)
bridge.messages.put_nowait(HostProtocolUnknown(message_type="future.type"))
bridge.messages.put_nowait(HostInputSubmit(text="next"))
assert await surface.next_line() == "next"
@pytest.mark.asyncio
async def test_resize_frames_update_last_known_size() -> None:
bridge = FakeOpenTuiBridge()
surface = OpenTuiSurface(bridge, approval_surface=Surface.CLI_GATEWAY)
assert surface.last_known_size is None
bridge.messages.put_nowait(HostResize(width=120, height=40))
bridge.messages.put_nowait(HostInputSubmit(text="go"))
assert await surface.next_line() == "go"
assert surface.last_known_size == (120, 40)
@pytest.mark.asyncio
async def test_approval_response_resolves_waiter_and_next_line_keeps_pumping() -> None:
bridge = FakeOpenTuiBridge()
surface = OpenTuiSurface(bridge, approval_surface=Surface.CLI_GATEWAY)
output = surface.output_handle
request_task = asyncio.create_task(
output.request_approval(
{"id": "appr-1", "tool": "shell", "summary": "touch demo.txt", "choices": []},
)
)
await asyncio.sleep(0)
assert bridge.sent[-1][0] == "approval.request"
assert bridge.sent[-1][1]["id"] == "appr-1"
bridge.messages.put_nowait(HostApprovalResponse(id="appr-1", approved=True, choice=None))
bridge.messages.put_nowait(HostInputSubmit(text="next input"))
# The decision frame resolves the waiter and is never surfaced as input.
assert await surface.next_line() == "next input"
response = await request_task
assert response == HostApprovalResponse(id="appr-1", approved=True, choice=None)
# A delivered decision already closed the overlay host-side; no dismiss.
assert "approval.dismiss" not in [message_type for message_type, _payload in bridge.sent]
@pytest.mark.asyncio
async def test_approval_waiter_survives_next_line_task_recreation() -> None:
"""The waiter registry lives on the surface's output handle, so a pending
approval resolves even when the runtime cancels and re-creates its input
task while the request is outstanding."""
bridge = FakeOpenTuiBridge()
surface = OpenTuiSurface(bridge, approval_surface=Surface.CLI_GATEWAY)
output = surface.output_handle
request_task = asyncio.create_task(
output.request_approval({"id": "appr-2", "tool": "shell", "summary": "", "choices": []})
)
await asyncio.sleep(0)
first_reader = asyncio.create_task(surface.next_line())
await asyncio.sleep(0)
first_reader.cancel()
with pytest.raises(asyncio.CancelledError):
await first_reader
bridge.messages.put_nowait(
HostApprovalResponse(id="appr-2", approved=False, choice="deny")
)
bridge.messages.put_nowait(HostInputSubmit(text="after recreation"))
assert await surface.next_line() == "after recreation"
response = await request_task
assert response == HostApprovalResponse(id="appr-2", approved=False, choice="deny")
@pytest.mark.asyncio
async def test_request_approval_times_out_to_none() -> None:
bridge = FakeOpenTuiBridge()
output = OpenTuiOutputHandle(bridge, approval_surface=Surface.CLI_GATEWAY)
response = await output.request_approval(
{"id": "appr-3", "tool": "shell", "summary": "", "choices": []},
timeout=0.01,
)
assert response is None
@pytest.mark.asyncio
async def test_request_approval_timeout_dismisses_host_overlay() -> None:
"""A timed-out approval must close the host overlay, or the stale modal
swallows the user's next Enter/Esc/y/n keypress."""
bridge = FakeOpenTuiBridge()
output = OpenTuiOutputHandle(bridge, approval_surface=Surface.CLI_GATEWAY)
response = await output.request_approval(
{"id": "appr-6", "tool": "shell", "summary": "", "choices": []},
timeout=0.01,
)
assert response is None
assert bridge.sent[-1] == ("approval.dismiss", {"id": "appr-6"})
@pytest.mark.asyncio
async def test_cancelled_approval_request_dismisses_host_overlay() -> None:
"""Turn cancellation (Ctrl+C) stops waiting on the overlay; the host must
be told to close it instead of leaving a dead modal mounted."""
bridge = FakeOpenTuiBridge()
output = OpenTuiOutputHandle(bridge, approval_surface=Surface.CLI_GATEWAY)
request_task = asyncio.create_task(
output.request_approval({"id": "appr-7", "tool": "shell", "summary": "", "choices": []})
)
await asyncio.sleep(0)
assert bridge.sent[-1][0] == "approval.request"
request_task.cancel()
with pytest.raises(asyncio.CancelledError):
await request_task
assert bridge.sent[-1] == ("approval.dismiss", {"id": "appr-7"})
assert output._approval_waiters == {}
@pytest.mark.asyncio
async def test_request_approval_returns_none_when_bridge_send_fails() -> None:
bridge = _FailingBridge()
output = OpenTuiOutputHandle(bridge, approval_surface=Surface.CLI_GATEWAY)
response = await output.request_approval(
{"id": "appr-4", "tool": "shell", "summary": "", "choices": []},
)
assert response is None
@pytest.mark.asyncio
async def test_request_approval_without_id_returns_none_without_sending() -> None:
bridge = FakeOpenTuiBridge()
output = OpenTuiOutputHandle(bridge, approval_surface=Surface.CLI_GATEWAY)
assert await output.request_approval({"tool": "shell"}) is None
assert bridge.sent == []
@pytest.mark.asyncio
async def test_host_eof_denies_pending_approval_waiters() -> None:
bridge = FakeOpenTuiBridge()
surface = OpenTuiSurface(bridge, approval_surface=Surface.CLI_GATEWAY)
output = surface.output_handle
request_task = asyncio.create_task(
output.request_approval({"id": "appr-5", "tool": "shell", "summary": "", "choices": []})
)
await asyncio.sleep(0)
bridge.messages.put_nowait(HostInputEof())
assert await surface.next_line() is None
assert await request_task is None
@pytest.mark.asyncio
async def test_unmatched_approval_response_is_skipped_and_loop_continues() -> None:
bridge = FakeOpenTuiBridge()
surface = OpenTuiSurface(bridge, approval_surface=Surface.CLI_GATEWAY)
bridge.messages.put_nowait(HostApprovalResponse(id="ghost", approved=True, choice=None))
bridge.messages.put_nowait(HostInputSubmit(text="still serving input"))
assert await surface.next_line() == "still serving input"
@pytest.mark.asyncio
async def test_opentui_output_handle_writes_to_scrollback() -> None:
bridge = FakeOpenTuiBridge()
output = OpenTuiOutputHandle(bridge, approval_surface=Surface.CLI_GATEWAY)
await output.write_through("tool output\nfinal answer")
assert bridge.sent == [
(
"scrollback.write",
asdict(ScrollbackWrite(text="tool output\nfinal answer")),
)
]
@pytest.mark.asyncio
async def test_open_opentui_surface_sends_completion_context_on_startup() -> None:
bridge = FakeOpenTuiBridge()
async with open_opentui_surface(
surface=Surface.CLI_GATEWAY,
ready_marker="",
print_ready_marker=False,
bridge=bridge,
completion_context=CompletionContext(catalog=(), files=()),
):
pass
assert bridge.sent == [
(
"composer.set",
{
"placeholder": "send a message",
"text": "",
"disabled": False,
},
),
(
"completion.context",
{
"catalog": (),
"files": (),
"filters_sensitive_paths": True,
},
),
]
@pytest.mark.asyncio
async def test_open_opentui_surface_marks_ready_after_completion_context() -> None:
bridge = FakeOpenTuiBridge()
async with open_opentui_surface(
surface=Surface.CLI_GATEWAY,
ready_marker="READY",
bridge=bridge,
completion_context=CompletionContext(catalog=(), files=()),
):
pass
assert [message_type for message_type, _payload in bridge.sent] == [
"composer.set",
"completion.context",
"scrollback.write",
]
assert bridge.sent[-1] == (
"scrollback.write",
asdict(ScrollbackWrite(text="READY\n")),
)
@pytest.mark.asyncio
async def test_open_opentui_surface_omits_ready_marker_by_default(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The readiness sentinel is harness scaffolding — a real session must not
render it unless the env var (or caller) explicitly opts in."""
monkeypatch.delenv("OPENSQUILLA_TUI_READY_MARKER", raising=False)
bridge = FakeOpenTuiBridge()
async with open_opentui_surface(
surface=Surface.CLI_GATEWAY,
bridge=bridge,
completion_context=CompletionContext(catalog=(), files=()),
):
pass
assert [message_type for message_type, _payload in bridge.sent] == [
"composer.set",
"completion.context",
]
@pytest.mark.asyncio
async def test_open_opentui_surface_env_var_opts_into_ready_marker(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("OPENSQUILLA_TUI_READY_MARKER", "HARNESS_READY")
bridge = FakeOpenTuiBridge()
async with open_opentui_surface(
surface=Surface.CLI_GATEWAY,
bridge=bridge,
completion_context=CompletionContext(catalog=(), files=()),
):
pass
assert bridge.sent[-1] == (
"scrollback.write",
asdict(ScrollbackWrite(text="HARNESS_READY\n")),
)
@pytest.mark.asyncio
async def test_opentui_surface_answers_file_completion_without_returning_input(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
) -> None:
from opensquilla.cli.tui.opentui import surface as surface_module
calls: list[tuple[object, str, int]] = []
def fake_enumerate_workspace_files(root, *, query: str, max_results: int):
calls.append((root, query, max_results))
return ["foo.py", "src/foo_bar.py"]
monkeypatch.setattr(
surface_module,
"enumerate_workspace_files",
fake_enumerate_workspace_files,
)
bridge = FakeOpenTuiBridge()
surface = OpenTuiSurface(
bridge,
approval_surface=Surface.CLI_GATEWAY,
workspace_dir=tmp_path,
)
bridge.messages.put_nowait(
HostCompletionRequest(kind="file", query="foo", request_id=7)
)
bridge.messages.put_nowait(HostInputSubmit(text="hello"))
# Completion is served off the input loop so a queued submit is never
# delayed behind enumeration; drain the tracked task before asserting.
assert await surface.next_line() == "hello"
completion_task = surface._completion_task
assert completion_task is not None
await completion_task
assert calls == [(tmp_path, "foo", 50)]
assert bridge.sent == [
(
"completion.response",
{
"request_id": 7,
"kind": "file",
"items": [
{
"label": "foo.py",
"description": "foo.py",
"insert_text": "@foo.py ",
"category": "file",
},
{
"label": "src/foo_bar.py",
"description": "src/foo_bar.py",
"insert_text": "@src/foo_bar.py ",
"category": "file",
},
],
},
)
]
@pytest.mark.asyncio
async def test_opentui_surface_answers_file_completion_with_empty_items_without_workspace() -> None:
bridge = FakeOpenTuiBridge()
surface = OpenTuiSurface(bridge, approval_surface=Surface.CLI_GATEWAY)
bridge.messages.put_nowait(HostCompletionRequest(kind="file", query="foo", request_id=8))
bridge.messages.put_nowait(HostInputSubmit(text="after completion"))
assert await surface.next_line() == "after completion"
completion_task = surface._completion_task
assert completion_task is not None
await completion_task
assert bridge.sent == [
(
"completion.response",
{
"request_id": 8,
"kind": "file",
"items": [],
},
)
]
@pytest.mark.asyncio
async def test_newer_completion_request_supersedes_older_one(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
) -> None:
from opensquilla.cli.tui.opentui import surface as surface_module
def fake_enumerate_workspace_files(root, *, query: str, max_results: int):
return [f"{query}.py"]
monkeypatch.setattr(
surface_module,
"enumerate_workspace_files",
fake_enumerate_workspace_files,
)
bridge = FakeOpenTuiBridge()
surface = OpenTuiSurface(
bridge,
approval_surface=Surface.CLI_GATEWAY,
workspace_dir=tmp_path,
)
bridge.messages.put_nowait(HostCompletionRequest(kind="file", query="old", request_id=1))
bridge.messages.put_nowait(HostCompletionRequest(kind="file", query="new", request_id=2))
bridge.messages.put_nowait(HostInputSubmit(text="go"))
assert await surface.next_line() == "go"
completion_task = surface._completion_task
assert completion_task is not None
await completion_task
responses = [
payload for message_type, payload in bridge.sent
if message_type == "completion.response"
]
assert [response["request_id"] for response in responses] == [2]
@pytest.mark.asyncio
async def test_completion_failure_does_not_kill_the_input_loop(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
) -> None:
from opensquilla.cli.tui.opentui import surface as surface_module
def broken_enumerate(root, *, query: str, max_results: int):
raise OSError("workspace walk failed")
monkeypatch.setattr(surface_module, "enumerate_workspace_files", broken_enumerate)
bridge = FakeOpenTuiBridge()
surface = OpenTuiSurface(
bridge,
approval_surface=Surface.CLI_GATEWAY,
workspace_dir=tmp_path,
)
bridge.messages.put_nowait(HostCompletionRequest(kind="file", query="q", request_id=3))
bridge.messages.put_nowait(HostInputSubmit(text="still here"))
assert await surface.next_line() == "still here"
completion_task = surface._completion_task
assert completion_task is not None
await completion_task
assert bridge.sent == []
@pytest.mark.asyncio
async def test_open_opentui_surface_uses_explicit_workspace_for_completion_context(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
) -> None:
from opensquilla.cli.tui.opentui import surface as surface_module
bridge = FakeOpenTuiBridge()
captured: dict[str, Any] = {}
def fake_build_completion_context(
surface: Surface,
*,
workspace_dir,
) -> CompletionContext:
captured["surface"] = surface
captured["workspace_dir"] = workspace_dir
return CompletionContext(catalog=(), files=("src/main.py",))
monkeypatch.setenv("OPENSQUILLA_WORKSPACE_DIR", "/tmp/wrong-env-workspace")
monkeypatch.setattr(
surface_module,
"build_completion_context",
fake_build_completion_context,
)
async with open_opentui_surface(
surface=Surface.CLI_STANDALONE,
ready_marker="",
print_ready_marker=False,
bridge=bridge,
workspace_dir=tmp_path,
):
pass
assert captured == {
"surface": Surface.CLI_STANDALONE,
"workspace_dir": tmp_path,
}
assert bridge.sent[-1] == (
"completion.context",
{
"catalog": (),
"files": ("src/main.py",),
"filters_sensitive_paths": True,
},
)
def test_opentui_output_toolbar_invalidates_router_plugin() -> None:
bridge = FakeOpenTuiBridge()
output = OpenTuiOutputHandle(bridge, approval_surface=Surface.CLI_GATEWAY)
output.set_toolbar("router_hud", "route standard -> fake-terminal 99% save 42%")
output.set_toolbar("router_hud_style", "normal")
output.set_toolbar("router_baseline_model", "vendor/big-model")
output.set_toolbar("router_source", "router")
output.set_toolbar("router_routing_applied", True)
output.set_toolbar("router_rollout_phase", "full")
output.invalidate()
assert bridge.sent == [
(
"router.update",
asdict(
RouterPluginState(
model="fake-terminal",
route="standard 99%",
saving="42%",
context="-",
style="normal",
baseline_model="vendor/big-model",
source="router",
routing_applied=True,
rollout_phase="full",
)
),
)
]
def test_router_plugin_state_carries_observe_source_and_usage() -> None:
bridge = FakeOpenTuiBridge()
output = OpenTuiOutputHandle(bridge, approval_surface=Surface.CLI_GATEWAY)
output.set_toolbar("router_hud", "observe standard -> fake-terminal 80%")
output.set_toolbar("router_hud_style", "dim")
output.set_toolbar("router_source", "observe")
output.set_toolbar("router_routing_applied", False)
output.set_toolbar("router_rollout_phase", "observe")
output.set_toolbar("router_usage", "1.2k/856")
output.invalidate()
(_, payload) = bridge.sent[0]
assert payload["source"] == "observe"
assert payload["routing_applied"] is False
assert payload["rollout_phase"] == "observe"
assert payload["context"] == "-"
assert payload["io"] == "1.2k/856"
def test_router_plugin_state_formats_cumulative_context_usage_percent() -> None:
bridge = FakeOpenTuiBridge()
output = OpenTuiOutputHandle(bridge, approval_surface=Surface.CLI_GATEWAY)
output.set_toolbar("router_hud", "observe standard -> fake-terminal 80%")
output.set_toolbar("router_hud_style", "dim")
output.set_toolbar("router_usage", "1/2")
output.set_toolbar("router_session_input", 84_000)
output.set_toolbar("router_context_window", 200_000)
output.invalidate()
(_, payload) = bridge.sent[0]
assert payload["context"] == "42%"
assert payload["io"] == "1/2"
@pytest.mark.parametrize(
("session_input", "context_window"),
[
(None, 200_000),
(84_000, None),
],
)
def test_router_plugin_state_reports_no_pressure_without_context_data(
session_input: int | None,
context_window: int | None,
) -> None:
"""Without a known context window there is no honest pressure value: ctx
shows "-" and the io field still carries the turn's token pair (the old
fallback packed the pair into ctx, reading like "1/2 of a window")."""
bridge = FakeOpenTuiBridge()
output = OpenTuiOutputHandle(bridge, approval_surface=Surface.CLI_GATEWAY)
output.set_toolbar("router_hud", "observe standard -> fake-terminal 80%")
output.set_toolbar("router_hud_style", "dim")
output.set_toolbar("router_usage", "1/2")
output.set_toolbar("router_session_input", session_input)
output.set_toolbar("router_context_window", context_window)
output.invalidate()
(_, payload) = bridge.sent[0]
assert payload["context"] == "-"
assert payload["io"] == "1/2"
def test_router_plugin_state_reports_pressure_even_before_turn_usage() -> None:
"""Pressure derives from session input vs window and no longer waits on a
turn-usage pair; io stays empty until a turn reports traffic."""
bridge = FakeOpenTuiBridge()
output = OpenTuiOutputHandle(bridge, approval_surface=Surface.CLI_GATEWAY)
output.set_toolbar("router_session_input", 84_000)
output.set_toolbar("router_context_window", 200_000)
output.invalidate()
(_, payload) = bridge.sent[0]
assert payload["context"] == "42%"
assert payload["io"] == ""
def test_router_plugin_state_clamps_context_usage_percent() -> None:
bridge = FakeOpenTuiBridge()
output = OpenTuiOutputHandle(bridge, approval_surface=Surface.CLI_GATEWAY)
output.set_toolbar("router_hud", "observe standard -> fake-terminal 80%")
output.set_toolbar("router_hud_style", "dim")
output.set_toolbar("router_usage", "1.2k/856")
output.set_toolbar("router_session_input", 250_000)
output.set_toolbar("router_context_window", 200_000)
output.invalidate()
(_, payload) = bridge.sent[0]
assert payload["context"] == "100%"
assert payload["io"] == "1.2k/856"
def test_router_plugin_state_fallback_keeps_defaults_and_usage() -> None:
bridge = FakeOpenTuiBridge()
output = OpenTuiOutputHandle(bridge, approval_surface=Surface.CLI_GATEWAY)
output.set_toolbar("router_hud", "fallback -> fake-terminal")
output.set_toolbar("router_hud_style", "warning")
output.set_toolbar("router_source", "fallback")
output.set_toolbar("router_usage", "856/12")
output.invalidate()
(_, payload) = bridge.sent[0]
assert payload["model"] == "fake-terminal"
assert payload["route"] == "fallback"
assert payload["source"] == "fallback"
assert payload["context"] == "-"
assert payload["io"] == "856/12"
assert payload["style"] == "warning"
def test_router_plugin_state_pending_defaults_without_usage() -> None:
bridge = FakeOpenTuiBridge()
output = OpenTuiOutputHandle(bridge, approval_surface=Surface.CLI_GATEWAY)
output.invalidate()
(_, payload) = bridge.sent[0]
assert payload["model"] == "pending"
assert payload["route"] == "pending"
assert payload["context"] == "-"
assert payload["baseline_model"] == ""
assert payload["routing_applied"] is True
assert payload["rollout_phase"] == "full"
@pytest.mark.asyncio
async def test_output_handle_send_message_forwards_to_bridge() -> None:
bridge = FakeOpenTuiBridge()
output = OpenTuiOutputHandle(bridge, approval_surface=Surface.CLI_GATEWAY)
await output.send_message("turn.begin", {"id": "t1"})
await output.send_message("model.text", {"text": "hi"})
assert bridge.sent == [
("turn.begin", {"id": "t1"}),
("model.text", {"text": "hi"}),
]
@pytest.mark.asyncio
async def test_stream_output_preserves_delta_order_while_pruning_tasks() -> None:
bridge = FakeOpenTuiBridge()
output = OpenTuiOutputHandle(bridge, approval_surface=Surface.CLI_GATEWAY)
async with output.stream_output() as write:
for index in range(50):
write(f"delta-{index}")
await asyncio.sleep(0)
texts = [
payload["text"]
for message_type, payload in bridge.sent
if message_type == "scrollback.write"
]
assert texts == [f"delta-{index}" for index in range(50)]
class _FailingBridge(FakeOpenTuiBridge):
async def send(self, message_type: str, payload: object | None = None) -> None:
raise RuntimeError("pipe down")
@pytest.mark.asyncio
async def test_stream_output_surfaces_write_failure_on_next_delta() -> None:
bridge = _FailingBridge()
output = OpenTuiOutputHandle(bridge, approval_surface=Surface.CLI_GATEWAY)
async with output.stream_output() as write:
write("first")
# Let the failed write task complete so the next delta reports it.
await asyncio.sleep(0)
await asyncio.sleep(0)
with pytest.raises(RuntimeError, match="pipe down"):
write("second")
@pytest.mark.asyncio
async def test_stream_output_surfaces_write_failure_at_context_exit() -> None:
bridge = _FailingBridge()
output = OpenTuiOutputHandle(bridge, approval_surface=Surface.CLI_GATEWAY)
with pytest.raises(RuntimeError, match="pipe down"):
async with output.stream_output() as write:
write("only")
+153
View File
@@ -0,0 +1,153 @@
from __future__ import annotations
import re
from pathlib import Path
import pytest
from opensquilla.cli.tui.opentui.themes import (
COLOR_ENV_VAR,
DEFAULT_THEME,
THEME_ENV_VAR,
THEME_NAMES,
handle_theme_command,
)
from opensquilla.cli.ui import console
_THEME_MJS = (
Path(__file__).resolve().parents[4] / "src/opensquilla/cli/tui/opentui/package/src/theme.mjs"
)
_MAIN_MJS = (
Path(__file__).resolve().parents[4] / "src/opensquilla/cli/tui/opentui/package/src/main.mjs"
)
def _js_palette_names() -> list[str]:
text = _THEME_MJS.read_text(encoding="utf-8")
block = text.split("PALETTES = Object.freeze({", 1)[1].split("});", 1)[0]
# Theme entries are at 2-space indent with an object value (`: {`); the inner
# tokens are deeper-indented string values, so they do not match.
return re.findall(r'^ {2}"?([a-z][a-z0-9-]*)"?:\s*\{', block, re.M)
def test_theme_names_match_js_registry() -> None:
js_names = _js_palette_names()
assert js_names, "could not parse PALETTES from theme.mjs"
assert list(THEME_NAMES) == js_names
assert DEFAULT_THEME in THEME_NAMES
def test_theme_env_var_matches_js_host() -> None:
# The JS host reads the variable as a literal; the Python constant only
# stays truthful if both sides name the same variable, so pin the literal
# here the same way the palette names are pinned above.
text = _MAIN_MJS.read_text(encoding="utf-8")
assert f"process.env.{THEME_ENV_VAR}" in text
def test_color_env_var_matches_js_host() -> None:
# Same pinning for the color-mode override: detectColorMode reads the
# variable off the env object it is handed (process.env at import), so the
# literal must appear in theme.mjs for the Python constant to stay truthful.
text = _THEME_MJS.read_text(encoding="utf-8")
assert f"env.{COLOR_ENV_VAR}" in text
@pytest.mark.asyncio
async def test_theme_command_opens_picker_with_no_argument() -> None:
class _FakeOutput:
def __init__(self) -> None:
self.sent: list[tuple[str, dict[str, object]]] = []
async def send_message(self, message_type: str, payload: dict[str, object]) -> None:
self.sent.append((message_type, payload))
output = _FakeOutput()
await handle_theme_command("/theme", output)
assert output.sent == [("theme.pick", {})]
@pytest.mark.asyncio
async def test_theme_command_switches_via_send_message() -> None:
class _FakeOutput:
def __init__(self) -> None:
self.sent: list[tuple[str, dict[str, object]]] = []
async def send_message(self, message_type: str, payload: dict[str, object]) -> None:
self.sent.append((message_type, payload))
output = _FakeOutput()
with console.capture():
await handle_theme_command("/theme midnight", output)
assert output.sent == [("theme.set", {"name": "midnight"})]
@pytest.mark.asyncio
async def test_theme_command_unknown_name_opens_picker() -> None:
class _FakeOutput:
def __init__(self) -> None:
self.sent: list[tuple[str, dict[str, object]]] = []
async def send_message(self, message_type: str, payload: dict[str, object]) -> None:
self.sent.append((message_type, payload))
output = _FakeOutput()
await handle_theme_command("/theme nope", output)
assert output.sent == [("theme.pick", {})]
@pytest.mark.asyncio
async def test_theme_command_explains_opentui_only_on_native() -> None:
# A native output handle has no send_message; the command must not crash and
# should explain that themes are OpenTUI-only.
class _NativeOutput:
async def write_through(self, payload: str) -> None:
return None
with console.capture() as capture:
await handle_theme_command("/theme ember", _NativeOutput())
assert "OpenTUI backend" in capture.get()
@pytest.mark.asyncio
async def test_theme_command_explains_when_wrapped_native_handle_cannot_send() -> None:
# The plugin wrapper ALWAYS exposes a callable send_message that silently
# no-ops on the native backend. callable() alone would misclassify /theme as
# sendable and do nothing; the command must consult supports_send_message and
# fall back to the OpenTUI-only explanation instead.
from opensquilla.cli.tui.adapters.runtime_helpers import TuiPluginOutputHandle
class _NativeOutput:
approval_surface = object()
async def write_through(self, payload: str) -> None:
return None
wrapper = TuiPluginOutputHandle(_NativeOutput(), plugin_manager=object())
assert wrapper.supports_send_message is False
with console.capture() as capture:
await handle_theme_command("/theme ember", wrapper)
assert "OpenTUI backend" in capture.get()
@pytest.mark.asyncio
async def test_theme_command_sends_through_wrapped_ipc_capable_handle() -> None:
from opensquilla.cli.tui.adapters.runtime_helpers import TuiPluginOutputHandle
class _OpenTuiOutput:
approval_surface = object()
def __init__(self) -> None:
self.sent: list[tuple[str, dict[str, object]]] = []
async def write_through(self, payload: str) -> None:
return None
async def send_message(self, message_type: str, payload: dict[str, object]) -> None:
self.sent.append((message_type, payload))
inner = _OpenTuiOutput()
wrapper = TuiPluginOutputHandle(inner, plugin_manager=object())
assert wrapper.supports_send_message is True
await handle_theme_command("/theme", wrapper)
assert inner.sent == [("theme.pick", {})]
+64
View File
@@ -0,0 +1,64 @@
from __future__ import annotations
from collections.abc import AsyncIterator, Callable
from contextlib import asynccontextmanager
from opensquilla.cli.tui.contracts import TuiOutputHandle
from opensquilla.engine.commands import Surface
class _OutputHandle:
approval_surface = Surface.CLI_GATEWAY
async def write_through(self, payload: str) -> None:
return None
def stream_output(self):
@asynccontextmanager
async def _cm() -> AsyncIterator[Callable[[str], None]]:
yield lambda _payload: None
return _cm()
class _SurfaceWithOutput:
def __init__(self, output_handle: TuiOutputHandle) -> None:
self.output_handle = output_handle
class _SurfaceWithoutOutput:
output_handle = object()
def test_tui_output_binding_owns_scope_storage() -> None:
from opensquilla.cli.tui.output_binding import TuiOutputBinding
scope: dict[str, object] = {}
output_handle = _OutputHandle()
binding = TuiOutputBinding(scope)
assert binding.get() is None
binding.expose(output_handle)
assert binding.get() is output_handle
binding.clear()
assert binding.get() is None
assert "tui_output" not in scope
def test_tui_output_binding_exposes_typed_surface_handle_only() -> None:
from opensquilla.cli.tui.output_binding import TuiOutputBinding
scope: dict[str, object] = {}
output_handle = _OutputHandle()
binding = TuiOutputBinding(scope)
binding.expose_from_surface(_SurfaceWithoutOutput())
assert binding.get() is None
binding.expose_from_surface(_SurfaceWithOutput(output_handle))
assert binding.get() is output_handle
+202
View File
@@ -0,0 +1,202 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
import pytest
from opensquilla.cli.chat.turn_stream import (
default_turn_stream_dependencies,
stream_response_gateway,
stream_response_turnrunner,
)
from opensquilla.cli.tui.backend.domain_events import (
KIND_DONE,
KIND_STATUS,
KIND_TEXT_FLUSH,
KIND_TOOL_FINISHED,
KIND_TOOL_STARTED,
TuiDomainEvent,
now_ms,
)
from opensquilla.cli.tui.backend.plugins import (
TuiPluginContext,
TuiPluginManager,
)
from opensquilla.engine.types import DoneEvent, ToolResultEvent, ToolUseStartEvent
from opensquilla.tools.types import CallerKind, ToolContext
@dataclass
class _RecordingPlugin:
plugin_id: str
slots: frozenset[str]
value: object | None = None
seen: list[str] = field(default_factory=list)
fail: bool = False
def on_event(self, event: TuiDomainEvent, context: TuiPluginContext) -> None:
self.seen.append(event.kind)
context.set_state(f"{self.plugin_id}:last", event.kind)
if self.fail:
raise RuntimeError("plugin exploded")
def snapshot(self, slot: str) -> object | None:
if slot not in self.slots:
return None
return self.value
class _GatewayClient:
async def send_message(self, *_args: Any, **_kwargs: Any):
yield {
"event": "session.event.tool_use_start",
"tool_name": "search",
"tool_use_id": "tool-1",
"input": {"query": "opensquilla"},
}
yield {
"event": "session.event.tool_result",
"tool_use_id": "tool-1",
"result": "ok",
"execution_status": {"status": "success"},
}
yield {"event": "session.event.text_delta", "text": "hidden from domain sink"}
yield {"event": "session.event.done", "model": "openrouter/test"}
async def resolve_approval(self, *_args: Any, **_kwargs: Any) -> None:
return None
async def abort_session(self, _key: str) -> None:
return None
class _TurnRunner:
async def run(self, *_args: Any, **_kwargs: Any):
yield ToolUseStartEvent(tool_use_id="tool-1", tool_name="search")
yield ToolResultEvent(
tool_use_id="tool-1",
tool_name="search",
result="ok",
is_error=False,
)
yield DoneEvent(model="openrouter/test")
def test_domain_event_is_frozen_and_timestamped() -> None:
event = TuiDomainEvent(
kind=KIND_STATUS,
source="runtime",
payload={"message": "ready"},
turn_id="turn-1",
timestamp_ms=now_ms(),
)
assert event.kind == "status"
assert event.payload["message"] == "ready"
assert event.timestamp_ms > 0
with pytest.raises(AttributeError):
event.kind = "done" # type: ignore[misc]
def test_plugin_manager_dispatches_in_priority_order_and_snapshots() -> None:
low = _RecordingPlugin("low", frozenset({"status"}), value={"low": True})
high = _RecordingPlugin("high", frozenset({"status"}), value={"high": True})
manager = TuiPluginManager()
manager.register(low, priority=0)
manager.register(high, priority=10)
event = TuiDomainEvent(
kind=KIND_STATUS,
source="runtime",
payload={"message": "working"},
turn_id=None,
timestamp_ms=now_ms(),
)
manager.dispatch(event)
assert [plugin.plugin_id for plugin in manager.plugins] == ["high", "low"]
assert high.seen == [KIND_STATUS]
assert low.seen == [KIND_STATUS]
assert manager.snapshot("status") == {"high": True}
assert manager.context.get_state("high:last") == KIND_STATUS
def test_plugin_manager_captures_plugin_errors_and_continues() -> None:
failing = _RecordingPlugin("failing", frozenset({"status"}), fail=True)
healthy = _RecordingPlugin("healthy", frozenset({"status"}), value="ok")
manager = TuiPluginManager([failing, healthy])
event = TuiDomainEvent(
kind=KIND_STATUS,
source="runtime",
payload={"message": "working"},
turn_id=None,
timestamp_ms=now_ms(),
)
manager.dispatch(event)
assert failing.seen == [KIND_STATUS]
assert healthy.seen == [KIND_STATUS]
assert manager.snapshot("status") == "ok"
assert [(error.plugin_id, error.message) for error in manager.errors] == [
("failing", "plugin exploded")
]
@pytest.mark.asyncio
async def test_gateway_turn_stream_emits_tool_and_done_domain_events() -> None:
events: list[TuiDomainEvent] = []
deps = default_turn_stream_dependencies(tui_event_sink=events.append)
await stream_response_gateway(
_GatewayClient(),
"agent:main:test",
"hello",
{"mode": None},
deps=deps,
)
assert [event.kind for event in events] == [
KIND_TOOL_STARTED,
KIND_TOOL_FINISHED,
KIND_TEXT_FLUSH,
KIND_DONE,
]
assert {event.source for event in events} == {"gateway"}
assert events[0].payload["tool_name"] == "search"
assert events[3].payload["model"] == "openrouter/test"
@pytest.mark.asyncio
async def test_turnrunner_stream_emits_matching_tool_and_done_domain_events(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr("opensquilla.engine.runtime.TurnRunner", _TurnRunner)
events: list[TuiDomainEvent] = []
deps = default_turn_stream_dependencies(
stream_wrapper=lambda stream, _svc: stream,
tui_event_sink=events.append,
)
tool_ctx = ToolContext(
caller_kind=CallerKind.CLI,
channel_kind="cli",
channel_id="cli:chat",
)
await stream_response_turnrunner(
_TurnRunner(),
"agent:main:test",
tool_ctx,
"hello",
deps=deps,
)
assert [event.kind for event in events] == [
KIND_TOOL_STARTED,
KIND_TOOL_FINISHED,
KIND_DONE,
]
assert {event.source for event in events} == {"turn_runner"}
assert events[0].payload["tool_name"] == "search"
assert events[2].payload["model"] == "openrouter/test"
@@ -0,0 +1,87 @@
from __future__ import annotations
import pytest
from opensquilla.cli.tui.renderers.selection import (
DEFAULT_TUI_BACKEND_ID,
OPENSQUILLA_TUI_BACKEND_ENV,
RendererBackendSelectionError,
get_renderer_backend,
renderer_backends,
select_renderer_backend,
select_renderer_backend_from_env,
)
REMOVED_TEXT_BACKEND = "text" + "ual"
REMOVED_BACKEND_IDS = ["terminal", REMOVED_TEXT_BACKEND, f"live-{REMOVED_TEXT_BACKEND}"]
def test_native_backend_is_default_and_opentui_is_preview_backend() -> None:
backend = select_renderer_backend()
assert backend.backend_id == DEFAULT_TUI_BACKEND_ID
assert backend.supports_streaming_fast_path is True
assert backend.supports_structured_ui is True
assert set(renderer_backends()) == {"native", "opentui"}
def test_renderer_backend_lookup_rejects_unknown_ids() -> None:
with pytest.raises(RendererBackendSelectionError) as exc_info:
get_renderer_backend("unknown")
assert "Unsupported TUI backend" in str(exc_info.value)
assert "opentui" in str(exc_info.value)
assert "terminal" not in str(exc_info.value)
assert REMOVED_TEXT_BACKEND not in str(exc_info.value)
def test_backend_selection_reads_env_and_preserves_native_default(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from opensquilla.cli.tui.opentui import bridge as opentui_bridge
from opensquilla.cli.tui.renderers.selection import RendererBackendAvailability
monkeypatch.setattr(
opentui_bridge.OpenTuiRendererBackend,
"is_available",
lambda self: RendererBackendAvailability(available=True),
)
assert select_renderer_backend_from_env({}).backend_id == "native"
assert (
select_renderer_backend_from_env({OPENSQUILLA_TUI_BACKEND_ENV: ""}).backend_id
== "native"
)
assert (
select_renderer_backend_from_env({OPENSQUILLA_TUI_BACKEND_ENV: " opentui "})
.backend_id
== "opentui"
)
def test_backend_selection_rejects_unknown_env_values_clearly() -> None:
with pytest.raises(RendererBackendSelectionError) as exc_info:
select_renderer_backend_from_env({OPENSQUILLA_TUI_BACKEND_ENV: "bogus"})
assert "Unsupported TUI backend" in str(exc_info.value)
assert "opentui" in str(exc_info.value)
assert "bogus" in str(exc_info.value)
@pytest.mark.parametrize("backend_id", REMOVED_BACKEND_IDS)
def test_backend_selection_rejects_removed_backend_ids(backend_id: str) -> None:
with pytest.raises(RendererBackendSelectionError) as exc_info:
select_renderer_backend(backend_id)
message = str(exc_info.value)
assert "Unsupported TUI backend" in message
assert backend_id in message
assert "opentui" in message
def test_opentui_backend_is_registered_without_importing_legacy_backends() -> None:
backend = get_renderer_backend("opentui")
assert backend.backend_id == "opentui"
assert backend.supports_structured_ui is True
assert backend.supports_streaming_fast_path is True
@@ -0,0 +1,291 @@
from __future__ import annotations
from collections.abc import AsyncIterator
from typing import Any
import pytest
from opensquilla.cli.chat.turn_stream import (
default_turn_stream_dependencies,
stream_response_gateway,
stream_response_turnrunner,
)
from opensquilla.cli.tui.adapters.turn_stream_defaults import router_hud_event_sink_factory
from opensquilla.cli.tui.backend.domain_events import (
KIND_DONE,
KIND_ROUTER_DECISION,
KIND_TEXT_FLUSH,
TuiDomainEvent,
now_ms,
)
from opensquilla.cli.tui.backend.plugins import TuiPluginManager
from opensquilla.cli.tui.plugins.router_hud import (
ROUTER_HUD_SLOT,
RouterHudPlugin,
RouterHudSnapshot,
)
from opensquilla.engine.types import DoneEvent, RouterDecisionEvent
from opensquilla.tools.types import CallerKind, ToolContext
ROUTER_PAYLOAD: dict[str, object] = {
"tier": "t2",
"tier_index": 2,
"model": "anthropic/claude-sonnet-4.6",
"baseline_model": "anthropic/claude-opus-4.7",
"source": "router",
"confidence": 0.71,
"probs": [0.1, 0.2, 0.7],
"savings_pct": 64.0,
"fallback": False,
"thinking_mode": "balanced",
"prompt_policy": "default",
"routing_applied": True,
"rollout_phase": "full",
"context_window": 200_000,
}
class _GatewayClient:
def __init__(self, events: list[dict[str, Any]]) -> None:
self._events = events
async def send_message(self, *_args: Any, **_kwargs: Any):
for event in self._events:
yield event
async def resolve_approval(self, *_args: Any, **_kwargs: Any) -> None:
return None
async def abort_session(self, _key: str) -> None:
return None
class _TurnRunner:
def __init__(self, events: list[object]) -> None:
self._events = events
async def run(self, *_args: Any, **_kwargs: Any) -> AsyncIterator[object]:
for event in self._events:
yield event
def _router_event(payload: dict[str, object]) -> TuiDomainEvent:
return TuiDomainEvent(
kind=KIND_ROUTER_DECISION,
source="gateway",
payload=payload,
turn_id="agent:main:test",
timestamp_ms=now_ms(),
)
def _snapshot_for(payload: dict[str, object]) -> RouterHudSnapshot:
plugin = RouterHudPlugin()
plugin.on_event(_router_event(payload), context=object())
snapshot = plugin.snapshot(ROUTER_HUD_SLOT)
assert isinstance(snapshot, RouterHudSnapshot)
return snapshot
@pytest.mark.asyncio
async def test_gateway_stream_surfaces_normalized_router_decision_domain_event() -> None:
events: list[TuiDomainEvent] = []
deps = default_turn_stream_dependencies(tui_event_sink=events.append)
await stream_response_gateway(
_GatewayClient(
[
{"event": "session.event.router_decision", **ROUTER_PAYLOAD},
{"event": "session.event.done", "model": "anthropic/claude-sonnet-4.6"},
]
),
"agent:main:test",
"hello",
deps=deps,
)
assert [event.kind for event in events] == [KIND_ROUTER_DECISION, KIND_DONE]
assert events[0].source == "gateway"
assert events[0].payload == ROUTER_PAYLOAD
@pytest.mark.asyncio
async def test_turnrunner_stream_surfaces_matching_router_decision_domain_event(
monkeypatch: pytest.MonkeyPatch,
) -> None:
router_event = RouterDecisionEvent(**ROUTER_PAYLOAD)
turn_runner = _TurnRunner([router_event, DoneEvent(model="anthropic/claude-sonnet-4.6")])
monkeypatch.setattr("opensquilla.engine.runtime.TurnRunner", _TurnRunner)
events: list[TuiDomainEvent] = []
deps = default_turn_stream_dependencies(
stream_wrapper=lambda stream, _svc: stream,
tui_event_sink=events.append,
)
tool_ctx = ToolContext(
caller_kind=CallerKind.CLI,
channel_kind="cli",
channel_id="cli:chat",
)
await stream_response_turnrunner(
turn_runner,
"agent:main:test",
tool_ctx,
"hello",
deps=deps,
)
assert [event.kind for event in events] == [KIND_ROUTER_DECISION, KIND_DONE]
assert events[0].source == "turn_runner"
assert events[0].payload == ROUTER_PAYLOAD
def test_router_hud_formats_full_route_label_and_snapshot_fields() -> None:
snapshot = _snapshot_for(ROUTER_PAYLOAD)
assert snapshot.tier == "t2"
assert snapshot.tier_index == 2
assert snapshot.model == "anthropic/claude-sonnet-4.6"
assert snapshot.baseline_model == "anthropic/claude-opus-4.7"
assert snapshot.confidence == 0.71
assert snapshot.savings_pct == 64.0
assert snapshot.label == "route t2 -> claude-sonnet-4.6 71% save 64%"
assert snapshot.style == "normal"
def test_router_hud_uses_natural_tier_indexes_and_observe_style() -> None:
payload = {
**ROUTER_PAYLOAD,
"tier": "t1",
"tier_index": None,
"routing_applied": False,
"rollout_phase": "observe",
}
snapshot = _snapshot_for(payload)
assert snapshot.tier_index == 1
assert snapshot.label == "observe t1 -> claude-sonnet-4.6 71%"
assert snapshot.style == "dim"
def test_router_hud_highlights_fallback_routes() -> None:
snapshot = _snapshot_for(
{
**ROUTER_PAYLOAD,
"source": "fallback",
"fallback": True,
"savings_pct": 0.0,
}
)
assert snapshot.fallback is True
assert snapshot.label == "fallback -> claude-sonnet-4.6"
assert snapshot.style == "warning"
def test_router_hud_formats_forced_and_no_baseline_without_savings() -> None:
snapshot = _snapshot_for(
{
**ROUTER_PAYLOAD,
"tier": "t0",
"tier_index": 0,
"source": "forced",
"baseline_model": "",
"savings_pct": 64.0,
}
)
assert snapshot.tier_index == 0
assert snapshot.label == "forced t0 -> claude-sonnet-4.6 71%"
assert "save" not in snapshot.label
def test_router_hud_omits_malformed_confidence() -> None:
snapshot = _snapshot_for(
{
**ROUTER_PAYLOAD,
"confidence": "not-a-number",
"savings_pct": "also-bad",
}
)
assert snapshot.confidence is None
assert snapshot.savings_pct is None
assert snapshot.label == "route t2 -> claude-sonnet-4.6"
def test_opentui_router_sink_updates_toolbar_only_for_router_events() -> None:
class _Output:
def __init__(self) -> None:
self.updates: list[tuple[str, object | None]] = []
self.invalidations = 0
def set_toolbar(self, key: str, value: object | None) -> None:
self.updates.append((key, value))
def invalidate(self) -> None:
self.invalidations += 1
output = _Output()
sink = router_hud_event_sink_factory(output)
sink(_router_event(ROUTER_PAYLOAD))
after_router = list(output.updates)
sink(
TuiDomainEvent(
kind=KIND_TEXT_FLUSH,
source="renderer",
payload={"text": "hello"},
turn_id="agent:main:test",
timestamp_ms=now_ms(),
)
)
assert ("router_hud", "route t2 -> claude-sonnet-4.6 71% save 64%") in after_router
assert ("router_hud_style", "normal") in after_router
assert output.updates == after_router
assert output.invalidations == 1
def test_opentui_router_sink_writes_context_window_from_payload() -> None:
class _Output:
def __init__(self) -> None:
self.updates: list[tuple[str, object | None]] = []
def set_toolbar(self, key: str, value: object | None) -> None:
self.updates.append((key, value))
def invalidate(self) -> None:
return None
output = _Output()
sink = router_hud_event_sink_factory(output)
sink(_router_event({**ROUTER_PAYLOAD, "context_window": 1_000_000}))
assert ("router_context_window", 1_000_000) in output.updates
def test_opentui_router_sink_reuses_launch_scoped_plugin_manager() -> None:
class _Output:
def __init__(self, plugin_manager: TuiPluginManager) -> None:
self.plugin_manager = plugin_manager
self.updates: list[tuple[str, object | None]] = []
def set_toolbar(self, key: str, value: object | None) -> None:
self.updates.append((key, value))
def invalidate(self) -> None:
return None
manager = TuiPluginManager([RouterHudPlugin()])
output = _Output(manager)
sink = router_hud_event_sink_factory(output)
sink(_router_event(ROUTER_PAYLOAD))
snapshot = manager.snapshot(ROUTER_HUD_SLOT)
assert isinstance(snapshot, RouterHudSnapshot)
assert snapshot.label == "route t2 -> claude-sonnet-4.6 71% save 64%"
assert ("router_hud", snapshot.label) in output.updates
+903
View File
@@ -0,0 +1,903 @@
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import asynccontextmanager
from typing import Any
import pytest
from opensquilla.cli.tui.backend.contracts import (
TuiInputKind,
TuiRuntimeConfig,
TuiRuntimeHooks,
)
from opensquilla.cli.tui.backend.runtime import run_tui_runtime
from opensquilla.cli.tui.backend.state import TuiRuntimeState
from opensquilla.engine.agent_injection import PendingInputProvider
class _FakeSurface:
def __init__(
self,
inputs: asyncio.Queue[str | None],
*,
on_next_line: Callable[[], None] | None = None,
) -> None:
self._inputs = inputs
self._on_next_line = on_next_line
self.next_line_calls = 0
self.cancel_callbacks: list[Any] = []
self.shutdown_callbacks: list[Any] = []
self.writes: list[str] = []
self.eof_count = 0
self.redraw_count = 0
async def next_line(self) -> str | None:
self.next_line_calls += 1
if self._on_next_line is not None:
self._on_next_line()
return await self._inputs.get()
def set_cancel_callback(self, cb) -> None: # noqa: ANN001
self.cancel_callbacks.append(cb)
def set_shutdown_callback(self, cb) -> None: # noqa: ANN001
self.shutdown_callbacks.append(cb)
def emit_eof(self) -> None:
self.eof_count += 1
self._inputs.put_nowait(None)
async def write_through(self, payload: str) -> None:
self.writes.append(payload)
@property
def redraw_callback(self) -> Callable[[], None]:
return self._invalidate
def _invalidate(self) -> None:
self.redraw_count += 1
def _surface_factory(surface: _FakeSurface):
@asynccontextmanager
async def _factory() -> AsyncIterator[_FakeSurface]:
yield surface
return _factory
async def _noop_echo(surface: Any, text: str) -> None:
await surface.write_through(f"echo:{text}")
async def _queued_echo(surface: Any) -> None:
await surface.write_through("queued")
async def _wait_until(predicate: Callable[[], bool], *, timeout: float = 2.0) -> None:
loop = asyncio.get_running_loop()
deadline = loop.time() + timeout
while not predicate():
if loop.time() >= deadline:
raise AssertionError("timed out waiting for runtime condition")
await asyncio.sleep(0)
def _runtime_config(**kwargs: Any) -> TuiRuntimeConfig:
return TuiRuntimeConfig(task_name="chat-turn-test", **kwargs)
def _runtime_hooks(**kwargs: Any) -> TuiRuntimeHooks:
return TuiRuntimeHooks(
on_user_input_echo=_noop_echo,
on_queued_turn_start=_queued_echo,
**kwargs,
)
@pytest.mark.asyncio
async def test_runtime_ignores_blank_input_lines() -> None:
"""A blank Enter is never a message: no dispatch, no echo, no queue entry.
Surfaces guard this too, but the backend is the defense shared by every
frontend — a phantom empty prompt card queued behind a running turn was
the visible failure.
"""
inputs: asyncio.Queue[str | None] = asyncio.Queue()
surface = _FakeSurface(inputs)
state = TuiRuntimeState()
executed: list[str] = []
echoed: list[str] = []
async def _dispatch(user_input: str) -> bool:
executed.append(user_input)
return True
async def _echo(_surface: Any, text: str) -> None:
echoed.append(text)
task = asyncio.create_task(
run_tui_runtime(
dispatch=_dispatch,
surface_factory=_surface_factory(surface),
config=_runtime_config(state=state),
hooks=TuiRuntimeHooks(
on_user_input_echo=_echo,
on_queued_turn_start=_queued_echo,
),
)
)
inputs.put_nowait("")
inputs.put_nowait(" ")
inputs.put_nowait("\t")
inputs.put_nowait("real message")
await _wait_until(lambda: executed == ["real message"])
inputs.put_nowait(None)
await task
assert executed == ["real message"]
assert echoed == ["real message"]
assert state.pending_items == ()
def test_runtime_state_drains_pending_inputs_for_agent_injection() -> None:
state = TuiRuntimeState()
state.enqueue("second")
state.enqueue("third")
assert isinstance(state, PendingInputProvider)
assert state.drain_pending() == ["second", "third"]
assert state.pending_items == ()
@pytest.mark.asyncio
async def test_runtime_allows_active_turn_to_drain_mid_turn_input() -> None:
inputs: asyncio.Queue[str | None] = asyncio.Queue()
surface = _FakeSurface(inputs)
state = TuiRuntimeState()
executed: list[str] = []
drained: list[str] = []
first_started = asyncio.Event()
drain_completed = asyncio.Event()
async def _dispatch(user_input: str) -> bool:
executed.append(user_input)
if user_input == "first":
first_started.set()
await _wait_until(lambda: state.pending_items == ("second",))
drained.extend(state.drain_pending())
drain_completed.set()
return True
task = asyncio.create_task(
run_tui_runtime(
dispatch=_dispatch,
surface_factory=_surface_factory(surface),
config=_runtime_config(state=state),
hooks=_runtime_hooks(),
)
)
await inputs.put("first")
await asyncio.wait_for(first_started.wait(), timeout=2.0)
await inputs.put("second")
await asyncio.wait_for(drain_completed.wait(), timeout=2.0)
await inputs.put(None)
await asyncio.wait_for(task, timeout=2.0)
assert drained == ["second"]
assert executed == ["first"]
assert state.pending_items == ()
assert "queued" not in surface.writes
@pytest.mark.asyncio
async def test_runtime_dispatches_single_input_against_fake_surface() -> None:
inputs: asyncio.Queue[str | None] = asyncio.Queue()
surface = _FakeSurface(inputs)
executed: list[str] = []
async def _dispatch(user_input: str) -> bool:
executed.append(user_input)
return True
task = asyncio.create_task(
run_tui_runtime(
dispatch=_dispatch,
surface_factory=_surface_factory(surface),
config=_runtime_config(),
hooks=_runtime_hooks(),
)
)
await inputs.put("hello")
await inputs.put(None)
await asyncio.wait_for(task, timeout=2.0)
assert executed == ["hello"]
assert surface.writes == ["echo:hello"]
assert surface.cancel_callbacks[-1] is None
assert surface.shutdown_callbacks[-1] is None
@pytest.mark.asyncio
async def test_runtime_queues_pending_input_and_promotes_fifo() -> None:
inputs: asyncio.Queue[str | None] = asyncio.Queue()
surface = _FakeSurface(inputs)
executed: list[str] = []
first_started = asyncio.Event()
finish_first = asyncio.Event()
async def _dispatch(user_input: str) -> bool:
executed.append(user_input)
if user_input == "first":
first_started.set()
await finish_first.wait()
return True
task = asyncio.create_task(
run_tui_runtime(
dispatch=_dispatch,
surface_factory=_surface_factory(surface),
config=_runtime_config(),
hooks=_runtime_hooks(),
)
)
await inputs.put("first")
await asyncio.wait_for(first_started.wait(), timeout=2.0)
await inputs.put("second")
await inputs.put("third")
for _ in range(20):
await asyncio.sleep(0)
finish_first.set()
await inputs.put(None)
await asyncio.wait_for(task, timeout=2.0)
assert executed == ["first", "second", "third"]
assert surface.writes.count("queued") == 2
@pytest.mark.asyncio
async def test_runtime_can_defer_next_input_until_turn_finishes() -> None:
inputs: asyncio.Queue[str | None] = asyncio.Queue()
surface = _FakeSurface(inputs)
executed: list[str] = []
first_started = asyncio.Event()
finish_first = asyncio.Event()
async def _dispatch(user_input: str) -> bool:
executed.append(user_input)
first_started.set()
await finish_first.wait()
return True
task = asyncio.create_task(
run_tui_runtime(
dispatch=_dispatch,
surface_factory=_surface_factory(surface),
config=_runtime_config(concurrent_input_during_turn=False),
hooks=_runtime_hooks(),
)
)
await inputs.put("first")
await asyncio.wait_for(first_started.wait(), timeout=2.0)
for _ in range(20):
await asyncio.sleep(0)
assert surface.next_line_calls == 1
finish_first.set()
await inputs.put(None)
await asyncio.wait_for(task, timeout=2.0)
assert executed == ["first"]
assert surface.next_line_calls == 2
@pytest.mark.asyncio
async def test_runtime_can_keep_bottom_input_live_during_turn() -> None:
inputs: asyncio.Queue[str | None] = asyncio.Queue()
second_input_started = asyncio.Event()
surface = _FakeSurface(
inputs,
on_next_line=lambda: second_input_started.set() if surface.next_line_calls >= 2 else None,
)
first_started = asyncio.Event()
finish_first = asyncio.Event()
executed: list[str] = []
async def _dispatch(user_input: str) -> bool:
executed.append(user_input)
first_started.set()
await finish_first.wait()
return True
task = asyncio.create_task(
run_tui_runtime(
dispatch=_dispatch,
surface_factory=_surface_factory(surface),
config=_runtime_config(concurrent_input_during_turn=True),
hooks=_runtime_hooks(),
)
)
await inputs.put("first")
await asyncio.wait_for(first_started.wait(), timeout=2.0)
await asyncio.wait_for(second_input_started.wait(), timeout=2.0)
assert surface.next_line_calls >= 2
await inputs.put(None)
finish_first.set()
await asyncio.wait_for(task, timeout=2.0)
assert executed == ["first"]
@pytest.mark.asyncio
async def test_runtime_starts_next_input_before_dispatch_when_concurrent() -> None:
inputs: asyncio.Queue[str | None] = asyncio.Queue()
events: list[str] = []
def _record_next_line() -> None:
events.append(f"input-{surface.next_line_calls}")
surface = _FakeSurface(inputs, on_next_line=_record_next_line)
dispatch_started = asyncio.Event()
finish_dispatch = asyncio.Event()
async def _dispatch(user_input: str) -> bool:
events.append(f"dispatch-{user_input}")
dispatch_started.set()
await finish_dispatch.wait()
return True
task = asyncio.create_task(
run_tui_runtime(
dispatch=_dispatch,
surface_factory=_surface_factory(surface),
config=_runtime_config(concurrent_input_during_turn=True),
hooks=_runtime_hooks(),
)
)
await inputs.put("first")
await asyncio.wait_for(dispatch_started.wait(), timeout=2.0)
assert events.index("input-2") < events.index("dispatch-first")
await inputs.put(None)
finish_dispatch.set()
await asyncio.wait_for(task, timeout=2.0)
@pytest.mark.asyncio
async def test_runtime_destructive_slash_cancels_active_turn_and_purges_queue() -> None:
inputs: asyncio.Queue[str | None] = asyncio.Queue()
surface = _FakeSurface(inputs)
executed: list[str] = []
first_started = asyncio.Event()
first_cancelled = asyncio.Event()
clear_completed = asyncio.Event()
cancel_calls: list[str] = []
clear_cancel_snapshots: list[list[str]] = []
async def _dispatch(user_input: str) -> bool:
if user_input == "first":
executed.append("first-start")
first_started.set()
try:
await asyncio.sleep(5)
except asyncio.CancelledError:
first_cancelled.set()
raise
executed.append("first-end")
return True
executed.append(user_input)
if user_input == "/clear":
clear_cancel_snapshots.append(list(cancel_calls))
clear_completed.set()
return True
async def _cancel_active_turn() -> None:
await asyncio.sleep(0)
cancel_calls.append("cancel")
task = asyncio.create_task(
run_tui_runtime(
dispatch=_dispatch,
surface_factory=_surface_factory(surface),
config=_runtime_config(
classify_input=lambda text: (
TuiInputKind.DESTRUCTIVE if text == "/clear" else TuiInputKind.NORMAL
)
),
hooks=_runtime_hooks(on_cancel_active_turn=_cancel_active_turn),
)
)
await inputs.put("first")
await asyncio.wait_for(first_started.wait(), timeout=2.0)
await inputs.put("second")
await inputs.put("third")
for _ in range(20):
await asyncio.sleep(0)
await inputs.put("/clear")
await asyncio.wait_for(first_cancelled.wait(), timeout=2.0)
await asyncio.wait_for(clear_completed.wait(), timeout=2.0)
await inputs.put(None)
await asyncio.wait_for(task, timeout=2.0)
assert executed == ["first-start", "/clear"]
assert cancel_calls == ["cancel"]
assert clear_cancel_snapshots == [["cancel"]]
@pytest.mark.asyncio
async def test_runtime_exit_drains_pending_work_before_dispatching_exit() -> None:
inputs: asyncio.Queue[str | None] = asyncio.Queue()
surface = _FakeSurface(inputs)
executed: list[str] = []
first_started = asyncio.Event()
finish_first = asyncio.Event()
async def _dispatch(user_input: str) -> bool:
if user_input == "first":
executed.append("first-start")
first_started.set()
await finish_first.wait()
executed.append("first-end")
return True
executed.append(user_input)
return user_input != "/exit"
task = asyncio.create_task(
run_tui_runtime(
dispatch=_dispatch,
surface_factory=_surface_factory(surface),
config=_runtime_config(
classify_input=lambda text: (
TuiInputKind.EXIT if text == "/exit" else TuiInputKind.NORMAL
)
),
hooks=_runtime_hooks(),
)
)
await inputs.put("first")
await asyncio.wait_for(first_started.wait(), timeout=2.0)
await inputs.put("second")
await inputs.put("third")
for _ in range(20):
await asyncio.sleep(0)
await inputs.put("/exit")
for _ in range(20):
await asyncio.sleep(0)
finish_first.set()
await asyncio.wait_for(task, timeout=2.0)
assert executed == ["first-start", "first-end", "second", "third", "/exit"]
@pytest.mark.asyncio
async def test_runtime_cancel_invokes_adapter_cancel_hook() -> None:
inputs: asyncio.Queue[str | None] = asyncio.Queue()
surface = _FakeSurface(inputs)
dispatch_started = asyncio.Event()
dispatch_cancelled = asyncio.Event()
cancel_calls: list[str] = []
async def _dispatch(user_input: str) -> bool:
dispatch_started.set()
try:
await asyncio.sleep(5)
except asyncio.CancelledError:
dispatch_cancelled.set()
raise
return True
async def _cancel_active_turn() -> None:
cancel_calls.append("cancel")
task = asyncio.create_task(
run_tui_runtime(
dispatch=_dispatch,
surface_factory=_surface_factory(surface),
config=_runtime_config(),
hooks=_runtime_hooks(on_cancel_active_turn=_cancel_active_turn),
)
)
await inputs.put("hello")
await asyncio.wait_for(dispatch_started.wait(), timeout=2.0)
active_cb = next(cb for cb in reversed(surface.cancel_callbacks) if cb is not None)
active_cb()
await inputs.put(None)
await asyncio.wait_for(task, timeout=2.0)
assert cancel_calls == ["cancel"]
assert dispatch_cancelled.is_set() is True
@pytest.mark.asyncio
async def test_runtime_cancel_drops_pending_inputs_from_active_turn() -> None:
inputs: asyncio.Queue[str | None] = asyncio.Queue()
surface = _FakeSurface(inputs)
state = TuiRuntimeState()
executed: list[str] = []
first_started = asyncio.Event()
async def _dispatch(user_input: str) -> bool:
if user_input == "first":
executed.append("first-start")
first_started.set()
try:
await asyncio.sleep(5)
except asyncio.CancelledError:
executed.append("first-cancelled")
raise
executed.append(user_input)
return True
task = asyncio.create_task(
run_tui_runtime(
dispatch=_dispatch,
surface_factory=_surface_factory(surface),
config=_runtime_config(state=state),
hooks=_runtime_hooks(),
)
)
await inputs.put("first")
await asyncio.wait_for(first_started.wait(), timeout=2.0)
await inputs.put("second")
await inputs.put("third")
await _wait_until(lambda: state.pending_items == ("second", "third"))
active_cb = next(cb for cb in reversed(surface.cancel_callbacks) if cb is not None)
active_cb()
await inputs.put(None)
await asyncio.wait_for(task, timeout=2.0)
assert executed == ["first-start", "first-cancelled"]
assert state.pending_items == ()
assert "queued" not in surface.writes
@pytest.mark.asyncio
async def test_runtime_cancel_notifies_when_dropping_queued_inputs() -> None:
inputs: asyncio.Queue[str | None] = asyncio.Queue()
surface = _FakeSurface(inputs)
state = TuiRuntimeState()
notices: list[str] = []
first_started = asyncio.Event()
async def _dispatch(user_input: str) -> bool:
if user_input == "first":
first_started.set()
await asyncio.sleep(5)
return True
task = asyncio.create_task(
run_tui_runtime(
dispatch=_dispatch,
surface_factory=_surface_factory(surface),
config=_runtime_config(state=state),
hooks=_runtime_hooks(notice=notices.append),
)
)
await inputs.put("first")
await asyncio.wait_for(first_started.wait(), timeout=2.0)
await inputs.put("second")
await inputs.put("third")
await _wait_until(lambda: state.pending_items == ("second", "third"))
active_cb = next(cb for cb in reversed(surface.cancel_callbacks) if cb is not None)
active_cb()
await inputs.put(None)
await asyncio.wait_for(task, timeout=2.0)
# The two typed-ahead messages were dropped — the user is told, not left guessing.
assert any("Discarded 2 queued messages" in notice for notice in notices)
@pytest.mark.asyncio
async def test_runtime_cancel_without_queue_emits_no_discard_notice() -> None:
inputs: asyncio.Queue[str | None] = asyncio.Queue()
surface = _FakeSurface(inputs)
state = TuiRuntimeState()
notices: list[str] = []
first_started = asyncio.Event()
async def _dispatch(user_input: str) -> bool:
if user_input == "first":
first_started.set()
await asyncio.sleep(5)
return True
task = asyncio.create_task(
run_tui_runtime(
dispatch=_dispatch,
surface_factory=_surface_factory(surface),
config=_runtime_config(state=state),
hooks=_runtime_hooks(notice=notices.append),
)
)
await inputs.put("first")
await asyncio.wait_for(first_started.wait(), timeout=2.0)
active_cb = next(cb for cb in reversed(surface.cancel_callbacks) if cb is not None)
active_cb() # cancel with an empty queue
await inputs.put(None)
await asyncio.wait_for(task, timeout=2.0)
assert not any("Discarded" in notice for notice in notices)
@pytest.mark.asyncio
async def test_runtime_notifies_when_input_is_queued_mid_turn() -> None:
inputs: asyncio.Queue[str | None] = asyncio.Queue()
surface = _FakeSurface(inputs)
state = TuiRuntimeState()
notices: list[str] = []
first_started = asyncio.Event()
async def _dispatch(user_input: str) -> bool:
if user_input == "first":
first_started.set()
await asyncio.sleep(5)
return True
task = asyncio.create_task(
run_tui_runtime(
dispatch=_dispatch,
surface_factory=_surface_factory(surface),
config=_runtime_config(state=state),
hooks=_runtime_hooks(notice=notices.append),
)
)
await inputs.put("first")
await asyncio.wait_for(first_started.wait(), timeout=2.0)
await inputs.put("second")
await _wait_until(lambda: state.pending_items == ("second",))
active_cb = next(cb for cb in reversed(surface.cancel_callbacks) if cb is not None)
active_cb()
await inputs.put(None)
await asyncio.wait_for(task, timeout=2.0)
# Typing mid-turn must surface a clear "queued" signal — the message was not
# silently dropped, nor did it start a turn.
assert any("Queued (#1) behind the running turn" in notice for notice in notices)
@pytest.mark.asyncio
async def test_runtime_full_queue_rejects_message_without_echoing_it() -> None:
inputs: asyncio.Queue[str | None] = asyncio.Queue()
surface = _FakeSurface(inputs)
state = TuiRuntimeState()
echoed: list[str] = []
notices: list[str] = []
first_started = asyncio.Event()
async def _record_echo(_surface: Any, text: str) -> None:
echoed.append(text)
async def _dispatch(user_input: str) -> bool:
if user_input == "first":
first_started.set()
await asyncio.sleep(5)
return True
task = asyncio.create_task(
run_tui_runtime(
dispatch=_dispatch,
surface_factory=_surface_factory(surface),
config=_runtime_config(state=state, queue_max_size=1),
hooks=TuiRuntimeHooks(
on_user_input_echo=_record_echo,
on_queued_turn_start=_queued_echo,
notice=notices.append,
),
)
)
await inputs.put("first")
await asyncio.wait_for(first_started.wait(), timeout=2.0)
await inputs.put("second") # fills the single queue slot
await _wait_until(lambda: state.pending_items == ("second",))
await inputs.put("third") # queue is full -> must be rejected
await _wait_until(lambda: any("Queue full" in notice for notice in notices))
active_cb = next(cb for cb in reversed(surface.cancel_callbacks) if cb is not None)
active_cb()
await inputs.put(None)
await asyncio.wait_for(task, timeout=2.0)
# The rejected message must NOT appear accepted in the transcript; the one that
# fit was echoed normally.
assert "second" in echoed
assert "third" not in echoed
assert state.pending_items == ()
@pytest.mark.asyncio
async def test_runtime_cancel_suppresses_adapter_cancel_hook_errors() -> None:
inputs: asyncio.Queue[str | None] = asyncio.Queue()
surface = _FakeSurface(inputs)
dispatch_started = asyncio.Event()
dispatch_cancelled = asyncio.Event()
async def _dispatch(user_input: str) -> bool:
dispatch_started.set()
try:
await asyncio.sleep(5)
except asyncio.CancelledError:
dispatch_cancelled.set()
raise
return True
def _cancel_active_turn() -> Awaitable[None]:
raise RuntimeError("abort hook failed")
task = asyncio.create_task(
run_tui_runtime(
dispatch=_dispatch,
surface_factory=_surface_factory(surface),
config=_runtime_config(),
hooks=_runtime_hooks(on_cancel_active_turn=_cancel_active_turn),
)
)
await inputs.put("hello")
await asyncio.wait_for(dispatch_started.wait(), timeout=2.0)
active_cb = next(cb for cb in reversed(surface.cancel_callbacks) if cb is not None)
active_cb()
await inputs.put(None)
await asyncio.wait_for(task, timeout=2.0)
assert dispatch_cancelled.is_set() is True
@pytest.mark.asyncio
async def test_runtime_installs_surface_redraw_callback_for_resize() -> None:
inputs: asyncio.Queue[str | None] = asyncio.Queue()
surface = _FakeSurface(inputs)
installed = asyncio.Event()
captured_resize_callbacks: list[Callable[[], None]] = []
def _install_signal_handlers(
*,
loop: asyncio.AbstractEventLoop,
on_resize: Callable[[], None],
is_turn_in_flight: Callable[[], bool],
) -> Callable[[], None]:
del loop, is_turn_in_flight
captured_resize_callbacks.append(on_resize)
installed.set()
return lambda: None
async def _dispatch(user_input: str) -> bool:
raise AssertionError(f"dispatch should not run: {user_input}")
task = asyncio.create_task(
run_tui_runtime(
dispatch=_dispatch,
surface_factory=_surface_factory(surface),
config=_runtime_config(install_signal_handlers=_install_signal_handlers),
)
)
await asyncio.wait_for(installed.wait(), timeout=2.0)
captured_resize_callbacks[0]()
await inputs.put(None)
await asyncio.wait_for(task, timeout=2.0)
assert surface.redraw_count == 1
class _RaisingSurface:
"""Surface whose ``next_line`` fails, modelling an OpenTUI host read error."""
def __init__(self, error: Exception) -> None:
self._error = error
self.cancel_callbacks: list[Any] = []
self.shutdown_callbacks: list[Any] = []
async def next_line(self) -> str | None:
raise self._error
def set_cancel_callback(self, cb) -> None: # noqa: ANN001
self.cancel_callbacks.append(cb)
def set_shutdown_callback(self, cb) -> None: # noqa: ANN001
self.shutdown_callbacks.append(cb)
def emit_eof(self) -> None:
return None
async def write_through(self, payload: str) -> None:
return None
@property
def redraw_callback(self) -> Callable[[], None]:
return lambda: None
@pytest.mark.asyncio
async def test_runtime_degrades_gracefully_on_surface_read_error() -> None:
surface = _RaisingSurface(RuntimeError("OpenTUI host error: [boom]"))
notices: list[str] = []
async def _dispatch(_user_input: str) -> bool:
raise AssertionError("dispatch should not run when input never arrives")
result = await run_tui_runtime(
dispatch=_dispatch,
surface_factory=_surface_factory(surface),
config=_runtime_config(),
hooks=_runtime_hooks(notice=notices.append),
)
assert isinstance(result, TuiRuntimeState)
assert any("Input surface error" in notice for notice in notices)
# the dynamic error text is markup-escaped so the notice itself cannot crash.
assert any("\\[boom]" in notice for notice in notices)
@pytest.mark.asyncio
async def test_runtime_runs_local_command_inline_without_echo_or_queue() -> None:
# A LOCAL command (e.g. /theme) typed while a turn is streaming must run
# immediately, never echoed as a prompt and never queued behind the turn.
inputs: asyncio.Queue[str | None] = asyncio.Queue()
surface = _FakeSurface(inputs)
dispatched: list[str] = []
echoed: list[str] = []
queued_starts = 0
turn_running = asyncio.Event()
release_turn = asyncio.Event()
async def _dispatch(user_input: str) -> bool:
dispatched.append(user_input)
if user_input == "hi":
turn_running.set()
await release_turn.wait() # hold the turn in flight
return True
async def _echo(_surface: Any, text: str) -> None:
echoed.append(text)
async def _queued_start(_surface: Any) -> None:
nonlocal queued_starts
queued_starts += 1
def _classify(text: str) -> TuiInputKind:
return TuiInputKind.LOCAL if text.startswith("/theme") else TuiInputKind.NORMAL
task = asyncio.ensure_future(
run_tui_runtime(
dispatch=_dispatch,
surface_factory=_surface_factory(surface),
config=_runtime_config(concurrent_input_during_turn=True, classify_input=_classify),
hooks=TuiRuntimeHooks(on_user_input_echo=_echo, on_queued_turn_start=_queued_start),
)
)
await inputs.put("hi")
await turn_running.wait()
await inputs.put("/theme") # LOCAL command while "hi" streams
await _wait_until(lambda: "/theme" in dispatched)
# dispatched immediately, NOT echoed as a prompt, NOT queued, no queued marker
assert echoed == ["hi"] # only the chat message was echoed
assert queued_starts == 0
assert surface.next_line_calls # input was being read concurrently
release_turn.set()
await inputs.put(None)
await asyncio.wait_for(task, timeout=2.0)
assert dispatched == ["hi", "/theme"]
+448
View File
@@ -0,0 +1,448 @@
"""Runtime adapter behavior: native terminal output/interrupts, standalone
dispatch model bookkeeping and error recovery, and legacy compat renderer
selection."""
from __future__ import annotations
import asyncio
import os
import signal
import sys
from types import SimpleNamespace
from typing import Any, cast
import pytest
from rich.console import Console
from opensquilla.cli.chat.turn import TurnResult, UsageSummary
from opensquilla.cli.tui.adapters import native_bridge, slash_standalone
from opensquilla.cli.tui.backend.contracts import TuiOutputHandle
from opensquilla.engine.commands import Surface
class _FakeSessionManager:
async def get_or_create(self, session_key: str, agent_id: str = "main") -> object:
return SimpleNamespace(session_key=session_key, agent_id=agent_id)
class _FakeServices:
def __init__(self) -> None:
self.config = None
self.session_manager = _FakeSessionManager()
async def close(self) -> None:
return None
class _RecordingConsole:
def __init__(self) -> None:
self.payloads: list[Any] = []
def print(self, *args: Any, **kwargs: Any) -> None:
self.payloads.extend(args)
def _standalone_deps(
*,
stream_response: Any,
run_concurrent_repl: Any,
output_console: Any,
error_panel_factory: Any,
) -> Any:
from opensquilla.cli.tui import standalone_runtime
return standalone_runtime.StandaloneRuntimeDependencies(
stream_response=stream_response,
image_command_handler=stream_response,
run_concurrent_repl=run_concurrent_repl,
slash_services_factory=lambda _svc: slash_standalone.StandaloneSlashServices(),
sync_slash_adapter_io=lambda: None,
get_tui_output=lambda _scope: None,
output_console=output_console,
error_panel_factory=error_panel_factory,
)
def _patch_standalone_services(monkeypatch: pytest.MonkeyPatch) -> _FakeServices:
services = _FakeServices()
async def fake_build_services() -> _FakeServices:
return services
monkeypatch.setattr("opensquilla.gateway.build_services", fake_build_services)
monkeypatch.setattr(
"opensquilla.gateway.build_turn_runner_from_services",
lambda _services: object(),
)
return services
# --- Native terminal output ---------------------------------------------------
@pytest.mark.asyncio
async def test_native_write_through_streams_midline_chunks_without_rewrapping(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# Streamed flushes land mid-line; Rich must not word-wrap each chunk as if
# the cursor were at column 0, or wrap points misalign and break paragraphs.
narrow = Console(width=20)
monkeypatch.setattr(native_bridge, "console", narrow)
handle = native_bridge.NativeTerminalOutputHandle(
approval_surface=Surface.CLI_STANDALONE,
)
chunks = ["Hello ", "world ", "this is a much longer sentence streamed in chunks"]
with narrow.capture() as capture:
for chunk in chunks:
await handle.write_through(chunk)
assert capture.get() == "".join(chunks)
# --- Native interrupt fallback -------------------------------------------------
@pytest.mark.asyncio
async def test_interrupt_fallback_owns_process_sigint_and_restores(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# Where loop signal handlers are unavailable (e.g. Windows), the surface
# must own the process-level SIGINT handler for the session so Ctrl+C
# cancels the turn instead of unwinding asyncio.run, then restore it.
surface = native_bridge.NativeTerminalSurface(approval_surface=Surface.CLI_STANDALONE)
loop = asyncio.get_running_loop()
def _unsupported(*_args: Any, **_kwargs: Any) -> None:
raise NotImplementedError
monkeypatch.setattr(loop, "add_signal_handler", _unsupported)
cancelled: list[bool] = []
shutdown: list[bool] = []
surface.set_cancel_callback(lambda: cancelled.append(True))
surface.set_shutdown_callback(lambda: shutdown.append(True))
before = signal.getsignal(signal.SIGINT)
try:
surface.install_interrupt_handler()
installed = signal.getsignal(signal.SIGINT)
assert installed is not before
assert callable(installed)
installed(signal.SIGINT, None) # simulate signal delivery
await asyncio.sleep(0.01)
assert cancelled == [True]
assert shutdown == []
finally:
surface.remove_interrupt_handler()
assert signal.getsignal(signal.SIGINT) is before
@pytest.mark.asyncio
@pytest.mark.skipif(
sys.platform.startswith("win"),
reason="os.kill-based SIGINT delivery is POSIX-only in tests",
)
async def test_interrupt_fallback_handles_real_sigint_without_exiting(
monkeypatch: pytest.MonkeyPatch,
) -> None:
surface = native_bridge.NativeTerminalSurface(approval_surface=Surface.CLI_STANDALONE)
loop = asyncio.get_running_loop()
def _unsupported(*_args: Any, **_kwargs: Any) -> None:
raise NotImplementedError
monkeypatch.setattr(loop, "add_signal_handler", _unsupported)
cancelled: list[bool] = []
shutdown: list[bool] = []
surface.set_cancel_callback(lambda: cancelled.append(True))
surface.set_shutdown_callback(lambda: shutdown.append(True))
before = signal.getsignal(signal.SIGINT)
try:
surface.install_interrupt_handler()
os.kill(os.getpid(), signal.SIGINT)
await asyncio.sleep(0.05)
assert cancelled == [True]
assert shutdown == []
finally:
surface.remove_interrupt_handler()
assert signal.getsignal(signal.SIGINT) is before
# --- Standalone dispatch model bookkeeping --------------------------------------
@pytest.mark.asyncio
async def test_standalone_dispatch_never_feeds_routed_model_back(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# Without --model every turn must dispatch with model=None so per-turn
# routing stays live; the routed result is display-only state.
from opensquilla.cli.tui import standalone_runtime
_patch_standalone_services(monkeypatch)
models_seen: list[str | None] = []
captured: dict[str, Any] = {}
async def fake_stream_response(
turn_runner: object,
session_key: str,
tool_ctx: object,
message: str,
model: str | None = None,
svc: object = None,
timeout: float | None = None,
*,
tui_output: TuiOutputHandle | None = None,
pending_input_provider: object | None = None,
) -> TurnResult:
models_seen.append(model)
return TurnResult(
text="reply",
usage=UsageSummary(input_tokens=1, output_tokens=1),
model_after="router/turn-pick",
)
async def fake_run_concurrent_repl(
*,
surface: Surface,
scope: standalone_runtime.StandaloneRuntimeScope,
dispatch: Any,
) -> None:
assert await dispatch("first") is True
assert await dispatch("second") is True
captured["scope_model"] = scope["model"]
deps = _standalone_deps(
stream_response=fake_stream_response,
run_concurrent_repl=fake_run_concurrent_repl,
output_console=_RecordingConsole(),
error_panel_factory=lambda message: message,
)
await standalone_runtime.run_standalone_chat(
model=None,
session_id="agent:main:standalone:test",
deps=deps,
)
assert models_seen == [None, None]
# The routed model is still mirrored to display state for the HUD.
assert captured["scope_model"] == "router/turn-pick"
@pytest.mark.asyncio
async def test_standalone_requested_model_follows_model_command_not_routing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from opensquilla.cli.tui import standalone_runtime
_patch_standalone_services(monkeypatch)
models_seen: list[str | None] = []
async def fake_stream_response(
turn_runner: object,
session_key: str,
tool_ctx: object,
message: str,
model: str | None = None,
svc: object = None,
timeout: float | None = None,
*,
tui_output: TuiOutputHandle | None = None,
pending_input_provider: object | None = None,
) -> TurnResult:
models_seen.append(model)
return TurnResult(
text="reply",
usage=UsageSummary(input_tokens=1, output_tokens=1),
model_after="router/other",
)
async def fake_run_concurrent_repl(
*,
surface: Surface,
scope: standalone_runtime.StandaloneRuntimeScope,
dispatch: Any,
) -> None:
assert await dispatch("first") is True
assert await dispatch("second") is True
assert await dispatch("/model user/second") is True
assert await dispatch("third") is True
deps = _standalone_deps(
stream_response=fake_stream_response,
run_concurrent_repl=fake_run_concurrent_repl,
output_console=_RecordingConsole(),
error_panel_factory=lambda message: message,
)
await standalone_runtime.run_standalone_chat(
model="user/requested",
session_id="agent:main:standalone:test",
deps=deps,
)
# --model survives routed turns; only /model changes the dispatch model.
assert models_seen == ["user/requested", "user/requested", "user/second"]
# --- Standalone dispatch error recovery ------------------------------------------
@pytest.mark.asyncio
async def test_standalone_dispatch_maps_stream_errors_to_error_panel(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from opensquilla.cli.tui import standalone_runtime
_patch_standalone_services(monkeypatch)
output_console = _RecordingConsole()
messages: list[str] = []
async def flaky_stream_response(
turn_runner: object,
session_key: str,
tool_ctx: object,
message: str,
model: str | None = None,
svc: object = None,
timeout: float | None = None,
*,
tui_output: TuiOutputHandle | None = None,
pending_input_provider: object | None = None,
) -> TurnResult:
messages.append(message)
if len(messages) == 1:
raise RuntimeError("provider exploded")
return TurnResult(
text="reply",
usage=UsageSummary(input_tokens=1, output_tokens=1),
model_after=None,
)
async def fake_run_concurrent_repl(
*,
surface: Surface,
scope: standalone_runtime.StandaloneRuntimeScope,
dispatch: Any,
) -> None:
# The failing turn is reported and the loop keeps accepting input.
assert await dispatch("boom") is True
assert await dispatch("recover") is True
deps = _standalone_deps(
stream_response=flaky_stream_response,
run_concurrent_repl=fake_run_concurrent_repl,
output_console=output_console,
error_panel_factory=lambda message: f"<panel {message}>",
)
await standalone_runtime.run_standalone_chat(
model=None,
session_id="agent:main:standalone:test",
deps=deps,
)
assert messages == ["boom", "recover"]
assert "<panel RuntimeError: provider exploded>" in output_console.payloads
@pytest.mark.asyncio
async def test_standalone_dispatch_maps_slash_errors_to_error_panel(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from opensquilla.cli.tui import standalone_runtime
_patch_standalone_services(monkeypatch)
output_console = _RecordingConsole()
async def broken_slash_handler(
cmd: str,
context: slash_standalone.StandaloneSlashContext,
) -> bool:
raise OSError("db locked")
monkeypatch.setattr(
slash_standalone,
"handle_standalone_slash_command",
broken_slash_handler,
)
async def fake_stream_response(
turn_runner: object,
session_key: str,
tool_ctx: object,
message: str,
model: str | None = None,
svc: object = None,
timeout: float | None = None,
*,
tui_output: TuiOutputHandle | None = None,
pending_input_provider: object | None = None,
) -> TurnResult:
return TurnResult(
text="reply",
usage=UsageSummary(input_tokens=1, output_tokens=1),
model_after=None,
)
async def fake_run_concurrent_repl(
*,
surface: Surface,
scope: standalone_runtime.StandaloneRuntimeScope,
dispatch: Any,
) -> None:
assert await dispatch("/new") is True
assert await dispatch("still alive") is True
deps = _standalone_deps(
stream_response=fake_stream_response,
run_concurrent_repl=fake_run_concurrent_repl,
output_console=output_console,
error_panel_factory=lambda message: f"<panel {message}>",
)
await standalone_runtime.run_standalone_chat(
model=None,
session_id="agent:main:standalone:test",
deps=deps,
)
assert "<panel OSError: db locked>" in output_console.payloads
# --- Legacy compat renderer selection --------------------------------------------
def test_chat_compat_default_deps_follow_selected_backend(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from opensquilla.cli.tui.adapters import chat_compat, runtime_bridge
from opensquilla.cli.tui.native.renderer import NativeStreamRenderer
from opensquilla.cli.tui.opentui.renderer import OpenTuiStreamRenderer
monkeypatch.setattr(
runtime_bridge,
"validate_tui_backend_selection",
lambda env=None: "native",
)
deps = chat_compat.default_turn_stream_dependencies()
assert deps.renderer_factory is NativeStreamRenderer
assert deps.stream_wrapper is cast(Any, chat_compat.wrap_cli_turn_stream)
monkeypatch.setattr(
runtime_bridge,
"validate_tui_backend_selection",
lambda env=None: "opentui",
)
deps = chat_compat.default_turn_stream_dependencies()
assert deps.renderer_factory is OpenTuiStreamRenderer
def test_chat_compat_no_longer_exports_tool_compress_wrapper() -> None:
from opensquilla.cli.tui.adapters import chat_compat
assert not hasattr(chat_compat, "handle_tool_compress_command")
+737
View File
@@ -0,0 +1,737 @@
"""Behavioral coverage for the TUI slash adapters and shared helpers."""
from __future__ import annotations
from collections.abc import AsyncIterator
from pathlib import Path
from types import SimpleNamespace
from typing import Any
import pytest
from opensquilla.cli.chat.session_state import ChatSessionState
from opensquilla.cli.chat.turn import TurnResult
from opensquilla.cli.tui.adapters import slash_bridge as _slash_bridge
from opensquilla.cli.tui.adapters import slash_gateway as _slash_gateway
from opensquilla.cli.tui.adapters import slash_standalone as _slash_standalone
from opensquilla.cli.tui.adapters.slash_common import (
record_turn,
registry_handler_words,
resolve_transcript_target,
transcript_messages_to_markdown,
)
from opensquilla.cli.tui.adapters.slash_gateway import (
GATEWAY_SLASH_HANDLER_WORDS,
GatewaySlashContext,
handle_gateway_slash_command,
)
from opensquilla.cli.tui.adapters.slash_policy import SlashCategory, classify
from opensquilla.cli.tui.adapters.slash_standalone import (
STANDALONE_SLASH_HANDLER_WORDS,
StandaloneSlashContext,
StandaloneSlashServices,
handle_standalone_slash_command,
)
from opensquilla.engine.commands import Surface
# Exit words are intercepted by the runtime loops before slash dispatch, so
# neither handler chain owns them.
_RUNTIME_OWNED_WORDS = frozenset({"/exit", "/quit"})
class _RecordingConsole:
def __init__(self) -> None:
self.entries: list[Any] = []
def print(self, *args: Any, **kwargs: Any) -> None:
self.entries.append(args[0] if args else "")
def text(self) -> str:
return "\n".join(str(entry) for entry in self.entries)
def _fake_error_panel(message: str, *, title: str = "Error") -> str:
return f"[panel:{title}] {message}"
def _patch_gateway_io(monkeypatch: pytest.MonkeyPatch) -> _RecordingConsole:
recorder = _RecordingConsole()
monkeypatch.setattr(_slash_gateway, "console", recorder)
monkeypatch.setattr(_slash_gateway, "error_panel", _fake_error_panel)
return recorder
def _patch_standalone_io(monkeypatch: pytest.MonkeyPatch) -> _RecordingConsole:
recorder = _RecordingConsole()
monkeypatch.setattr(_slash_standalone, "console", recorder)
monkeypatch.setattr(_slash_standalone, "error_panel", _fake_error_panel)
return recorder
class _StubGatewayClient:
"""Protocol-shaped double covering every method the adapter dispatches."""
def __init__(self) -> None:
self.calls: list[tuple[str, Any]] = []
self.created: list[dict[str, Any]] = []
self.resolve_payloads: dict[str, dict[str, Any]] = {}
self.history: list[dict[str, Any]] = []
self.raise_map: dict[str, Exception] = {}
self._counter = 0
def _maybe_raise(self, method: str) -> None:
exc = self.raise_map.get(method)
if exc is not None:
raise exc
async def call(self, method: str, params: dict | None = None) -> Any:
self.calls.append(("call", (method, params)))
self._maybe_raise("call")
if method == "meta.list":
return {"skills": []}
return {"ok": True}
async def create_session(
self,
agent_id: str = "main",
model: str | None = None,
display_name: str | None = None,
) -> str:
self._maybe_raise("create_session")
self._counter += 1
key = f"agent:main:test:{self._counter}"
self.created.append({"key": key, "model": model, "display_name": display_name})
return key
async def list_sessions(self, limit: int = 50) -> dict[str, Any]:
self._maybe_raise("list_sessions")
return {"sessions": []}
async def resolve_session(self, key: str) -> dict[str, Any]:
self._maybe_raise("resolve_session")
payload = self.resolve_payloads.get(key)
if payload is not None:
return dict(payload)
return {"session_key": key, "model": None}
async def delete_sessions(self, keys: list[str]) -> dict[str, Any]:
self._maybe_raise("delete_sessions")
self.calls.append(("delete_sessions", tuple(keys)))
return {"deleted": list(keys), "errors": []}
async def reset_session(self, key: str) -> dict[str, Any]:
self._maybe_raise("reset_session")
return {"reset": True, "key": key}
async def compact_session(self, key: str) -> dict[str, Any]:
self._maybe_raise("compact_session")
return {"compacted": False}
async def list_models(
self,
provider: str | None = None,
capabilities: list[str] | None = None,
) -> list[dict[str, Any]]:
self._maybe_raise("list_models")
return []
async def patch_session(self, key: str, **fields: Any) -> dict[str, Any]:
self._maybe_raise("patch_session")
self.calls.append(("patch_session", (key, fields)))
return {"ok": True}
async def usage_status(self) -> dict[str, Any]:
self._maybe_raise("usage_status")
return {"totalTokens": 0, "totalCostUsd": 0.0}
async def upload_file(self, path: Path, mime: str, name: str) -> str:
return "file-1"
def send_message(
self,
session_key: str,
message: str,
attachments: list[dict] | None = None,
elevated: str | None = None,
) -> AsyncIterator[dict[str, Any]]:
async def _events() -> AsyncIterator[dict[str, Any]]:
yield {}
return _events()
async def resolve_approval(
self,
approval_id: str,
approved: bool,
*,
choice: str | None = None,
) -> Any:
return {"ok": True}
async def abort_session(self, key: str) -> dict[str, Any]:
return {"ok": True}
async def session_history(self, session_key: str, limit: int = 1000) -> dict[str, Any]:
self._maybe_raise("session_history")
return {"messages": list(self.history)}
async def forget_approvals(self, target: str | None = None) -> dict[str, Any]:
self.calls.append(("forget_approvals", target))
return {"ok": True}
async def approvals_snapshot(self) -> dict[str, Any]:
return {"mode": "prompt"}
async def set_approval_mode(self, mode: str) -> dict[str, Any]:
self.calls.append(("set_approval_mode", mode))
return {"ok": True}
def _gateway_context(
client: _StubGatewayClient | None = None,
*,
model: str | None = "openai/test",
requested_model: str | None = None,
) -> GatewaySlashContext:
return GatewaySlashContext(
state=ChatSessionState(session_key="agent:main:test:0", model=model),
client=client or _StubGatewayClient(),
elevated_state={"mode": None},
requested_model=requested_model,
)
class _StandaloneHarness:
def __init__(self) -> None:
self.transcripts: dict[str, list[Any]] = {}
self.read_errors: dict[str, Exception] = {}
async def create_session(self, session_key: str, *, agent_id: str = "main") -> object:
return SimpleNamespace(session_key=session_key, agent_id=agent_id)
async def read_transcript(self, session_key: str) -> list[Any]:
exc = self.read_errors.get(session_key)
if exc is not None:
raise exc
return list(self.transcripts.get(session_key, []))
async def truncate_session(self, session_key: str, *, max_messages: int = 0) -> None:
self.transcripts[session_key] = []
async def compact_session(
self,
session_key: str,
context_window_tokens: int,
config: object | None = None,
) -> str:
return "summary"
async def flush_transcript(
self,
transcript: object,
session_key: str,
**kwargs: object,
) -> object:
return 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=[],
)
def _standalone_context(
harness: _StandaloneHarness | None = None,
*,
session_key: str = "agent:main:standalone:test",
model: str | None = "openai/test",
) -> StandaloneSlashContext:
harness = harness or _StandaloneHarness()
state = ChatSessionState(session_key=session_key, model=model)
return StandaloneSlashContext(
state=state,
session_key=session_key,
model=model,
tool_ctx=object(),
slash_services=StandaloneSlashServices(
create_session=harness.create_session,
read_transcript=harness.read_transcript,
truncate_session=harness.truncate_session,
compact_session=harness.compact_session,
flush_transcript=harness.flush_transcript,
),
turn_runner=object(),
build_tool_ctx=lambda _session_key: object(),
replace_session=lambda **_updates: None,
)
# --------------------------------------------------------------------------- #
# Word sets derive from the engine registry and the chains cover them #
# --------------------------------------------------------------------------- #
def test_handler_word_sets_derive_from_engine_registry() -> None:
assert GATEWAY_SLASH_HANDLER_WORDS == registry_handler_words(Surface.CLI_GATEWAY)
assert STANDALONE_SLASH_HANDLER_WORDS == registry_handler_words(Surface.CLI_STANDALONE)
assert "/meta" in GATEWAY_SLASH_HANDLER_WORDS
assert "/usage" not in STANDALONE_SLASH_HANDLER_WORDS
async def test_gateway_handler_chain_covers_every_registry_word(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
monkeypatch.chdir(tmp_path)
_patch_gateway_io(monkeypatch)
for word in sorted(GATEWAY_SLASH_HANDLER_WORDS - _RUNTIME_OWNED_WORDS):
handled = await handle_gateway_slash_command(word, _gateway_context())
assert handled is True, f"gateway handler chain does not dispatch {word}"
async def test_standalone_handler_chain_covers_every_registry_word(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
monkeypatch.chdir(tmp_path)
recorder = _patch_standalone_io(monkeypatch)
for word in sorted(STANDALONE_SLASH_HANDLER_WORDS - _RUNTIME_OWNED_WORDS):
recorder.entries.clear()
handled = await handle_standalone_slash_command(word, _standalone_context())
assert handled is True, f"standalone handler chain does not dispatch {word}"
assert "Unknown command" not in recorder.text(), f"{word} fell through as unknown"
# --------------------------------------------------------------------------- #
# Twin return contracts #
# --------------------------------------------------------------------------- #
async def test_gateway_unknown_command_returns_false(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_patch_gateway_io(monkeypatch)
handled = await handle_gateway_slash_command("/definitely-unknown", _gateway_context())
assert handled is False
async def test_standalone_unknown_command_prints_notice_and_returns_true(
monkeypatch: pytest.MonkeyPatch,
) -> None:
recorder = _patch_standalone_io(monkeypatch)
handled = await handle_standalone_slash_command("/definitely-unknown", _standalone_context())
assert handled is True
assert "Unknown command" in recorder.text()
# --------------------------------------------------------------------------- #
# Connection loss keeps the REPL alive #
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize(
("cmd", "method"),
[
("/sessions", "list_sessions"),
("/clear", "reset_session"),
("/usage", "usage_status"),
("/new", "create_session"),
],
)
async def test_gateway_connection_loss_renders_reconnect_hint(
monkeypatch: pytest.MonkeyPatch,
cmd: str,
method: str,
) -> None:
recorder = _patch_gateway_io(monkeypatch)
client = _StubGatewayClient()
client.raise_map[method] = ConnectionError(
"Gateway connection lost; restart chat or reconnect before sending another command."
)
handled = await handle_gateway_slash_command(cmd, _gateway_context(client))
assert handled is True
output = recorder.text()
assert "Gateway command failed" in output
assert "Gateway connection lost" in output
assert "opensquilla gateway" in output
async def test_gateway_os_error_from_rpc_is_reported_not_raised(
monkeypatch: pytest.MonkeyPatch,
) -> None:
recorder = _patch_gateway_io(monkeypatch)
client = _StubGatewayClient()
client.raise_map["list_models"] = OSError("socket closed")
handled = await handle_gateway_slash_command("/models", _gateway_context(client))
assert handled is True
assert "socket closed" in recorder.text()
# --------------------------------------------------------------------------- #
# /save error mapping and durable precedence #
# --------------------------------------------------------------------------- #
async def test_gateway_save_bad_path_renders_error_panel(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
recorder = _patch_gateway_io(monkeypatch)
client = _StubGatewayClient()
client.history = [{"role": "user", "text": "hello"}]
target = tmp_path / "missing-dir" / "out.md"
handled = await handle_gateway_slash_command(f"/save {target}", _gateway_context(client))
assert handled is True
assert "Could not save transcript" in recorder.text()
assert not target.exists()
async def test_standalone_save_bad_path_renders_error_panel(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
recorder = _patch_standalone_io(monkeypatch)
context = _standalone_context()
context.state.transcript.add("user", "hello")
target = tmp_path / "missing-dir" / "out.md"
handled = await handle_standalone_slash_command(f"/save {target}", context)
assert handled is True
assert "Could not save transcript" in recorder.text()
assert not target.exists()
async def test_standalone_save_exports_durable_history_after_resume(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
recorder = _patch_standalone_io(monkeypatch)
harness = _StandaloneHarness()
session_key = "agent:main:standalone:resumed"
harness.transcripts[session_key] = [
SimpleNamespace(role="user", content="persisted question"),
SimpleNamespace(role="assistant", content="persisted answer"),
]
context = _standalone_context(harness, session_key=session_key)
target = tmp_path / "resumed.md"
handled = await handle_standalone_slash_command(f"/save {target}", context)
assert handled is True
saved = target.read_text(encoding="utf-8")
assert "persisted question" in saved
assert "persisted answer" in saved
assert "Saved transcript" in recorder.text()
async def test_standalone_save_falls_back_to_memory_when_durable_read_fails(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
_patch_standalone_io(monkeypatch)
harness = _StandaloneHarness()
session_key = "agent:main:standalone:test"
harness.read_errors[session_key] = RuntimeError("storage offline")
context = _standalone_context(harness, session_key=session_key)
context.state.transcript.add("user", "in-memory only")
target = tmp_path / "fallback.md"
handled = await handle_standalone_slash_command(f"/save {target}", context)
assert handled is True
assert "in-memory only" in target.read_text(encoding="utf-8")
# --------------------------------------------------------------------------- #
# Requested-vs-routed model separation #
# --------------------------------------------------------------------------- #
async def test_gateway_new_does_not_pin_routed_display_model(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_patch_gateway_io(monkeypatch)
client = _StubGatewayClient()
# A router-default session: the stored session model is None even though
# the display model shows the router's last pick.
client.resolve_payloads["agent:main:test:0"] = {"model": None}
context = _gateway_context(client, model="router/last-pick")
handled = await handle_gateway_slash_command("/new", context)
assert handled is True
assert client.created[0]["model"] is None
async def test_gateway_new_prefers_explicit_requested_model(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_patch_gateway_io(monkeypatch)
client = _StubGatewayClient()
context = _gateway_context(client, model="router/last-pick", requested_model="openai/explicit")
await handle_gateway_slash_command("/new", context)
assert client.created[0]["model"] == "openai/explicit"
async def test_gateway_new_requested_model_survives_pin_read_failure(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""An erroring resolve must never override an explicitly requested model."""
recorder = _patch_gateway_io(monkeypatch)
client = _StubGatewayClient()
client.raise_map["resolve_session"] = RuntimeError("gateway busy")
context = _gateway_context(client, model="router/last-pick", requested_model="openai/explicit")
handled = await handle_gateway_slash_command("/new", context)
assert handled is True
assert client.created[0]["model"] == "openai/explicit"
assert "Could not read" not in recorder.text()
async def test_gateway_new_inherits_stored_session_pin(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_patch_gateway_io(monkeypatch)
client = _StubGatewayClient()
client.resolve_payloads["agent:main:test:0"] = {"model": "openai/pinned"}
context = _gateway_context(client, model="router/last-pick")
await handle_gateway_slash_command("/new", context)
assert client.created[0]["model"] == "openai/pinned"
async def test_gateway_new_warns_when_pin_read_fails(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A slow/erroring resolve while creating a new session must not silently
drop the pin: warn the user that the router default applies instead."""
recorder = _patch_gateway_io(monkeypatch)
client = _StubGatewayClient()
client.raise_map["resolve_session"] = RuntimeError("gateway busy")
context = _gateway_context(client, model="router/last-pick")
handled = await handle_gateway_slash_command("/new", context)
assert handled is True
# No explicit pin could be read, so the new session is created unpinned...
assert client.created[0]["model"] is None
# ...but the user is told, rather than left assuming the pin carried over.
output = recorder.text()
assert "Could not read the current session's model pin" in output
assert "/model" in output
async def test_gateway_model_records_explicit_request_on_context(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_patch_gateway_io(monkeypatch)
client = _StubGatewayClient()
context = _gateway_context(client)
handled = await handle_gateway_slash_command("/model openai/chosen", context)
assert handled is True
assert context.requested_model == "openai/chosen"
assert context.state.model == "openai/chosen"
assert ("patch_session", ("agent:main:test:0", {"model": "openai/chosen"})) in client.calls
# --------------------------------------------------------------------------- #
# /delete of the active session #
# --------------------------------------------------------------------------- #
async def test_gateway_delete_active_session_switches_to_fresh_session(
monkeypatch: pytest.MonkeyPatch,
) -> None:
recorder = _patch_gateway_io(monkeypatch)
client = _StubGatewayClient()
client.resolve_payloads["agent:main:test:0"] = {
"session_key": "agent:main:test:0",
"model": None,
}
context = _gateway_context(client)
context.state.transcript.add("user", "hello")
handled = await handle_gateway_slash_command("/delete agent:main:test:0", context)
assert handled is True
assert len(client.created) == 1
assert context.state.session_key == client.created[0]["key"]
assert context.state.transcript.turns == []
assert "switched to a new session" in recorder.text()
async def test_gateway_delete_other_session_keeps_active_state(
monkeypatch: pytest.MonkeyPatch,
) -> None:
recorder = _patch_gateway_io(monkeypatch)
client = _StubGatewayClient()
client.resolve_payloads["agent:main:other"] = {
"session_key": "agent:main:other",
"model": None,
}
context = _gateway_context(client)
handled = await handle_gateway_slash_command("/delete agent:main:other", context)
assert handled is True
assert client.created == []
assert context.state.session_key == "agent:main:test:0"
assert "Deleted session" in recorder.text()
async def test_gateway_delete_active_session_refreshes_display_model(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Deleting the active session must refresh state.model to the replacement
session's pin, like /new does — not leave the deleted session's stale
display model showing in /status and the HUD."""
_patch_gateway_io(monkeypatch)
client = _StubGatewayClient()
client.resolve_payloads["agent:main:test:0"] = {
"session_key": "agent:main:test:0",
"model": "openai/replacement-pin",
}
# The replacement session (next created key) resolves to its own pin.
client.resolve_payloads["agent:main:test:1"] = {"model": "openai/replacement-pin"}
context = _gateway_context(client, model="openai/stale-display")
handled = await handle_gateway_slash_command("/delete agent:main:test:0", context)
assert handled is True
assert client.created[0]["model"] == "openai/replacement-pin"
assert context.state.model == "openai/replacement-pin"
async def test_gateway_delete_active_session_model_falls_back_on_resolve_failure(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""When the post-create resolve fails, the display model still reflects the
replacement pin rather than the deleted session's stale value."""
_patch_gateway_io(monkeypatch)
class _DeleteResolveOnceClient(_StubGatewayClient):
def __init__(self) -> None:
super().__init__()
self._resolves = 0
async def resolve_session(self, key: str) -> dict[str, Any]:
self._resolves += 1
# First resolve (the /delete target lookup) succeeds; the
# post-create refresh resolve fails.
if self._resolves >= 2:
raise RuntimeError("gateway busy")
return {"session_key": key, "model": "openai/replacement-pin"}
client = _DeleteResolveOnceClient()
context = _gateway_context(client, model="openai/stale-display")
handled = await handle_gateway_slash_command("/delete agent:main:test:0", context)
assert handled is True
assert client.created[0]["model"] == "openai/replacement-pin"
assert context.state.model == "openai/replacement-pin"
# --------------------------------------------------------------------------- #
# Destructive / exit classification matches dispatch #
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize("command", ["/clear", " /clear ", "/reset", "/compact", "/cmp"])
def test_classify_destructive_exact_bare_word(command: str) -> None:
assert classify(command) is SlashCategory.DESTRUCTIVE
@pytest.mark.parametrize("command", ["/CLEAR", "/Clear", "/clear now", "/reset trailing-junk"])
def test_classify_never_purges_for_inputs_dispatch_rejects(command: str) -> None:
category = classify(command)
assert category is not SlashCategory.DESTRUCTIVE
assert category is not SlashCategory.EXIT
assert category is not SlashCategory.NON_SLASH
@pytest.mark.parametrize("command", ["/exit", "/quit", " /exit "])
def test_classify_exit_exact_bare_word(command: str) -> None:
assert classify(command) is SlashCategory.EXIT
@pytest.mark.parametrize("command", ["/EXIT", "/exit now", "/Quit"])
def test_classify_exit_variants_enqueue_for_runtime_interception(command: str) -> None:
category = classify(command)
assert category is not SlashCategory.EXIT
assert category is not SlashCategory.DESTRUCTIVE
assert category is not SlashCategory.NON_SLASH
# --------------------------------------------------------------------------- #
# Protocol double works for approval-flavored commands #
# --------------------------------------------------------------------------- #
async def test_gateway_approval_commands_accept_protocol_double(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_patch_gateway_io(monkeypatch)
client = _StubGatewayClient()
context = _gateway_context(client)
assert await handle_gateway_slash_command("/approvals", context) is True
assert await handle_gateway_slash_command("/forget some-target", context) is True
assert await handle_gateway_slash_command("/permissions off", context) is True
assert ("forget_approvals", "some-target") in client.calls
assert ("set_approval_mode", "prompt") in client.calls
assert context.state.elevated is None
def test_tool_compress_dead_code_is_removed() -> None:
assert not hasattr(_slash_gateway, "_handle_tool_compress_command")
assert not hasattr(_slash_bridge, "handle_tool_compress_command")
# --------------------------------------------------------------------------- #
# Shared helper behavior #
# --------------------------------------------------------------------------- #
def test_resolve_transcript_target_defaults_to_session_derived_name() -> None:
target = resolve_transcript_target("/save", "agent:main:test:1")
assert target == Path("opensquilla-chat-agent-main-test-1.md")
explicit = resolve_transcript_target("/save /tmp/out.md", "agent:main:test:1")
assert explicit == Path("/tmp/out.md")
def test_transcript_messages_to_markdown_accepts_dicts_and_rows() -> None:
markdown = transcript_messages_to_markdown(
[
{"role": "user", "text": "dict question"},
SimpleNamespace(role="assistant", content="row answer"),
]
)
assert "dict question" in markdown
assert "row answer" in markdown
def test_record_turn_updates_transcript_and_usage() -> None:
state = ChatSessionState(session_key="agent:main:test:2")
record_turn(state, "ask", TurnResult(text="answer"))
assert [turn.role for turn in state.transcript.turns] == ["user", "assistant"]
assert state.transcript.turns[0].content == "ask"
assert state.transcript.turns[1].content == "answer"
+41
View File
@@ -0,0 +1,41 @@
from __future__ import annotations
from opensquilla.cli.tui.state import TuiRuntimeState
def test_runtime_state_tracks_pending_queue_fifo() -> None:
state = TuiRuntimeState()
state.enqueue("first")
state.enqueue("second")
assert state.pending_size == 2
assert state.pending_items == ("first", "second")
assert state.promote_next() == "first"
assert state.promote_next() == "second"
assert state.promote_next() is None
def test_runtime_state_exposes_active_turn_transitions() -> None:
state = TuiRuntimeState()
assert state.has_active_turn is False
state.mark_turn_started("hello")
assert state.has_active_turn is True
assert state.active_input == "hello"
state.mark_turn_finished()
assert state.has_active_turn is False
assert state.active_input is None
def test_runtime_state_clears_pending_queue() -> None:
state = TuiRuntimeState()
state.enqueue("first")
state.enqueue("second")
dropped = state.clear_pending()
assert dropped == ("first", "second")
assert state.pending_size == 0
+328
View File
@@ -0,0 +1,328 @@
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Any, Literal
from opensquilla.cli.chat.turn_stream import (
TurnStreamDependencies,
handle_image_command_turnrunner,
stream_response_gateway,
)
from opensquilla.cli.tui.backend.domain_events import (
KIND_DONE,
KIND_ROUTER_DECISION,
KIND_TEXT_FLUSH,
TuiDomainEvent,
)
from opensquilla.cli.tui.backend.streaming import StreamingFlushPolicy, StreamingPlane
from opensquilla.engine.types import DoneEvent, RouterDecisionEvent, TextDeltaEvent
from opensquilla.tools.types import CallerKind, ToolContext
class MutableClock:
def __init__(self) -> None:
self.value = 0
def __call__(self) -> int:
return self.value
def advance(self, milliseconds: int) -> None:
self.value += milliseconds
def test_streaming_plane_flushes_when_delay_budget_expires() -> None:
clock = MutableClock()
plane = StreamingPlane(
policy=StreamingFlushPolicy(max_delay_ms=33, max_chars=100),
clock_ms=clock,
)
assert plane.append("abc") is None
clock.advance(34)
flush = plane.append("def")
assert flush is not None
assert flush.text == "abcdef"
assert flush.reason == "delay"
assert plane.delta_count == 2
assert plane.flush_count == 1
assert plane.text_chars == 6
assert plane.max_buffer_chars == 6
def test_streaming_plane_flushes_when_size_budget_is_reached() -> None:
plane = StreamingPlane(policy=StreamingFlushPolicy(max_chars=10))
assert plane.append("12345") is None
flush = plane.append("67890")
assert flush is not None
assert flush.text == "1234567890"
assert flush.reason == "size"
assert plane.flush_count == 1
def test_streaming_plane_flushes_newline_chunks_after_minimum_size() -> None:
plane = StreamingPlane(
policy=StreamingFlushPolicy(max_chars=100, newline_min_chars=5),
)
assert plane.append("abcd") is None
flush = plane.append("e\n")
assert flush is not None
assert flush.text == "abcde\n"
assert flush.reason == "newline"
def test_streaming_plane_finish_flushes_tail_text() -> None:
plane = StreamingPlane(policy=StreamingFlushPolicy(max_chars=100))
assert plane.append("tail") is None
flush = plane.finish()
assert flush is not None
assert flush.text == "tail"
assert flush.reason == "finish"
assert plane.finish() is None
def test_streaming_plane_emits_text_flush_domain_events() -> None:
events: list[TuiDomainEvent] = []
plane = StreamingPlane(
policy=StreamingFlushPolicy(max_chars=4),
event_sink=events.append,
source="turn_runner",
turn_id="turn-1",
)
flush = plane.append("abcd")
assert flush is not None
assert [event.kind for event in events] == [KIND_TEXT_FLUSH]
assert events[0].source == "turn_runner"
assert events[0].turn_id == "turn-1"
assert events[0].payload["text"] == "abcd"
assert events[0].payload["chars"] == 4
assert events[0].payload["reason"] == "size"
class _FakeGatewayClient:
def __init__(self, events: list[dict[str, Any]]) -> None:
self._events = events
async def send_message(
self,
session_key: str,
message: str,
attachments: list[dict] | None = None,
elevated: str | None = None,
) -> AsyncIterator[dict[str, Any]]:
del session_key, message, attachments, elevated
for event in self._events:
yield event
async def resolve_approval(
self,
approval_id: str,
approved: bool,
*,
choice: str | None = None,
) -> Any:
del approval_id, approved, choice
return None
async def abort_session(self, key: str) -> Any:
del key
return None
class _FakeRenderer:
def __init__(self) -> None:
self.buffer = ""
self.append_calls: list[str] = []
self.finalized = False
def __enter__(self) -> _FakeRenderer:
return self
def __exit__(self, exc_type: object, exc: object, tb: object) -> Literal[False]:
del exc_type, exc, tb
return False
async def aappend_text(self, delta: str) -> None:
self.append_calls.append(delta)
self.buffer += delta
async def afinalize(self, usage: Any | None = None, *, cancelled: bool = False) -> None:
del usage, cancelled
self.finalized = True
async def aclose(self) -> None:
return None
class _RendererFactory:
def __init__(self) -> None:
self.created: list[_FakeRenderer] = []
def __call__(self, **_kwargs: Any) -> _FakeRenderer:
renderer = _FakeRenderer()
self.created.append(renderer)
return renderer
class _FakeOutputHandle:
@property
def approval_surface(self) -> object:
return object()
async def write_through(self, payload: str) -> None:
del payload
@asynccontextmanager
async def stream_output(self):
def write(_payload: str) -> None:
return None
yield write
def _text_delta_events(count: int, chunk: str) -> list[dict[str, Any]]:
return [
{"event": "session.event.text_delta", "text": chunk}
for _ in range(count)
] + [{"event": "session.event.done"}]
class _ImageTurnRunner:
def __init__(self, events: list[object]) -> None:
self._events = events
async def run(self, *_args: Any, **_kwargs: Any) -> AsyncIterator[object]:
for event in self._events:
yield event
def test_turn_stream_coalesces_many_text_deltas_for_tui_output() -> None:
renderer_factory = _RendererFactory()
events: list[TuiDomainEvent] = []
deps = TurnStreamDependencies(
renderer_factory=renderer_factory,
stream_wrapper=lambda stream, _svc: stream,
approval_handler=lambda *_args, **_kwargs: asyncio.sleep(0),
cancel_clearer=lambda: None,
image_attachment_builder=lambda _command: ("", []),
output_console=object(),
error_panel_factory=lambda message: message,
tui_event_sink=events.append,
)
result = asyncio.run(
stream_response_gateway(
_FakeGatewayClient(_text_delta_events(4_000, "abcd")),
"session-1",
"hello",
tui_output=_FakeOutputHandle(),
deps=deps,
)
)
renderer = renderer_factory.created[0]
assert result.text == "abcd" * 4_000
assert renderer.buffer == result.text
assert len(renderer.append_calls) < 100
assert len(renderer.append_calls) == sum(
1 for event in events if event.kind == KIND_TEXT_FLUSH
)
def test_turn_stream_keeps_one_delta_per_append_without_tui_streaming_surface() -> None:
renderer_factory = _RendererFactory()
deps = TurnStreamDependencies(
renderer_factory=renderer_factory,
stream_wrapper=lambda stream, _svc: stream,
approval_handler=lambda *_args, **_kwargs: asyncio.sleep(0),
cancel_clearer=lambda: None,
image_attachment_builder=lambda _command: ("", []),
output_console=object(),
error_panel_factory=lambda message: message,
)
result = asyncio.run(
stream_response_gateway(
_FakeGatewayClient(_text_delta_events(12, "x")),
"session-1",
"hello",
deps=deps,
)
)
renderer = renderer_factory.created[0]
assert result.text == "x" * 12
assert renderer.append_calls == ["x"] * 12
def test_image_command_turnrunner_uses_tui_streaming_plane_and_events(
monkeypatch,
) -> None:
monkeypatch.setattr("opensquilla.engine.runtime.TurnRunner", _ImageTurnRunner)
renderer_factory = _RendererFactory()
events: list[TuiDomainEvent] = []
deps = TurnStreamDependencies(
renderer_factory=renderer_factory,
stream_wrapper=lambda stream, _svc: stream,
approval_handler=lambda *_args, **_kwargs: asyncio.sleep(0),
cancel_clearer=lambda: None,
image_attachment_builder=lambda _command: (
"describe image",
[{"path": "/tmp/image.png"}],
),
output_console=object(),
error_panel_factory=lambda message: message,
tui_event_sink=events.append,
)
tool_ctx = ToolContext(
caller_kind=CallerKind.CLI,
channel_kind="cli",
channel_id="cli:chat",
)
router_event = RouterDecisionEvent(
tier="t2",
tier_index=2,
model="anthropic/claude-sonnet-4.6",
baseline_model="anthropic/claude-opus-4.7",
source="router",
confidence=0.71,
savings_pct=64.0,
)
turn_runner = _ImageTurnRunner(
[router_event]
+ [TextDeltaEvent(text="abcd") for _ in range(4_000)]
+ [DoneEvent(model="anthropic/claude-sonnet-4.6")]
)
result = asyncio.run(
handle_image_command_turnrunner(
turn_runner,
"agent:main:image",
tool_ctx,
"/image /tmp/image.png",
tui_output=_FakeOutputHandle(),
deps=deps,
)
)
renderer = renderer_factory.created[0]
assert result.text == "abcd" * 4_000
assert renderer.buffer == result.text
assert len(renderer.append_calls) < 100
assert [event.kind for event in events if event.kind != KIND_TEXT_FLUSH] == [
KIND_ROUTER_DECISION,
KIND_DONE,
]
assert sum(1 for event in events if event.kind == KIND_TEXT_FLUSH) == len(
renderer.append_calls
)
@@ -0,0 +1,66 @@
from __future__ import annotations
import asyncio
from dataclasses import asdict
from typing import Any
import pytest
from opensquilla.cli.tui.adapters import native_bridge
from opensquilla.cli.tui.adapters.native_bridge import NativeTerminalSurface
from opensquilla.cli.tui.opentui.messages import HostInputEof, HostInputSubmit
from opensquilla.cli.tui.opentui.surface import OpenTuiSurface
from opensquilla.engine.commands import Surface
class _FakeOpenTuiBridge:
def __init__(self) -> None:
self.messages: asyncio.Queue[object] = asyncio.Queue()
self.sent: list[tuple[str, dict[str, Any] | None]] = []
async def send(self, message_type: str, payload: object | None = None) -> None:
if payload is None:
self.sent.append((message_type, None))
return
self.sent.append(
(message_type, payload if isinstance(payload, dict) else asdict(payload))
)
async def next_message(self) -> object | None:
return await self.messages.get()
@pytest.mark.asyncio
async def test_opentui_eof_state_is_per_surface_instance() -> None:
bridge = _FakeOpenTuiBridge()
first = OpenTuiSurface(bridge, approval_surface=Surface.CLI_GATEWAY)
bridge.messages.put_nowait(HostInputEof())
assert await first.next_line() is None
bridge.messages.put_nowait(HostInputSubmit(text="fresh input"))
assert await first.next_line() is None
second = OpenTuiSurface(bridge, approval_surface=Surface.CLI_GATEWAY)
assert await second.next_line() == "fresh input"
@pytest.mark.asyncio
async def test_native_eof_state_is_per_surface_instance(monkeypatch: pytest.MonkeyPatch) -> None:
calls = 0
def fake_input(_prompt: str) -> str:
nonlocal calls
calls += 1
if calls == 1:
raise EOFError
return "fresh input"
monkeypatch.setattr(native_bridge.console, "input", fake_input)
first = NativeTerminalSurface(approval_surface=Surface.CLI_GATEWAY)
assert await first.next_line() is None
assert await first.next_line() is None
second = NativeTerminalSurface(approval_surface=Surface.CLI_GATEWAY)
assert await second.next_line() == "fresh input"
@@ -0,0 +1,159 @@
from __future__ import annotations
from opensquilla.cli.tui.backend.transcript import (
MessageItem,
RouterDecisionItem,
StatusItem,
ToolItem,
ToolPreviewPolicy,
TranscriptStore,
UsageItem,
ViewportRequest,
build_args_preview,
build_output_preview,
project_viewport,
)
def test_transcript_store_assigns_stable_ids_and_snapshots_are_immutable() -> None:
store = TranscriptStore()
first = store.append(MessageItem(role="user", text="hello", run_id="run-1", timestamp_ms=1))
second = store.append(
ToolItem(
tool_id="call-1",
name="search",
status="running",
args_preview="{}",
output_preview="",
expanded=False,
timestamp_ms=2,
)
)
snapshot = store.snapshot()
store.append(StatusItem(message="working", style="dim", timestamp_ms=3))
assert first.item_id == "message-1"
assert second.item_id == "tool-call-1"
assert [item.item_id for item in snapshot] == ["message-1", "tool-call-1"]
assert len(snapshot) == 2
assert len(store) == 3
def test_transcript_store_clear_resets_items_and_id_counters() -> None:
store = TranscriptStore()
store.append(MessageItem(role="user", text="hello", run_id=None, timestamp_ms=1))
store.clear()
item = store.append(MessageItem(role="assistant", text="hi", run_id=None, timestamp_ms=2))
assert len(store) == 1
assert item.item_id == "message-1"
def test_tool_args_preview_caps_long_json_without_reordering_keys() -> None:
policy = ToolPreviewPolicy(max_arg_chars=28)
preview = build_args_preview({"first": "a" * 20, "second": "b"}, policy)
assert preview.truncated is True
assert preview.text.startswith('{"first"')
assert "..." in preview.text
assert len(preview.text) <= policy.max_arg_chars + len("...")
def test_tool_output_preview_caps_lines_and_chars() -> None:
policy = ToolPreviewPolicy(max_output_lines=3, max_output_chars=200)
output = "\n".join(f"line {index}" for index in range(10))
preview = build_output_preview(output, policy)
assert preview.truncated is True
assert "line 0" in preview.text
assert "line 2" in preview.text
assert "line 3" not in preview.text
assert "... truncated" in preview.text
def test_tool_output_preview_handles_image_placeholders_and_errors() -> None:
policy = ToolPreviewPolicy()
image_preview = build_output_preview(
{"type": "image", "mime": "image/png", "width": 20, "height": 10},
policy,
)
error_preview = build_output_preview("boom", policy, is_error=True)
assert image_preview.text == "[image image/png 20x10]"
assert image_preview.truncated is False
assert error_preview.text == "error: boom"
def test_viewport_projection_is_bounded_for_dense_history() -> None:
store = TranscriptStore()
for index in range(250):
store.append(
MessageItem(
role="user",
text=f"user {index}",
run_id="run-dense",
timestamp_ms=index * 2,
)
)
store.append(
MessageItem(
role="assistant",
text=f"assistant {index}",
run_id="run-dense",
timestamp_ms=index * 2 + 1,
)
)
for index in range(120):
store.append(
ToolItem(
tool_id=f"tool-{index}",
name="search",
status="done",
args_preview="{}",
output_preview="ok",
expanded=index < 20,
timestamp_ms=1_000 + index,
detail_line_count=8,
)
)
projection = project_viewport(
store.snapshot(),
ViewportRequest(scroll_offset=200, viewport_height=24, overscan=3),
)
assert projection.total_items == 620
assert projection.total_rows > 620
assert len(projection.items) <= 30
assert projection.items == project_viewport(
store.snapshot(),
ViewportRequest(scroll_offset=200, viewport_height=24, overscan=3),
).items
def test_transcript_accepts_router_status_and_usage_items() -> None:
store = TranscriptStore()
store.append(
RouterDecisionItem(
tier="standard",
model="openrouter/model",
baseline_model="openrouter/baseline",
confidence=0.71,
rollout_phase="full",
timestamp_ms=1,
)
)
store.append(StatusItem(message="working", style="dim", timestamp_ms=2))
store.append(UsageItem(input_tokens=10, output_tokens=20, cost_usd=0.01, timestamp_ms=3))
assert [item.item_id for item in store.snapshot()] == [
"router-1",
"status-1",
"usage-1",
]
@@ -0,0 +1,119 @@
from __future__ import annotations
import asyncio
import importlib.util
import json
import sys
from pathlib import Path
from typing import Any
PROJECT_ROOT = Path(__file__).resolve().parents[4]
BENCH_SCRIPT = PROJECT_ROOT / "scripts" / "bench_tui_replay.py"
FIXTURE_MODULE = Path(__file__).with_name("replay_fixtures.py")
def _load_module(name: str, path: Path) -> Any:
spec = importlib.util.spec_from_file_location(name, path)
assert spec is not None
assert spec.loader is not None
module = importlib.util.module_from_spec(spec)
sys.modules[name] = module
spec.loader.exec_module(module)
return module
fixtures = _load_module("tui_replay_fixtures", FIXTURE_MODULE)
ReplayEvent = fixtures.ReplayEvent
build_dense_history_events = fixtures.build_dense_history_events
build_long_stream_events = fixtures.build_long_stream_events
def _events_by_kind(events: list[Any], kind: str) -> list[Any]:
return [event for event in events if event.kind == kind]
def _text_chars(events: list[ReplayEvent]) -> int:
return sum(
len(str(event.payload["text"]))
for event in events
if event.kind == "text_delta"
)
def test_long_stream_fixture_matches_baseline_shape() -> None:
events = build_long_stream_events()
assert all(isinstance(event, ReplayEvent) for event in events)
assert events[0].kind == "user_input"
assert events[1].kind == "router_decision"
assert events[-1].kind == "done"
assert len(_events_by_kind(events, "text_delta")) == 4_000
assert _text_chars(events) == 160_000
assert len(_events_by_kind(events, "tool_start")) == 4
assert len(_events_by_kind(events, "tool_finished")) == 4
assert len(_events_by_kind(events, "router_decision")) == 1
def test_dense_history_fixture_matches_baseline_shape() -> None:
events = build_dense_history_events()
messages = _events_by_kind(events, "history_message")
tool_cards = _events_by_kind(events, "tool_card")
text_chars = sum(
len(str(event.payload["content"]))
for event in messages
if "content" in event.payload
)
assert len(messages) == 500
assert sum(1 for event in messages if event.payload["role"] == "user") == 250
assert sum(1 for event in messages if event.payload["role"] == "assistant") == 250
assert len(tool_cards) == 120
assert sum(1 for event in tool_cards if event.payload["expanded_candidate"]) == 20
assert len(_events_by_kind(events, "router_decision")) == 4
assert text_chars >= 24 * 80 * 30
def test_opentui_replay_summary_can_be_written(tmp_path) -> None:
bench = _load_module("bench_tui_replay", BENCH_SCRIPT)
summary = asyncio.run(bench.run_replay("opentui", "long-stream", repeat=1))
summary_path = tmp_path / "summary.json"
bench.write_summary(summary, summary_path)
data = json.loads(summary_path.read_text())
assert data["renderer"] == "opentui"
assert data["fixture"] == "long-stream"
assert data["event_count"] == 4011
assert data["text_chars"] == 160_000
assert data["tool_count"] == 4
assert data["router_decision_count"] == 1
assert data["flush_count"] == 0
assert 0 < data["coalescing_ratio"] < 1
assert data["max_buffer_chars"] <= 2_048
assert data["transcript_items"] == 0
assert data["visible_items"] == 0
assert data["expanded_tools"] == 0
assert data["projection_wall_ms"] == 0
assert data["available"] is True
assert data["skip_reason"] is None
assert data["rendered_text_matches"] is True
assert data["plugin_error_count"] == 0
assert data["errors"] == []
def test_dense_history_replay_uses_bounded_transcript_projection() -> None:
bench = _load_module("bench_tui_replay", BENCH_SCRIPT)
summary = asyncio.run(bench.run_replay("opentui", "dense-history", repeat=1))
assert summary.event_count == 624
assert summary.text_chars >= 24 * 80 * 30
assert summary.tool_count == 120
assert summary.router_decision_count == 4
assert summary.flush_count == 0
assert summary.coalescing_ratio == 0
assert summary.transcript_items == 624
assert 0 < summary.visible_items <= 30
assert summary.expanded_tools == 20
assert summary.projection_wall_ms >= 0
assert summary.errors == []