324 lines
11 KiB
Python
324 lines
11 KiB
Python
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
|