367 lines
12 KiB
Python
367 lines
12 KiB
Python
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"]
|