chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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"]
|
||||
@@ -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 == []
|
||||
@@ -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"}]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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}")
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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")
|
||||
@@ -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", {})]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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")
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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 == []
|
||||
Reference in New Issue
Block a user