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",
|
||||
)
|
||||
Reference in New Issue
Block a user