4b6817381b
Benchmark image — build + push to ECR (any adapter) / build + push (push) Waiting to run
CI / quality (ubuntu-latest) (push) Waiting to run
CI / test (tools-runtime) (push) Waiting to run
CI / test (e2e-general) (push) Waiting to run
CI / test (cli-runtime) (push) Waiting to run
CI / test (e2e-provider-and-openclaw) (push) Waiting to run
CI / test (integrations-and-misc) (push) Waiting to run
CI / coverage-report (push) Blocked by required conditions
CI / test-kubernetes (push) Waiting to run
CI / should-run-thorough (push) Waiting to run
CI / test-thorough (cloudwatch-demo) (push) Blocked by required conditions
CI / test-thorough (flink-ecs) (push) Blocked by required conditions
CI / test-thorough (upstream-lambda) (push) Blocked by required conditions
CI / test-thorough (prefect-ecs-fargate) (push) Blocked by required conditions
CodeQL / Analyze (python) (push) Waiting to run
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Blocked by required conditions
Release / publish-release (push) Blocked by required conditions
Release / publish-main-release (push) Blocked by required conditions
Release / prepare (push) Waiting to run
Release / verify (push) Blocked by required conditions
Release / build-python-dist (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Blocked by required conditions
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Waiting to run
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Waiting to run
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Waiting to run
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
1781 lines
69 KiB
Python
1781 lines
69 KiB
Python
"""Tests for the interactive shell terminal runtime helpers."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import io
|
||
import re
|
||
import subprocess
|
||
import sys
|
||
import threading
|
||
import time
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
from prompt_toolkit.application import create_app_session
|
||
from prompt_toolkit.buffer import Buffer
|
||
from prompt_toolkit.completion import CompleteEvent
|
||
from prompt_toolkit.document import Document
|
||
from prompt_toolkit.history import FileHistory, InMemoryHistory
|
||
from prompt_toolkit.input import DummyInput
|
||
from prompt_toolkit.input.defaults import create_pipe_input
|
||
from prompt_toolkit.keys import Keys
|
||
from prompt_toolkit.output import DummyOutput
|
||
|
||
from platform.terminal import theme as ui_theme
|
||
from platform.terminal.theme import (
|
||
ANSI_RESET,
|
||
get_active_theme_name,
|
||
set_active_theme,
|
||
)
|
||
from surfaces.interactive_shell.command_registry import SLASH_COMMANDS, dispatch_slash
|
||
from surfaces.interactive_shell.runtime.core import confirmation as controller_runtime
|
||
from surfaces.interactive_shell.runtime.core import state as loop_state
|
||
from surfaces.interactive_shell.runtime.core import turn_detection as loop_turn_detection
|
||
from surfaces.interactive_shell.runtime.startup import initial_input as startup_initial_input
|
||
from surfaces.interactive_shell.session import Session
|
||
from surfaces.interactive_shell.ui import input_prompt
|
||
from surfaces.interactive_shell.ui.components.cpr_stdin import (
|
||
strip_cpr_escape_sequences,
|
||
strip_cpr_sequences,
|
||
)
|
||
from surfaces.interactive_shell.ui.input_prompt import completion as prompt_completion
|
||
from surfaces.interactive_shell.ui.input_prompt.completion import ShellCompleter
|
||
from surfaces.interactive_shell.ui.input_prompt.key_bindings import (
|
||
_SHIFT_ENTER_SEQUENCE,
|
||
_build_prompt_key_bindings,
|
||
_tab_expand_or_menu,
|
||
build_cancel_key_bindings,
|
||
)
|
||
from surfaces.interactive_shell.ui.input_prompt.lexer import ReplInputLexer
|
||
from surfaces.interactive_shell.ui.input_prompt.rendering import _prompt_message
|
||
from surfaces.interactive_shell.ui.input_prompt.style import _build_prompt_style
|
||
from surfaces.interactive_shell.ui.streaming import _CHARS_PER_TOKEN
|
||
from surfaces.interactive_shell.ui.streaming.console import StreamingConsole
|
||
|
||
|
||
def test_agent_presentation_import_does_not_load_shell_turn_execution() -> None:
|
||
result = subprocess.run(
|
||
[
|
||
sys.executable,
|
||
"-c",
|
||
(
|
||
"import sys; "
|
||
"import surfaces.interactive_shell.runtime.agent_presentation; "
|
||
"print('surfaces.interactive_shell.runtime.shell_turn_execution' in sys.modules)"
|
||
),
|
||
],
|
||
check=True,
|
||
capture_output=True,
|
||
text=True,
|
||
)
|
||
|
||
assert result.stdout.strip() == "False"
|
||
|
||
|
||
def test_streaming_console_status_does_not_recurse(monkeypatch) -> None:
|
||
"""Regression: overriding Console.print broke Rich's status spinner."""
|
||
spinner = loop_state.SpinnerState()
|
||
console = StreamingConsole(
|
||
spinner,
|
||
threading.Event(),
|
||
file=io.StringIO(),
|
||
force_terminal=False,
|
||
width=80,
|
||
)
|
||
with console.status("working", spinner="dots"):
|
||
pass
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("text", "expected"),
|
||
[
|
||
("\x1b[32;1R", ""),
|
||
("[32;1R", ""),
|
||
("\x9b32;1R", ""),
|
||
("what is our current model?[32;1R", "what is our current model?"),
|
||
("before \x1b[12;80R after", "before after"),
|
||
("7R[25;57R23;57R", ""),
|
||
("25;57R", ""),
|
||
# Regression (issue #2942): CPR-shaped substrings embedded in ordinary
|
||
# input must NOT be stripped. A bare ``rowR``/``row;colR`` followed by a
|
||
# lone digit, or trailed by a word, is real user text, not a leak — only a
|
||
# full ``row;colR`` continuation counts as a CPR fragment.
|
||
("retry 5R3 times", "retry 5R3 times"),
|
||
("ranges 12;34R okay", "ranges 12;34R okay"),
|
||
("use r5.xlarge", "use r5.xlarge"),
|
||
("scale to 16RAM nodes", "scale to 16RAM nodes"),
|
||
("scale 12;34R5 nodes", "scale 12;34R5 nodes"),
|
||
# Consecutive bare fragments (no introducer) are a real leaked burst and
|
||
# must still strip fully.
|
||
("25;57R12;80R", ""),
|
||
],
|
||
)
|
||
def test_strip_cpr_sequences_removes_terminal_cursor_replies(
|
||
text: str,
|
||
expected: str,
|
||
) -> None:
|
||
assert strip_cpr_sequences(text) == expected
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("text", "expected"),
|
||
[
|
||
("before \x1b[12;80R after", "before after"),
|
||
("what is 12;80R?", "what is 12;80R?"),
|
||
("[12;80R", "[12;80R"),
|
||
],
|
||
)
|
||
def test_strip_cpr_escape_sequences_only_removes_escaped_variants(
|
||
text: str,
|
||
expected: str,
|
||
) -> None:
|
||
assert strip_cpr_escape_sequences(text) == expected
|
||
|
||
|
||
def test_repl_input_lexer_highlights_first_slash_token() -> None:
|
||
lexer = ReplInputLexer()
|
||
get_line = lexer.lex_document(Document("/model show", len("/model")))
|
||
fragments = get_line(0)
|
||
cmd_frags = [(frag[0], frag[1]) for frag in fragments if frag[0] == "class:repl-slash-command"]
|
||
assert cmd_frags == [("class:repl-slash-command", "/model")]
|
||
rest = "".join(frag[1] for frag in fragments if frag[0] == "")
|
||
assert " show" in rest or rest.endswith(" show")
|
||
|
||
|
||
def test_build_prompt_session_uses_persistent_history(
|
||
tmp_path: Path,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
import config.constants as const_module
|
||
|
||
monkeypatch.setattr(const_module, "OPENSRE_HOME_DIR", tmp_path)
|
||
|
||
with create_app_session(input=DummyInput(), output=DummyOutput()):
|
||
prompt = input_prompt.build_prompt_session()
|
||
|
||
assert isinstance(prompt.history, FileHistory)
|
||
assert prompt.history.filename == str(tmp_path / "interactive_history")
|
||
assert tmp_path.exists()
|
||
assert isinstance(prompt.completer, ShellCompleter)
|
||
assert prompt.multiline is True
|
||
assert prompt.reserve_space_for_menu == 8
|
||
assert prompt.app.key_bindings is not None
|
||
|
||
|
||
def test_build_prompt_session_falls_back_to_memory_history(
|
||
tmp_path: Path,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
import config.constants as const_module
|
||
|
||
blocked_home = tmp_path / "not-a-directory"
|
||
blocked_home.write_text("", encoding="utf-8")
|
||
monkeypatch.setattr(const_module, "OPENSRE_HOME_DIR", blocked_home)
|
||
|
||
with create_app_session(input=DummyInput(), output=DummyOutput()):
|
||
prompt = input_prompt.build_prompt_session()
|
||
|
||
assert isinstance(prompt.history, InMemoryHistory)
|
||
|
||
|
||
def test_repl_session_prompt_history_backend_matches_prompt_toolkit_history(
|
||
tmp_path: Path,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
import config.constants as const_module
|
||
|
||
monkeypatch.setattr(const_module, "OPENSRE_HOME_DIR", tmp_path)
|
||
with create_app_session(input=DummyInput(), output=DummyOutput()):
|
||
session = Session()
|
||
prompt = input_prompt.build_prompt_session()
|
||
session.terminal.prompt_history_backend = prompt.history
|
||
assert session.terminal.prompt_history_backend is prompt.history
|
||
|
||
|
||
def test_prompt_message_uses_accent_glyph() -> None:
|
||
set_active_theme("blue")
|
||
rendered = _prompt_message(Session()).value
|
||
|
||
assert ui_theme.PROMPT_ACCENT_ANSI in rendered
|
||
assert "❯" in rendered
|
||
assert ANSI_RESET in rendered
|
||
|
||
|
||
def test_shift_enter_inserts_newline_before_submit(
|
||
tmp_path: Path,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
import config.constants as const_module
|
||
|
||
monkeypatch.setattr(const_module, "OPENSRE_HOME_DIR", tmp_path)
|
||
|
||
async def _collect() -> str:
|
||
with (
|
||
create_pipe_input() as pipe_input,
|
||
create_app_session(input=pipe_input, output=DummyOutput()),
|
||
):
|
||
prompt = input_prompt.build_prompt_session()
|
||
task = asyncio.create_task(prompt.prompt_async(""))
|
||
pipe_input.send_bytes(b"first line")
|
||
pipe_input.send_bytes(_SHIFT_ENTER_SEQUENCE.encode())
|
||
pipe_input.send_bytes(b"second line\r")
|
||
return await asyncio.wait_for(task, timeout=1)
|
||
|
||
assert asyncio.run(_collect()) == "first line\nsecond line"
|
||
|
||
|
||
def test_shell_completer_previews_all_commands() -> None:
|
||
completions = list(
|
||
ShellCompleter().get_completions(
|
||
Document("/"),
|
||
CompleteEvent(text_inserted=True),
|
||
)
|
||
)
|
||
names = [completion.text for completion in completions]
|
||
|
||
assert "/help" in names
|
||
assert "/effort" in names
|
||
assert "/integrations" in names
|
||
assert "/tools" in names
|
||
assert "/model" in names
|
||
assert all(name.startswith("/") for name in names)
|
||
|
||
|
||
def test_shell_completer_filters_by_prefix() -> None:
|
||
completions = list(
|
||
ShellCompleter().get_completions(
|
||
Document("/to"),
|
||
CompleteEvent(text_inserted=True),
|
||
)
|
||
)
|
||
|
||
assert [completion.text for completion in completions] == ["/tools"]
|
||
|
||
|
||
def test_shell_completer_suggests_subcommands_for_tools() -> None:
|
||
completions = list(
|
||
ShellCompleter().get_completions(
|
||
Document("/tools "),
|
||
CompleteEvent(text_inserted=True),
|
||
)
|
||
)
|
||
names = sorted({c.text for c in completions})
|
||
assert names == ["list", "ls", "tool", "tools"]
|
||
|
||
|
||
def test_shell_completer_hides_inline_picker_autocomplete_in_tty(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
monkeypatch.setattr(prompt_completion, "repl_tty_interactive", lambda: True)
|
||
|
||
completions = list(
|
||
ShellCompleter().get_completions(
|
||
Document("/model "),
|
||
CompleteEvent(text_inserted=True),
|
||
)
|
||
)
|
||
|
||
assert completions == []
|
||
|
||
|
||
def test_shell_completer_keeps_inline_picker_autocomplete_when_arg_started(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
monkeypatch.setattr(prompt_completion, "repl_tty_interactive", lambda: True)
|
||
|
||
completions = list(
|
||
ShellCompleter().get_completions(
|
||
Document("/model s"),
|
||
CompleteEvent(text_inserted=True),
|
||
)
|
||
)
|
||
|
||
assert sorted({c.text for c in completions}) == ["set", "show"]
|
||
|
||
|
||
def test_shell_completer_suggests_effort_levels() -> None:
|
||
completions = list(
|
||
ShellCompleter().get_completions(
|
||
Document("/effort "),
|
||
CompleteEvent(text_inserted=True),
|
||
)
|
||
)
|
||
names = sorted({c.text for c in completions})
|
||
assert names == ["high", "low", "max", "medium", "xhigh"]
|
||
|
||
|
||
def test_tab_applies_unique_slash_command_completion() -> None:
|
||
buff = Buffer(completer=ShellCompleter())
|
||
buff.insert_text("/mod")
|
||
_tab_expand_or_menu(buff)
|
||
assert buff.text == "/model"
|
||
|
||
|
||
def test_tab_with_open_completion_menu_applies_current_item() -> None:
|
||
from prompt_toolkit.buffer import CompletionState
|
||
from prompt_toolkit.completion import Completion
|
||
|
||
buff = Buffer()
|
||
buff.insert_text("/mo")
|
||
orig_doc = buff.document
|
||
c_model = Completion("/model", start_position=-3)
|
||
c_mcp = Completion("/mcp", start_position=-3)
|
||
# Assign directly — updating ``buff.document`` afterward clears ``complete_state``.
|
||
buff.complete_state = CompletionState(orig_doc, [c_model, c_mcp], 0)
|
||
|
||
_tab_expand_or_menu(buff)
|
||
|
||
assert buff.complete_state is None
|
||
assert buff.text == "/model"
|
||
|
||
|
||
def test_tab_with_menu_and_no_index_applies_first_choice() -> None:
|
||
from prompt_toolkit.buffer import CompletionState
|
||
from prompt_toolkit.completion import Completion
|
||
|
||
buff = Buffer()
|
||
buff.insert_text("/mo")
|
||
orig_doc = buff.document
|
||
c_model = Completion("/model", start_position=-3)
|
||
c_mcp = Completion("/mcp", start_position=-3)
|
||
buff.complete_state = CompletionState(orig_doc, [c_model, c_mcp], None)
|
||
|
||
_tab_expand_or_menu(buff)
|
||
|
||
assert buff.complete_state is None
|
||
assert buff.text == "/model"
|
||
|
||
|
||
def test_completion_includes_tab_navigation() -> None:
|
||
key_bindings = _build_prompt_key_bindings()
|
||
keys = {binding.keys for binding in key_bindings.bindings}
|
||
|
||
assert (Keys.ControlM,) in keys
|
||
assert (Keys.Down,) in keys
|
||
assert (Keys.Up,) in keys
|
||
assert (Keys.Tab,) in keys
|
||
assert (Keys.BackTab,) in keys
|
||
|
||
|
||
def test_build_prompt_style_tracks_active_theme() -> None:
|
||
set_active_theme("amber")
|
||
amber_attrs = _build_prompt_style().get_attrs_for_style_str("class:prompt-frame-line")
|
||
set_active_theme("teal")
|
||
teal_attrs = _build_prompt_style().get_attrs_for_style_str("class:prompt-frame-line")
|
||
assert amber_attrs.color and amber_attrs.color.lower() == "f2d48a"
|
||
assert teal_attrs.color and teal_attrs.color.lower() == "8ae2d6"
|
||
assert amber_attrs.color != teal_attrs.color
|
||
|
||
|
||
def test_completion_menu_current_item_uses_highlight_style() -> None:
|
||
from platform.terminal.theme import BG, HIGHLIGHT
|
||
|
||
set_active_theme("green")
|
||
style = _build_prompt_style()
|
||
attrs = style.get_attrs_for_style_str("class:repl-slash-command")
|
||
|
||
assert attrs.color == HIGHLIGHT.lstrip("#")
|
||
assert attrs.bgcolor == BG.lstrip("#")
|
||
assert attrs.bold is True
|
||
|
||
attrs_menu = style.get_attrs_for_style_str("class:completion-menu.completion.current")
|
||
|
||
assert attrs_menu.color == HIGHLIGHT.lstrip("#")
|
||
assert attrs_menu.bgcolor == BG.lstrip("#")
|
||
assert attrs_menu.reverse is False
|
||
assert attrs_menu.bold is True
|
||
|
||
|
||
def test_lazy_rich_style_split_tracks_active_theme() -> None:
|
||
from platform.terminal import theme as ui_theme
|
||
|
||
set_active_theme("green")
|
||
assert bool(ui_theme.DIM) is True
|
||
assert ui_theme.DIM.split() == [ui_theme.get_active_theme().DIM]
|
||
assert ui_theme.BOLD_BRAND.split() == ["bold", ui_theme.get_active_theme().BRAND]
|
||
|
||
set_active_theme("blue")
|
||
assert bool(ui_theme.DIM) is True
|
||
assert ui_theme.DIM.split() == [ui_theme.get_active_theme().DIM]
|
||
assert ui_theme.BOLD_BRAND.split() == ["bold", ui_theme.get_active_theme().BRAND]
|
||
|
||
|
||
def test_lazy_rich_style_parses_as_real_rich_style() -> None:
|
||
from rich.style import Style
|
||
|
||
from platform.terminal import theme as ui_theme
|
||
|
||
# Rich resolves a _LazyRichStyle via its underlying ``str`` value, so the
|
||
# contract at call sites is to pass ``str(...)``. Verify that contract
|
||
# produces a non-null Style across themes.
|
||
set_active_theme("amber")
|
||
assert Style.parse(str(ui_theme.DIM)) != Style.null()
|
||
assert Style.parse(str(ui_theme.BOLD_BRAND)) != Style.null()
|
||
|
||
set_active_theme("blue")
|
||
assert Style.parse(str(ui_theme.DIM)) != Style.null()
|
||
assert Style.parse(str(ui_theme.BOLD_BRAND)) != Style.null()
|
||
|
||
|
||
def test_shell_completer_path_completion_honors_mixed_case_prefix(tmp_path: Path) -> None:
|
||
"""Regression: path fragments must not be lowercased before PathCompleter.
|
||
|
||
On case-sensitive filesystems, a lowered prefix can stop matching real directory
|
||
names (e.g. ``RePoRtS`` no longer matches prefix ``re``).
|
||
"""
|
||
mixed_dir = tmp_path / "RePoRtS"
|
||
mixed_dir.mkdir()
|
||
(mixed_dir / "x.txt").write_text("x", encoding="utf-8")
|
||
partial = str(tmp_path / "Re")
|
||
line = f"/investigate {partial}"
|
||
completions = list(
|
||
ShellCompleter().get_completions(
|
||
Document(line, len(line)),
|
||
CompleteEvent(text_inserted=True),
|
||
)
|
||
)
|
||
assert completions
|
||
joined = " ".join(str(c.display) for c in completions)
|
||
assert "RePoRtS" in joined
|
||
|
||
|
||
def test_shell_completer_investigate_includes_template_hints() -> None:
|
||
completions = list(
|
||
ShellCompleter().get_completions(
|
||
Document("/investigate ", len("/investigate ")),
|
||
CompleteEvent(text_inserted=True),
|
||
)
|
||
)
|
||
assert any(c.text == "generic" for c in completions)
|
||
assert any(c.text == "splunk" for c in completions)
|
||
|
||
|
||
def test_run_text_investigation_uses_background_launcher_when_mode_enabled(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
from rich.console import Console
|
||
|
||
from tools.interactive_shell.actions.investigation import (
|
||
run_text_investigation,
|
||
)
|
||
|
||
launches: list[tuple[str, str]] = []
|
||
|
||
def _fake_start_background_text_investigation(
|
||
*,
|
||
alert_text: str,
|
||
session: Session,
|
||
console: Console,
|
||
display_command: str,
|
||
) -> str:
|
||
_ = (session, console)
|
||
launches.append((alert_text, display_command))
|
||
return "bg123"
|
||
|
||
monkeypatch.setattr(
|
||
"surfaces.interactive_shell.runtime.background.runner.start_background_text_investigation",
|
||
_fake_start_background_text_investigation,
|
||
)
|
||
|
||
session = Session()
|
||
session.terminal.background_mode_enabled = True
|
||
console = Console(file=io.StringIO(), force_terminal=False, highlight=False)
|
||
|
||
run_text_investigation("High CPU alert", session, console)
|
||
|
||
assert launches == [("High CPU alert", "background free-text investigation")]
|
||
assert session.task_registry.list_recent(10) == []
|
||
|
||
|
||
def test_run_initial_input_dispatches_as_non_tty(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
calls: list[dict[str, object]] = []
|
||
|
||
def _fake_handle_message(*args: object, **kwargs: object) -> None:
|
||
_ = args
|
||
calls.append(kwargs)
|
||
|
||
monkeypatch.setattr(
|
||
"surfaces.interactive_shell.runtime.shell_turn_execution.execute_shell_turn",
|
||
_fake_handle_message,
|
||
)
|
||
|
||
assert startup_initial_input.run_initial_input("/remote", Session()) == 0
|
||
assert len(calls) == 1
|
||
assert calls[0]["is_tty"] is False
|
||
|
||
|
||
class TestLooksLikeCorrection:
|
||
"""Unit tests for the ``_looks_like_correction`` heuristic.
|
||
|
||
Pins the v1 catches, false-positive guards, and limitations so future
|
||
iteration on ``_INTERVENTION_CORRECTION_RE`` has a regression baseline.
|
||
"""
|
||
|
||
@pytest.mark.parametrize(
|
||
"text",
|
||
[
|
||
"no, do that instead",
|
||
"nope",
|
||
"nvm",
|
||
"never mind",
|
||
"actually, let's check Datadog first",
|
||
"scratch that, run the synthetic test",
|
||
"wait, wrong dashboard",
|
||
"let's do an EKS health check instead",
|
||
"try a token refresh instead",
|
||
"wrong dashboard, fix it",
|
||
"instead, log a warning",
|
||
"Wait!", # case-insensitive
|
||
"NO.",
|
||
],
|
||
)
|
||
def test_correction_cues_match(self, text: str) -> None:
|
||
assert loop_turn_detection.looks_like_correction(text) is True
|
||
|
||
@pytest.mark.parametrize(
|
||
"text",
|
||
[
|
||
# Punctuation-lookahead guards reject content uses of cue words.
|
||
"stop the server before redeploying",
|
||
"instead of returning null, log a warning",
|
||
"no problem, I'll handle it",
|
||
"wait for the result",
|
||
# v1 limitation: cue must be at start of message.
|
||
"hmm, scratch that",
|
||
"doesn't work, try X instead",
|
||
# Edge cases.
|
||
"",
|
||
" ",
|
||
"```\nstop the server\n```",
|
||
],
|
||
)
|
||
def test_non_correction_text_does_not_match(self, text: str) -> None:
|
||
assert loop_turn_detection.looks_like_correction(text) is False
|
||
|
||
|
||
class TestLooksLikeConfirmationAnswer:
|
||
"""Unit tests for the y/n token recognizer.
|
||
|
||
Type-ahead text submitted while a ``Proceed? [Y/n]`` worker is
|
||
parked used to be silently delivered to the confirmation handler
|
||
and declined the pending action. The recognizer is the
|
||
gate that keeps that from happening — only deliberate y/n tokens
|
||
(and empty Enter, which the upstream ``[Y/n]`` prompt accepts as
|
||
"yes") are treated as answers.
|
||
"""
|
||
|
||
@pytest.mark.parametrize(
|
||
"text",
|
||
[
|
||
"y",
|
||
"Y",
|
||
"yes",
|
||
"YES",
|
||
"n",
|
||
"N",
|
||
"no",
|
||
"No",
|
||
" y ",
|
||
" yes\n",
|
||
"",
|
||
" ",
|
||
None,
|
||
],
|
||
)
|
||
def test_recognised_tokens_match(self, text: str | None) -> None:
|
||
assert loop_turn_detection.looks_like_confirmation_answer(text) is True
|
||
|
||
@pytest.mark.parametrize(
|
||
"text",
|
||
[
|
||
"what is opensre?",
|
||
"yeah do it", # extra words even after "yeah" — ambiguous, not delivered
|
||
"yep",
|
||
"yup",
|
||
"nope",
|
||
"/help",
|
||
"show me logs",
|
||
"y'all should run this",
|
||
],
|
||
)
|
||
def test_unrecognised_text_does_not_match(self, text: str) -> None:
|
||
assert loop_turn_detection.looks_like_confirmation_answer(text) is False
|
||
|
||
@pytest.mark.parametrize(
|
||
"text",
|
||
[
|
||
# Multi-line type-ahead whose first token reads as ``y``/``yes``
|
||
# must NOT be classified as a confirmation. The gate compares the
|
||
# whole stripped/lowered string against the token set, so any
|
||
# trailing words or internal newlines disqualify it.
|
||
"yes please run that against staging instead",
|
||
"yes\nbut do X first",
|
||
"y\nrun it now",
|
||
"no\nactually wait, let me check the logs first",
|
||
"Y but also rotate the keys after",
|
||
# Embedded newlines preserved through ``strip()`` so the join
|
||
# still fails the membership check.
|
||
"yes\nplease",
|
||
],
|
||
)
|
||
def test_multiline_type_ahead_starting_with_y_or_n_does_not_match(self, text: str) -> None:
|
||
"""Regression for the type-ahead-as-confirmation footgun: a pasted
|
||
or typed sentence beginning with ``y``/``yes`` (or ``n``/``no``)
|
||
must be treated as a new turn, not silently delivered as the
|
||
Proceed? answer. ``str.strip()`` only trims outer whitespace, so
|
||
any inner non-whitespace content keeps the lowered string out
|
||
of the token set. (Pure outer-whitespace cases like
|
||
``" yes\\n "`` correctly DO match — that's just ``yes`` with
|
||
stray whitespace, not a multi-line message.)
|
||
"""
|
||
assert loop_turn_detection.looks_like_confirmation_answer(text) is False
|
||
|
||
|
||
class TestLooksLikeCancelRequest:
|
||
"""Unit tests for the bare-cancel slash recognizer.
|
||
|
||
The recognizer is the gate that lets the prompt loop intercept
|
||
``/cancel``-style slashes typed while a dispatch is parked
|
||
(e.g. on a ``Proceed? [y/N]`` confirmation) and send them through
|
||
``state.cancel_current_dispatch()`` instead of queueing them
|
||
behind the dispatch they're trying to interrupt.
|
||
"""
|
||
|
||
@pytest.mark.parametrize(
|
||
"text",
|
||
[
|
||
"/cancel",
|
||
"/CANCEL",
|
||
"/Cancel",
|
||
"/stop",
|
||
"/STOP",
|
||
"/abort",
|
||
" /cancel ",
|
||
"/cancel\n",
|
||
"\t/stop\t",
|
||
],
|
||
)
|
||
def test_recognised_cancel_slashes_match(self, text: str) -> None:
|
||
assert loop_turn_detection.looks_like_cancel_request(text) is True
|
||
|
||
@pytest.mark.parametrize(
|
||
"text",
|
||
[
|
||
# Targeted background-task cancel — must keep flowing
|
||
# through the normal slash dispatch path so the existing
|
||
# ``_cmd_cancel`` handler resolves the task id.
|
||
"/cancel 8f5fe574",
|
||
"/cancel abc123",
|
||
"/stop now",
|
||
# Other slashes — unrelated to interrupt.
|
||
"/help",
|
||
"/tasks",
|
||
# Natural-language uses of the same words must NOT be
|
||
# intercepted; the user might be talking about cancelling
|
||
# a deploy or stopping a service in their environment.
|
||
"cancel this please",
|
||
"cancel",
|
||
"stop the deploy",
|
||
"stop",
|
||
"abort",
|
||
# Empty / whitespace / None — nothing to intercept.
|
||
"",
|
||
" ",
|
||
None,
|
||
],
|
||
)
|
||
def test_unrecognised_text_does_not_match(self, text: str | None) -> None:
|
||
assert loop_turn_detection.looks_like_cancel_request(text) is False
|
||
|
||
|
||
# ── Spinner state tests ──────────────────────────────────────────────────────
|
||
|
||
|
||
def _strip_ansi(text: str) -> str:
|
||
return re.sub(r"\x1b\[[0-9;?]*[A-Za-z]", "", text)
|
||
|
||
|
||
class TestSpinnerState:
|
||
"""``_SpinnerState`` holds the live-stream indicator state and renders
|
||
two ANSI views: the inline spinner above the input frame
|
||
(``inline_spinner_ansi``) and the bottom toolbar hint
|
||
(``toolbar_ansi``).
|
||
"""
|
||
|
||
def test_idle_state_emits_no_inline_spinner(self) -> None:
|
||
spinner = loop_state.SpinnerState()
|
||
assert spinner.streaming is False
|
||
assert spinner.inline_spinner_ansi() == ""
|
||
|
||
def test_inline_spinner_uses_active_theme_highlight(self) -> None:
|
||
from platform.terminal.theme import set_active_theme
|
||
|
||
set_active_theme("blue")
|
||
spinner = loop_state.SpinnerState()
|
||
spinner.start()
|
||
raw = spinner.inline_spinner_ansi()
|
||
assert "168;212;255" in raw
|
||
assert "185;237;175" not in raw
|
||
|
||
def test_streaming_inline_spinner_includes_glyph_and_token_count(self) -> None:
|
||
spinner = loop_state.SpinnerState()
|
||
spinner.start()
|
||
spinner.bytes_in = 1234 * _CHARS_PER_TOKEN # = 1234 tokens
|
||
rendered = _strip_ansi(spinner.inline_spinner_ansi())
|
||
# The verb is randomly picked from ``_THINKING_VERBS`` per turn —
|
||
# any of them followed by ``…`` is acceptable.
|
||
assert any(f"{verb}…" in rendered for verb in spinner._THINKING_VERBS)
|
||
# 1234 tokens → "1.2k" via format_token_count_short.
|
||
assert "1.2k tokens" in rendered
|
||
# Spinner glyph from the brail palette.
|
||
assert any(g in rendered for g in spinner._SPINNER_FRAMES)
|
||
|
||
def test_streaming_inline_spinner_hides_token_count_when_zero(self) -> None:
|
||
"""When no tokens have been received yet, the spinner should not
|
||
display ``↓ 0 tokens`` — the count is meaningless until streaming
|
||
starts. Only the elapsed time is shown in the parenthetical.
|
||
"""
|
||
spinner = loop_state.SpinnerState()
|
||
spinner.start()
|
||
# bytes_in defaults to 0 → token_count = 0
|
||
rendered = _strip_ansi(spinner.inline_spinner_ansi())
|
||
assert "tokens" not in rendered, (
|
||
"expected no token count in spinner before any tokens arrive"
|
||
)
|
||
assert "0" not in rendered or "0s" in rendered or "0." in rendered, (
|
||
"zero-token count leaked into spinner display"
|
||
)
|
||
|
||
def test_streaming_inline_spinner_shows_token_count_once_tokens_arrive(self) -> None:
|
||
"""Once bytes arrive, the token count reappears in the spinner."""
|
||
spinner = loop_state.SpinnerState()
|
||
spinner.start()
|
||
spinner.bytes_in = 100 * _CHARS_PER_TOKEN # = 100 tokens
|
||
rendered = _strip_ansi(spinner.inline_spinner_ansi())
|
||
assert "tokens" in rendered
|
||
|
||
def test_streaming_inline_spinner_verb_stays_constant_across_calls(self) -> None:
|
||
"""A turn's verb is fixed at ``start()`` so the indicator
|
||
doesn't flicker between words mid-stream."""
|
||
spinner = loop_state.SpinnerState()
|
||
spinner.start()
|
||
verbs_seen: set[str] = set()
|
||
for _ in range(20):
|
||
rendered = _strip_ansi(spinner.inline_spinner_ansi())
|
||
for verb in spinner._THINKING_VERBS:
|
||
if f"{verb}…" in rendered:
|
||
verbs_seen.add(verb)
|
||
break
|
||
assert len(verbs_seen) == 1, f"verb changed mid-turn — saw {verbs_seen}"
|
||
|
||
def test_inline_spinner_glyph_animates_with_elapsed_time(self) -> None:
|
||
"""The frame is a function of elapsed time, not of render-call count.
|
||
|
||
prompt_toolkit evaluates the prompt message several times per render
|
||
pass, so a per-call counter freezes the on-screen glyph (it advances a
|
||
whole number of cycles between visible renders). Repeated calls at one
|
||
instant must render one frame; advancing the clock must animate.
|
||
"""
|
||
spinner = loop_state.SpinnerState()
|
||
spinner.start()
|
||
same_instant = {
|
||
_extract_glyph(spinner.inline_spinner_ansi(), spinner._SPINNER_FRAMES)
|
||
for _ in range(len(spinner._SPINNER_FRAMES) * 2)
|
||
}
|
||
assert len(same_instant) == 1
|
||
|
||
seen = set()
|
||
for step in range(len(spinner._SPINNER_FRAMES)):
|
||
spinner.started_at = time.monotonic() - step * spinner._FRAME_INTERVAL_S * 1.001
|
||
seen.add(_extract_glyph(spinner.inline_spinner_ansi(), spinner._SPINNER_FRAMES))
|
||
assert seen == set(spinner._SPINNER_FRAMES)
|
||
|
||
def test_stop_returns_to_idle_state(self) -> None:
|
||
spinner = loop_state.SpinnerState()
|
||
spinner.start()
|
||
assert spinner.streaming is True
|
||
spinner.stop()
|
||
assert spinner.streaming is False
|
||
assert spinner.inline_spinner_ansi() == ""
|
||
|
||
def test_toolbar_always_returns_empty_string(self) -> None:
|
||
"""toolbar_ansi() must always return '' in both idle and streaming states.
|
||
|
||
A visible toolbar causes prompt_toolkit to emit \\033[6n (CPR)
|
||
cursor-position queries on every refresh_interval; those responses
|
||
corrupt the vt100 input parser with stray keystrokes. Hiding the
|
||
toolbar unconditionally also keeps its height at zero in *both* states,
|
||
which prevents the one-row height delta that causes prompt_toolkit to
|
||
misplace the cursor and leave stale spinner lines on screen. Idle hints
|
||
are surfaced through idle_hint_ansi() rendered in the prompt message's
|
||
reserved first line instead.
|
||
"""
|
||
spinner = loop_state.SpinnerState()
|
||
assert spinner.toolbar_ansi() == ""
|
||
|
||
spinner.start()
|
||
assert spinner.toolbar_ansi() == ""
|
||
|
||
def test_idle_hint_lists_shortcut_keys_when_buffer_empty(self) -> None:
|
||
"""When idle and the input buffer is empty (no prompt-toolkit app
|
||
running in this test → ``get_app_or_none()`` returns None →
|
||
treated as empty), the idle hint advertises the always-useful keys
|
||
but hides ``esc to clear`` since Esc is a no-op on empty buffer.
|
||
"""
|
||
spinner = loop_state.SpinnerState()
|
||
rendered = _strip_ansi(spinner.idle_hint_ansi())
|
||
assert "/ for commands" in rendered
|
||
assert "history" in rendered
|
||
# Hidden — buffer is empty, Esc would be a no-op, so the hint
|
||
# would mislead the user.
|
||
assert "esc to clear" not in rendered
|
||
|
||
def test_idle_hint_includes_esc_to_clear_when_buffer_has_text(
|
||
self, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
"""When idle and the input buffer has text, the idle hint appends
|
||
``esc to clear`` so the user knows the shortcut exists.
|
||
``get_app_or_none`` is monkeypatched to return a fake app whose
|
||
``current_buffer.text`` is non-empty.
|
||
"""
|
||
|
||
class _FakeBuffer:
|
||
text = "partially typed"
|
||
|
||
class _FakeApp:
|
||
current_buffer = _FakeBuffer()
|
||
|
||
monkeypatch.setattr(loop_state, "get_app_or_none", lambda: _FakeApp())
|
||
|
||
spinner = loop_state.SpinnerState()
|
||
rendered = _strip_ansi(spinner.idle_hint_ansi())
|
||
assert "esc to clear" in rendered
|
||
assert "/ for commands" in rendered
|
||
|
||
def test_inline_spinner_contains_esc_to_cancel_when_streaming(self) -> None:
|
||
"""During streaming the inline spinner (shown in the prompt's first
|
||
reserved line) carries the ``esc to cancel`` hint so the user can
|
||
interrupt the dispatch.
|
||
"""
|
||
spinner = loop_state.SpinnerState()
|
||
spinner.start()
|
||
rendered = _strip_ansi(spinner.inline_spinner_ansi())
|
||
assert "esc to cancel" in rendered
|
||
# Idle hint text should NOT appear in the spinner row.
|
||
assert "/ for commands" not in rendered
|
||
|
||
def test_prompt_prefix_is_consistent_height(self) -> None:
|
||
"""The first line of the prompt message is always exactly one row —
|
||
it shows the idle hint when not streaming and the spinner when
|
||
streaming. Using ``idle_hint_ansi()`` or ``inline_spinner_ansi()``
|
||
as the prefix means height never changes between states, so
|
||
prompt_toolkit's cursor management stays accurate and stale spinner
|
||
lines do not accumulate on screen.
|
||
"""
|
||
spinner = loop_state.SpinnerState()
|
||
|
||
idle_prefix = _strip_ansi(spinner.idle_hint_ansi())
|
||
assert "\n" not in idle_prefix, f"idle prefix must be 1 row, got: {idle_prefix!r}"
|
||
|
||
spinner.start()
|
||
streaming_prefix = _strip_ansi(spinner.inline_spinner_ansi())
|
||
assert "\n" not in streaming_prefix, (
|
||
f"streaming prefix must be 1 row, got: {streaming_prefix!r}"
|
||
)
|
||
|
||
|
||
def _extract_glyph(ansi_text: str, frames: tuple[str, ...]) -> str:
|
||
plain = _strip_ansi(ansi_text)
|
||
for g in frames:
|
||
if g in plain:
|
||
return g
|
||
return ""
|
||
|
||
|
||
# ── Streaming-console adapter tests ──────────────────────────────────────────
|
||
|
||
|
||
class TestStreamingConsole:
|
||
"""``_StreamingConsole`` is the bridge between the streaming layer and
|
||
the prompt-toolkit spinner. It is the only way the dispatch worker
|
||
thread can signal back to the prompt: progress updates and
|
||
cancellation polling go through this object's optional methods.
|
||
"""
|
||
|
||
def test_update_progress_writes_to_spinner_state(self) -> None:
|
||
import threading as _threading
|
||
|
||
spinner = loop_state.SpinnerState()
|
||
spinner.start()
|
||
cancel = _threading.Event()
|
||
console = StreamingConsole(
|
||
spinner,
|
||
cancel,
|
||
highlight=False,
|
||
force_terminal=True,
|
||
color_system=None,
|
||
)
|
||
console.update_streaming_progress(4096)
|
||
assert spinner.bytes_in == 4096
|
||
|
||
def test_cancel_requested_reflects_event_state(self) -> None:
|
||
import threading as _threading
|
||
|
||
spinner = loop_state.SpinnerState()
|
||
cancel = _threading.Event()
|
||
console = StreamingConsole(
|
||
spinner,
|
||
cancel,
|
||
highlight=False,
|
||
force_terminal=True,
|
||
color_system=None,
|
||
)
|
||
assert console.cancel_requested is False
|
||
cancel.set()
|
||
assert console.cancel_requested is True
|
||
cancel.clear()
|
||
assert console.cancel_requested is False
|
||
|
||
def test_blank_print_resets_column_without_preparing_new_line(
|
||
self,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
import threading as _threading
|
||
|
||
spinner = loop_state.SpinnerState()
|
||
console = StreamingConsole(
|
||
spinner,
|
||
_threading.Event(),
|
||
file=io.StringIO(),
|
||
force_terminal=False,
|
||
width=80,
|
||
)
|
||
|
||
calls: list[str] = []
|
||
monkeypatch.setattr(
|
||
"surfaces.interactive_shell.ui.components.choice_menu.ensure_tty_column_zero",
|
||
lambda: calls.append("ensure"),
|
||
)
|
||
monkeypatch.setattr(
|
||
"surfaces.interactive_shell.ui.components.choice_menu.prepare_repl_output_line",
|
||
lambda: calls.append("prepare"),
|
||
)
|
||
|
||
console.print()
|
||
assert calls == ["ensure"]
|
||
|
||
|
||
# ── ReplState dataclass tests ────────────────────────────────────────────────
|
||
|
||
|
||
class TestReplState:
|
||
"""``_ReplState`` is the single owner of the cancellation primitives
|
||
shared between the prompt loop, the queue processor, and the
|
||
Esc/Ctrl+L key bindings. Its methods exist so callers don't poke
|
||
raw fields and re-derive ``is_running`` everywhere.
|
||
"""
|
||
|
||
def test_default_state_is_idle(self) -> None:
|
||
state = loop_state.ReplState()
|
||
assert state.is_dispatch_running() is False
|
||
assert state.exit_requested is False
|
||
# No active dispatch → no cancel event parked.
|
||
assert state.current_cancel_event is None
|
||
assert state.queue.empty()
|
||
assert state.phase is loop_state.TurnPhase.IDLE
|
||
|
||
def test_phase_transitions_dispatch_to_idle(self) -> None:
|
||
async def _scenario() -> None:
|
||
state = loop_state.ReplState()
|
||
|
||
async def _noop() -> None:
|
||
return None
|
||
|
||
task = asyncio.create_task(_noop())
|
||
cancel = threading.Event()
|
||
state.start_dispatch(task=task, cancel_event=cancel)
|
||
assert state.is_dispatch_running() is True
|
||
assert await task is None
|
||
state.finish_dispatch(cancel)
|
||
assert state.phase is loop_state.TurnPhase.IDLE
|
||
state.clear_current_task()
|
||
assert state.phase is loop_state.TurnPhase.IDLE
|
||
|
||
asyncio.run(_scenario())
|
||
|
||
def test_confirmation_phase_round_trips_to_dispatching(self) -> None:
|
||
async def _scenario() -> None:
|
||
state = loop_state.ReplState()
|
||
|
||
async def _waits() -> None:
|
||
await asyncio.sleep(1.0)
|
||
|
||
task = asyncio.create_task(_waits())
|
||
state.start_dispatch(task=task, cancel_event=threading.Event())
|
||
state.begin_confirmation(threading.Event(), "Proceed? ")
|
||
assert state.is_awaiting_confirmation() is True
|
||
assert state.phase is loop_state.TurnPhase.AWAITING_CONFIRMATION
|
||
# A normal confirmation completion returns to dispatching (task alive).
|
||
state.clear_confirmation()
|
||
assert state.is_awaiting_confirmation() is False
|
||
assert state.phase is loop_state.TurnPhase.DISPATCHING
|
||
task.cancel()
|
||
|
||
asyncio.run(_scenario())
|
||
|
||
def test_clear_confirmation_returns_to_idle_without_active_task(self) -> None:
|
||
state = loop_state.ReplState()
|
||
state.begin_confirmation(threading.Event(), "Proceed? ")
|
||
assert state.phase is loop_state.TurnPhase.AWAITING_CONFIRMATION
|
||
state.clear_confirmation()
|
||
assert state.phase is loop_state.TurnPhase.IDLE
|
||
|
||
def test_cancel_sets_cancelling_phase(self) -> None:
|
||
state = loop_state.ReplState()
|
||
state.current_cancel_event = threading.Event()
|
||
state.cancel_current_dispatch()
|
||
assert state.phase is loop_state.TurnPhase.CANCELLING
|
||
|
||
def test_cancel_during_confirmation_keeps_cancelling_phase(self) -> None:
|
||
state = loop_state.ReplState()
|
||
state.current_cancel_event = threading.Event()
|
||
state.begin_confirmation(threading.Event(), "Proceed? ")
|
||
state.cancel_current_dispatch()
|
||
assert state.phase is loop_state.TurnPhase.CANCELLING
|
||
# The confirmation cleanup must NOT downgrade an in-progress cancel.
|
||
state.clear_confirmation()
|
||
assert state.phase is loop_state.TurnPhase.CANCELLING
|
||
|
||
def test_idle_cancel_does_not_set_cancelling_phase(self) -> None:
|
||
state = loop_state.ReplState()
|
||
state.cancel_current_dispatch()
|
||
assert state.phase is loop_state.TurnPhase.IDLE
|
||
|
||
def test_is_dispatch_running_tracks_task_lifecycle(self) -> None:
|
||
async def _scenario() -> None:
|
||
state = loop_state.ReplState()
|
||
|
||
async def _slow() -> None:
|
||
await asyncio.sleep(0.05)
|
||
|
||
state.current_task = asyncio.create_task(_slow())
|
||
assert state.is_dispatch_running() is True
|
||
await state.current_task
|
||
assert state.is_dispatch_running() is False
|
||
|
||
asyncio.run(_scenario())
|
||
|
||
def test_cancel_current_dispatch_signals_event_and_task(self) -> None:
|
||
async def _scenario() -> None:
|
||
import threading as _threading
|
||
|
||
state = loop_state.ReplState()
|
||
dispatch_cancel = _threading.Event()
|
||
state.current_cancel_event = dispatch_cancel
|
||
|
||
async def _waits_forever() -> None:
|
||
# Long sleep — only the cancel can interrupt this.
|
||
await asyncio.sleep(1.0)
|
||
|
||
state.current_task = asyncio.create_task(_waits_forever())
|
||
# Snapshot to a local — re-reading ``state.current_task``
|
||
# across the cancel + await would let CodeQL / code-quality
|
||
# bots flag the bare ``await`` as ineffectual and would also
|
||
# leave the assertion racey if anything reassigned the field.
|
||
task = state.current_task
|
||
state.cancel_current_dispatch()
|
||
|
||
# Both signals must fire — per-dispatch event flipped AND
|
||
# the asyncio task cancelled.
|
||
assert dispatch_cancel.is_set() is True
|
||
try: # noqa: SIM105
|
||
await task
|
||
except asyncio.CancelledError:
|
||
# Expected — that's the whole point of the cancel.
|
||
pass
|
||
assert task.cancelled() is True
|
||
|
||
asyncio.run(_scenario())
|
||
|
||
def test_cancel_from_worker_thread_uses_call_soon_threadsafe(self) -> None:
|
||
"""``Task.cancel`` is not thread-safe; ``cancel_current_dispatch``
|
||
must send the cancel via ``loop.call_soon_threadsafe`` when
|
||
invoked from a worker thread (the ``/exit`` slash handler runs
|
||
in ``asyncio.to_thread`` and reaches us through
|
||
:func:`_request_exit`).
|
||
"""
|
||
|
||
async def _scenario() -> None:
|
||
import threading as _threading
|
||
|
||
state = loop_state.ReplState()
|
||
state.loop = asyncio.get_running_loop()
|
||
|
||
async def _waits_forever() -> None:
|
||
await asyncio.sleep(10.0)
|
||
|
||
state.current_task = asyncio.create_task(_waits_forever())
|
||
task = state.current_task
|
||
|
||
worker_done = _threading.Event()
|
||
|
||
def _cancel_in_worker() -> None:
|
||
state.cancel_current_dispatch()
|
||
worker_done.set()
|
||
|
||
worker = _threading.Thread(target=_cancel_in_worker)
|
||
worker.start()
|
||
worker.join(timeout=1.0)
|
||
assert worker_done.is_set(), "worker thread did not return"
|
||
|
||
try: # noqa: SIM105
|
||
await task
|
||
except asyncio.CancelledError:
|
||
# Expected: the worker-triggered cancellation surfaces
|
||
# here once the scheduled ``task.cancel`` callback runs
|
||
# on the loop. Swallow so the assertion below can verify
|
||
# cancellation state without the exception unwinding the
|
||
# test.
|
||
pass
|
||
assert task.cancelled() is True
|
||
|
||
asyncio.run(_scenario())
|
||
|
||
def test_cancel_when_no_task_is_a_no_op(self) -> None:
|
||
"""``cancel_current_dispatch`` is idempotent — safe to call when
|
||
nothing is running. With no active dispatch parked,
|
||
``current_cancel_event`` is ``None`` and there's nothing to flip."""
|
||
state = loop_state.ReplState()
|
||
state.cancel_current_dispatch()
|
||
assert state.is_dispatch_running() is False
|
||
assert state.current_cancel_event is None
|
||
|
||
def test_per_dispatch_cancel_events_are_isolated(self) -> None:
|
||
"""Regression guard for the shared-event race that used to let
|
||
a previous turn's worker-thread observation get clobbered by
|
||
the next turn's ``Event.clear()``.
|
||
|
||
The fix: each dispatch processor turn allocates a fresh
|
||
``threading.Event`` and parks it at ``state.current_cancel_event``.
|
||
The previous turn's worker keeps a strong reference to its OWN
|
||
event; a new turn replacing the parked one never resets the
|
||
old worker's signal.
|
||
"""
|
||
import threading as _threading
|
||
|
||
state = loop_state.ReplState()
|
||
|
||
# Turn 1: park its event, fire cancel — that event is now set.
|
||
old_event = _threading.Event()
|
||
state.current_cancel_event = old_event
|
||
state.cancel_current_dispatch()
|
||
assert old_event.is_set() is True
|
||
|
||
# Turn 2 starts: a fresh event is parked. The OLD event must
|
||
# still be set (its worker is still polling it from the prior
|
||
# turn); the new event must not be — turn 2 hasn't been
|
||
# cancelled yet.
|
||
new_event = _threading.Event()
|
||
state.current_cancel_event = new_event
|
||
assert old_event.is_set() is True, (
|
||
"old turn's event must not be cleared by a new turn parking"
|
||
)
|
||
assert new_event.is_set() is False
|
||
|
||
# Cancelling the new turn flips ONLY the new event.
|
||
state.cancel_current_dispatch()
|
||
assert new_event.is_set() is True
|
||
# Old event still set independently.
|
||
assert old_event.is_set() is True
|
||
|
||
|
||
# ── Cancel key bindings ──────────────────────────────────────────────────────
|
||
|
||
|
||
class TestBuildCancelKeyBindings:
|
||
"""``_build_cancel_key_bindings`` returns prompt-level control
|
||
handlers. The handlers are extracted out of the
|
||
prompt loop so they can be exercised without the full async
|
||
machinery; this test instantiates the bindings and verifies they
|
||
were registered for the right keys."""
|
||
|
||
def test_returns_bindings_for_escape_and_ctrl_l(self) -> None:
|
||
state = loop_state.ReplState()
|
||
kb = build_cancel_key_bindings(state)
|
||
# Flatten each binding's keys tuple. ``Keys`` enum members have
|
||
# ``.value`` strings like ``"escape"``/``"c-l"`` matching the
|
||
# decorator argument; plain string keys are themselves.
|
||
registered = {getattr(k, "value", k) for b in kb.bindings for k in b.keys}
|
||
assert "escape" in registered, f"escape binding missing — registered: {registered}"
|
||
assert "c-l" in registered, f"Ctrl+L binding missing — registered: {registered}"
|
||
|
||
|
||
# ── Prompt confirmation bridge (worker-thread handoff) ──────────────────────────────
|
||
|
||
|
||
class TestRequestConfirmationViaPrompt:
|
||
"""``request_confirmation_via_prompt`` runs on the worker thread that
|
||
runs a queued turn. It parks on a ``threading.Event`` while the main
|
||
asyncio loop collects the next ``prompt_async`` return and hands the
|
||
text back via ``state.deliver_confirmation``. ``Esc`` (or any other
|
||
cancel path) flips ``state.current_cancel_event`` and the polling
|
||
loop raises ``DispatchCancelled`` within one prompt refresh tick.
|
||
|
||
These tests pin both paths so a stuck event can never leave a worker
|
||
parked forever. Each runs the function in a real background thread
|
||
and asserts it returns within a short timeout.
|
||
"""
|
||
|
||
# Generous join timeout — one poll tick is
|
||
# ``loop_state.PROMPT_REFRESH_INTERVAL_S`` (~100ms); every return path
|
||
# must complete well inside this, even on slow CI hardware. A
|
||
# regression that leaves the worker parked surfaces as a test
|
||
# failure (``t.is_alive()``) rather than a hang.
|
||
_JOIN_TIMEOUT_S = 2.0
|
||
# How long ``_wait_until_parked`` polls for the worker thread to
|
||
# reach the parked state. Worker parks in microseconds (a few
|
||
# Python statements after thread start), so 1s is a wide safety
|
||
# margin while still failing fast if the parking never happens.
|
||
# Must be < ``_JOIN_TIMEOUT_S`` so the parking check fails before
|
||
# the join would.
|
||
_PARK_TIMEOUT_S = 1.0
|
||
# Spin granularity for the parking poll. 5ms is fine-grained enough
|
||
# that the test sees the parked state within one or two ticks of it
|
||
# happening (~10ms upper bound on added test latency), while
|
||
# avoiding a tight loop that hogs the GIL from the worker thread
|
||
# we're waiting on.
|
||
_PARK_POLL_INTERVAL_S = 0.005
|
||
|
||
def _wait_until_parked(self, state: loop_state.ReplState) -> None:
|
||
"""Spin until the worker has assigned ``state.confirm_event``.
|
||
|
||
The function does this before its first ``response_event.wait``,
|
||
so once we see it, the worker is definitively in the poll loop
|
||
and ready to receive a delivery or cancel signal.
|
||
"""
|
||
deadline = time.monotonic() + self._PARK_TIMEOUT_S
|
||
while time.monotonic() < deadline:
|
||
if state.is_awaiting_confirmation():
|
||
return
|
||
time.sleep(self._PARK_POLL_INTERVAL_S)
|
||
raise AssertionError("worker never parked on confirm_event")
|
||
|
||
def _run_in_thread(
|
||
self, state: loop_state.ReplState, prompt_text: str
|
||
) -> tuple[threading.Thread, list[str], list[Exception]]:
|
||
"""Run the worker in a background thread and capture both its
|
||
return value (``result``) and any raised exception (``exc``).
|
||
|
||
Cancellation now raises :class:`controller_runtime.DispatchCancelled` instead
|
||
of returning ``""``, so tests need access to both channels —
|
||
the happy path checks ``result``, the cancel paths check
|
||
``exc``.
|
||
"""
|
||
result: list[str] = []
|
||
exc: list[Exception] = []
|
||
|
||
def target() -> None:
|
||
try:
|
||
result.append(
|
||
controller_runtime.request_confirmation_via_prompt(state, prompt_text)
|
||
)
|
||
except Exception as e:
|
||
exc.append(e)
|
||
|
||
t = threading.Thread(target=target, daemon=True)
|
||
t.start()
|
||
return t, result, exc
|
||
|
||
def test_returns_delivered_response_and_clears_state(self) -> None:
|
||
"""Happy path: main loop calls ``state.deliver_confirmation('y')``
|
||
→ worker wakes from poll → returns ``"y"``; ``confirm_event`` and
|
||
``confirm_response`` are cleared so the next confirmation starts
|
||
from a fresh slate.
|
||
"""
|
||
state = loop_state.ReplState()
|
||
# Active dispatch must have a cancel event parked; in production
|
||
# ``interactive_shell.runtime.turn_host.run_agent_turn`` allocates this
|
||
# before invoking the confirm_fn. Never set in this test.
|
||
state.current_cancel_event = threading.Event()
|
||
|
||
t, result, exc = self._run_in_thread(state, "Proceed? [y/N] ")
|
||
self._wait_until_parked(state)
|
||
|
||
# Prompt text is stored in state so prompt-toolkit owns the render.
|
||
assert state.confirm_prompt_text == "Proceed? [y/N] "
|
||
|
||
state.deliver_confirmation("y")
|
||
t.join(timeout=self._JOIN_TIMEOUT_S)
|
||
|
||
assert not t.is_alive(), "worker did not return within timeout"
|
||
assert exc == [], f"happy path raised unexpectedly: {exc}"
|
||
assert result == ["y"]
|
||
# Finally-block invariant: state cleared for the next prompt.
|
||
assert state.confirm_event is None
|
||
assert state.confirm_response == []
|
||
assert state.confirm_prompt_text == ""
|
||
|
||
def test_raises_dispatch_cancelled_when_cancel_event_fires(self) -> None:
|
||
"""Esc / ``/cancel`` **user-facing** path: goes through
|
||
``state.cancel_current_dispatch()``, which sets BOTH
|
||
``current_cancel_event`` AND ``confirm_event``. The worker
|
||
wakes from ``response_event.wait`` because ``confirm_event``
|
||
is the same object as ``response_event``; the polling loop
|
||
exits via the natural ``while not response_event.is_set()``
|
||
condition.
|
||
|
||
With ``confirm_response`` never populated, the function MUST
|
||
raise :class:`controller_runtime.DispatchCancelled` rather than returning
|
||
the empty string. Returning ``""`` would be silently confirmed
|
||
by ``execution_policy`` because ``[Y/n]`` treats empty as YES,
|
||
so the in-flight action would run despite the user cancelling.
|
||
Raising propagates out of ``execution_allowed`` and the
|
||
surrounding action loop instead, matching what the user
|
||
expects from ``Esc``.
|
||
"""
|
||
state = loop_state.ReplState()
|
||
state.current_cancel_event = threading.Event()
|
||
|
||
t, result, exc = self._run_in_thread(state, "Proceed? [y/N] ")
|
||
self._wait_until_parked(state)
|
||
|
||
state.cancel_current_dispatch()
|
||
t.join(timeout=self._JOIN_TIMEOUT_S)
|
||
|
||
assert not t.is_alive(), "worker did not return within timeout"
|
||
assert result == [], f"cancel path returned a value: {result}"
|
||
assert len(exc) == 1, f"expected exactly one exception, got {exc}"
|
||
assert isinstance(exc[0], controller_runtime.DispatchCancelled), (
|
||
f"expected DispatchCancelled, got {type(exc[0]).__name__}: {exc[0]}"
|
||
)
|
||
assert state.confirm_event is None
|
||
assert state.confirm_response == []
|
||
|
||
def test_raises_dispatch_cancelled_when_cancel_already_set_before_park(self) -> None:
|
||
"""Race-safety: if the user pressed Esc *before* the worker
|
||
reaches the poll loop, the first iteration must observe
|
||
``current_cancel_event.is_set()`` and raise immediately rather
|
||
than waiting forever for a confirmation that won't come.
|
||
|
||
Isolates the **in-loop cancel-check** on the FIRST iteration:
|
||
``current_cancel_event`` is pre-set; ``confirm_event`` is NOT
|
||
set; the worker enters the loop, reaches the cancel-check
|
||
BEFORE the first ``response_event.wait``, and raises
|
||
:class:`controller_runtime.DispatchCancelled`. Deleting the cancel-check
|
||
would make this test hang to ``_JOIN_TIMEOUT_S`` and fail.
|
||
"""
|
||
state = loop_state.ReplState()
|
||
cancel = threading.Event()
|
||
cancel.set() # already cancelled
|
||
state.current_cancel_event = cancel
|
||
|
||
t, result, exc = self._run_in_thread(state, "Proceed? ")
|
||
t.join(timeout=self._JOIN_TIMEOUT_S)
|
||
|
||
assert not t.is_alive(), "pre-set cancel did not unblock worker"
|
||
assert result == []
|
||
assert len(exc) == 1
|
||
assert isinstance(exc[0], controller_runtime.DispatchCancelled)
|
||
assert state.confirm_event is None
|
||
|
||
def test_in_loop_cancel_check_raises_after_wait_timeout(self) -> None:
|
||
"""In-loop cancel-check on a SUBSEQUENT iteration: the worker
|
||
is already parked in ``response_event.wait(timeout=0.1s)`` when
|
||
cancel fires. The wait times out (because we don't touch
|
||
``confirm_event``), the next iteration's cancel-check sees
|
||
``current_cancel_event.is_set()`` and raises
|
||
:class:`controller_runtime.DispatchCancelled`.
|
||
|
||
Why this is needed: the user-facing test
|
||
(``test_raises_dispatch_cancelled_when_cancel_event_fires``)
|
||
calls ``state.cancel_current_dispatch()``, which sets BOTH the
|
||
cancel and confirm events — the worker exits via the
|
||
confirm_event-set path (the post-wait empty-``confirm_response``
|
||
check), NOT via the cancel-check. Deleting the cancel-check
|
||
would still pass that test. This test sets ONLY the cancel
|
||
event so the only way out is through the check.
|
||
"""
|
||
state = loop_state.ReplState()
|
||
cancel = threading.Event()
|
||
state.current_cancel_event = cancel
|
||
|
||
t, result, exc = self._run_in_thread(state, "Proceed? ")
|
||
self._wait_until_parked(state)
|
||
|
||
# Set cancel directly — do NOT set confirm_event. The worker
|
||
# is inside ``response_event.wait(timeout=0.1s)``; that wait
|
||
# will time out, the next iteration's cancel-check then fires.
|
||
cancel.set()
|
||
t.join(timeout=self._JOIN_TIMEOUT_S)
|
||
|
||
assert not t.is_alive(), (
|
||
"in-loop cancel-check did not return within timeout — "
|
||
"the function ignored a cancel signal that arrived mid-wait"
|
||
)
|
||
assert result == []
|
||
assert len(exc) == 1
|
||
assert isinstance(exc[0], controller_runtime.DispatchCancelled)
|
||
assert state.confirm_event is None
|
||
|
||
def test_confirm_response_reset_before_confirm_event_published(
|
||
self, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
"""Race-safety: the response list MUST be reset before
|
||
``confirm_event`` is published. Otherwise a concurrent
|
||
``deliver_confirmation`` (running between publish and reset)
|
||
appends to the current list, the next statement rebinds
|
||
``confirm_response`` to ``[]``, and the user's answer is
|
||
silently dropped.
|
||
|
||
``deliver_confirmation`` early-exits when ``confirm_event is
|
||
None``, so resetting the list first is invisible to the main
|
||
thread; only the event publish makes the parking observable.
|
||
This test instruments ``__setattr__`` to verify the ordering
|
||
deterministically — timing-based tests can't reliably hit the
|
||
sub-microsecond race window even when the bug is present.
|
||
"""
|
||
state = loop_state.ReplState()
|
||
state.current_cancel_event = threading.Event()
|
||
|
||
# Track every ``confirm_event`` / ``confirm_response`` write
|
||
# made by ``request_confirmation_via_prompt``. Monkeypatching
|
||
# AFTER state construction so the dataclass ``__init__`` field
|
||
# writes don't pollute the recorded order.
|
||
assignments: list[str] = []
|
||
real_setattr = loop_state.ReplState.__setattr__
|
||
|
||
def tracking_setattr(obj: object, name: str, value: object) -> None:
|
||
if name in ("confirm_event", "confirm_response"):
|
||
assignments.append(name)
|
||
real_setattr(obj, name, value) # type: ignore[arg-type]
|
||
|
||
monkeypatch.setattr(loop_state.ReplState, "__setattr__", tracking_setattr)
|
||
|
||
t, result, exc = self._run_in_thread(state, "Proceed? ")
|
||
self._wait_until_parked(state)
|
||
|
||
state.deliver_confirmation("answer")
|
||
t.join(timeout=self._JOIN_TIMEOUT_S)
|
||
|
||
assert exc == [], f"happy path raised unexpectedly: {exc}"
|
||
assert result == ["answer"]
|
||
|
||
# During setup ``request_confirmation_via_prompt`` writes both
|
||
# attributes. The first write of each is the setup phase
|
||
# (later writes are the ``finally`` cleanup which clears
|
||
# both — order there doesn't matter). Pull out just the
|
||
# setup-phase assignment order.
|
||
setup_order: list[str] = []
|
||
for name in assignments:
|
||
setup_order.append(name)
|
||
if name == "confirm_event":
|
||
break # confirm_event publish is the last setup write
|
||
assert "confirm_response" in setup_order, (
|
||
f"confirm_response never reset during setup — saw {setup_order}"
|
||
)
|
||
response_idx = setup_order.index("confirm_response")
|
||
event_idx = setup_order.index("confirm_event")
|
||
assert response_idx < event_idx, (
|
||
f"race window: confirm_event published before "
|
||
f"confirm_response was reset — order was {setup_order}"
|
||
)
|
||
|
||
def test_empty_string_delivery_returns_empty_string(self) -> None:
|
||
"""User presses Enter on the confirmation prompt without typing
|
||
anything → ``state.deliver_confirmation("")`` is called →
|
||
function returns ``""``. Real production case: ``[Y/n]`` prompts
|
||
treat plain Enter as "yes" (capital Y default).
|
||
|
||
This is the ONLY path that legitimately yields an empty-string
|
||
return: ``deliver_confirmation`` populated ``confirm_response``
|
||
with ``""`` BEFORE setting the event, so the post-wait check
|
||
sees a non-empty list and returns the user's actual answer.
|
||
Cancellation, by contrast, sets the event WITHOUT delivering
|
||
an answer and is now distinguishable — that path raises
|
||
:class:`controller_runtime.DispatchCancelled` (see the cancel-path tests
|
||
above).
|
||
"""
|
||
state = loop_state.ReplState()
|
||
state.current_cancel_event = threading.Event()
|
||
|
||
t, result, exc = self._run_in_thread(state, "Proceed? [y/N] ")
|
||
self._wait_until_parked(state)
|
||
|
||
state.deliver_confirmation("")
|
||
t.join(timeout=self._JOIN_TIMEOUT_S)
|
||
|
||
assert not t.is_alive(), "empty delivery did not unblock worker"
|
||
assert exc == [], f"explicit empty delivery should not raise: {exc}"
|
||
assert result == [""]
|
||
assert state.confirm_event is None
|
||
assert state.confirm_response == []
|
||
|
||
|
||
class TestExecutionAllowedRespectsDispatchCancelled:
|
||
"""End-to-end contract: cancelling during ``Proceed? [Y/n]`` must
|
||
actually STOP the in-flight action — not just stop the spinner.
|
||
|
||
Pre-fix, the cancel handler returned ``""``; ``execution_allowed``
|
||
treated empty as YES (since ``[Y/n]`` defaults to Y) and the worker
|
||
happily ran the action it was supposed to interrupt — only the
|
||
spinner stopped, the agent kept going. This test class pins the
|
||
new contract: the confirm callable raises ``DispatchCancelled`` and
|
||
the exception propagates out of ``execution_allowed`` *without*
|
||
silently confirming the action. The action loop in
|
||
``run_action_tool_turn`` therefore exits via the exception, the
|
||
in-flight action never runs, and any further actions in the same
|
||
plan are skipped.
|
||
"""
|
||
|
||
def test_dispatch_cancelled_propagates_through_execution_allowed(
|
||
self, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
from rich.console import Console
|
||
|
||
from surfaces.interactive_shell.ui.execution_confirm import execution_allowed
|
||
from tools.interactive_shell.shared import (
|
||
ExecutionPolicyResult,
|
||
)
|
||
|
||
monkeypatch.setattr("sys.stdin.isatty", lambda: True)
|
||
session = Session()
|
||
console = Console(file=io.StringIO(), force_terminal=False, highlight=False)
|
||
|
||
def _cancel_confirm(_prompt: str) -> str:
|
||
raise controller_runtime.DispatchCancelled("cancelled while awaiting confirmation")
|
||
|
||
policy = ExecutionPolicyResult(
|
||
verdict="ask",
|
||
tool_type="opensre_cli",
|
||
reason="this opensre subcommand may change local config or infrastructure",
|
||
)
|
||
|
||
with pytest.raises(controller_runtime.DispatchCancelled):
|
||
execution_allowed(
|
||
policy,
|
||
session=session,
|
||
console=console,
|
||
action_summary="opensre remote health --help",
|
||
confirm_fn=_cancel_confirm,
|
||
is_tty=True,
|
||
)
|
||
|
||
def test_empty_confirm_response_would_silently_allow_without_raise(
|
||
self, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
"""Regression guard documenting WHY the raise is required.
|
||
|
||
If the confirm callable returned ``""`` instead of raising,
|
||
``execution_allowed`` would treat the empty answer as YES
|
||
(``[Y/n]`` defaults to Y) and the action would run. This test
|
||
pins that footgun by demonstrating the bad outcome with an
|
||
empty-string return — the cancel handler MUST raise, not
|
||
return, to actually stop the in-flight action.
|
||
"""
|
||
from rich.console import Console
|
||
|
||
from surfaces.interactive_shell.ui.execution_confirm import execution_allowed
|
||
from tools.interactive_shell.shared import (
|
||
ExecutionPolicyResult,
|
||
)
|
||
|
||
monkeypatch.setattr("sys.stdin.isatty", lambda: True)
|
||
session = Session()
|
||
console = Console(file=io.StringIO(), force_terminal=False, highlight=False)
|
||
|
||
policy = ExecutionPolicyResult(
|
||
verdict="ask",
|
||
tool_type="opensre_cli",
|
||
reason="this opensre subcommand may change local config or infrastructure",
|
||
)
|
||
|
||
# Empty string answer (the pre-fix cancel return value) is
|
||
# silently confirmed — proving the bug existed and that we
|
||
# cannot rely on a sentinel string for cancellation.
|
||
assert (
|
||
execution_allowed(
|
||
policy,
|
||
session=session,
|
||
console=console,
|
||
action_summary="opensre remote health --help",
|
||
confirm_fn=lambda _prompt: "",
|
||
is_tty=True,
|
||
)
|
||
is True
|
||
)
|
||
|
||
|
||
class TestThemeCommand:
|
||
@staticmethod
|
||
def _capture() -> tuple:
|
||
from rich.console import Console
|
||
|
||
buf = io.StringIO()
|
||
return Console(file=buf, force_terminal=False, highlight=False), buf
|
||
|
||
def test_theme_command_is_registered(self) -> None:
|
||
assert "/theme" in SLASH_COMMANDS
|
||
|
||
def test_theme_command_updates_active_theme_and_persists(self, monkeypatch) -> None:
|
||
from surfaces.interactive_shell.command_registry import theme as theme_cmd
|
||
|
||
saved_payloads: list[dict[str, object]] = []
|
||
monkeypatch.setattr(theme_cmd, "repl_tty_interactive", lambda: True)
|
||
monkeypatch.setattr(theme_cmd, "repl_choose_one", lambda **_kwargs: "blue")
|
||
monkeypatch.setattr(theme_cmd, "_refresh_prompt_style", lambda _session: None)
|
||
monkeypatch.setattr("surfaces.cli.commands.config._load_config", lambda: {})
|
||
monkeypatch.setattr(
|
||
"surfaces.cli.commands.config._save_config",
|
||
lambda data: saved_payloads.append(dict(data)),
|
||
)
|
||
|
||
set_active_theme("green")
|
||
session = Session()
|
||
console, _buf = self._capture()
|
||
|
||
assert dispatch_slash("/theme", session, console) is True
|
||
assert get_active_theme_name() == "blue"
|
||
assert saved_payloads
|
||
interactive = saved_payloads[-1].get("interactive")
|
||
assert isinstance(interactive, dict)
|
||
assert interactive.get("theme") == "blue"
|
||
|
||
def test_theme_picker_uses_session_theme_as_current(self, monkeypatch) -> None:
|
||
from surfaces.interactive_shell.command_registry import theme as theme_cmd
|
||
|
||
monkeypatch.setattr(theme_cmd, "repl_tty_interactive", lambda: True)
|
||
captured: dict[str, object] = {}
|
||
|
||
def _fake_choose_one(**kwargs: object) -> None:
|
||
captured.update(kwargs)
|
||
return None
|
||
|
||
monkeypatch.setattr(theme_cmd, "repl_choose_one", _fake_choose_one)
|
||
|
||
session = Session()
|
||
session.terminal.active_theme_name = "pink"
|
||
set_active_theme("pink")
|
||
console, _buf = self._capture()
|
||
|
||
dispatch_slash("/theme", session, console)
|
||
|
||
assert captured.get("initial_value") == "pink"
|
||
choices = captured.get("choices")
|
||
assert isinstance(choices, list)
|
||
assert any("pink (current)" in label for _value, label in choices)
|
||
|
||
def test_theme_command_direct_arg_sets_theme(self, monkeypatch) -> None:
|
||
from surfaces.interactive_shell.command_registry import theme as theme_cmd
|
||
|
||
monkeypatch.setattr(theme_cmd, "repl_tty_interactive", lambda: True)
|
||
monkeypatch.setattr(theme_cmd, "_refresh_prompt_style", lambda _session: None)
|
||
monkeypatch.setattr("surfaces.cli.commands.config._load_config", lambda: {})
|
||
monkeypatch.setattr("surfaces.cli.commands.config._save_config", lambda _data: None)
|
||
|
||
set_active_theme("green")
|
||
session = Session()
|
||
console, _buf = self._capture()
|
||
|
||
assert dispatch_slash("/theme amber", session, console) is True
|
||
assert get_active_theme_name() == "amber"
|
||
|
||
def test_theme_change_refreshes_welcome_poster(self, monkeypatch) -> None:
|
||
from surfaces.interactive_shell.command_registry import theme as theme_cmd
|
||
|
||
monkeypatch.setattr(theme_cmd, "repl_tty_interactive", lambda: True)
|
||
monkeypatch.setattr(theme_cmd, "repl_choose_one", lambda **_kwargs: "blue")
|
||
monkeypatch.setattr(theme_cmd, "_refresh_prompt_style", lambda _session: None)
|
||
monkeypatch.setattr("surfaces.cli.commands.config._load_config", lambda: {})
|
||
monkeypatch.setattr("surfaces.cli.commands.config._save_config", lambda _data: None)
|
||
|
||
refreshed: list[dict[str, object | None]] = []
|
||
|
||
def _refresh(
|
||
console: object,
|
||
*,
|
||
session: object = None,
|
||
theme_notice: str | None = None,
|
||
) -> None:
|
||
refreshed.append({"console": console, "session": session, "theme_notice": theme_notice})
|
||
|
||
monkeypatch.setattr(
|
||
"surfaces.interactive_shell.ui.components.rendering.refresh_welcome_poster",
|
||
_refresh,
|
||
)
|
||
|
||
session = Session()
|
||
console, _buf = self._capture()
|
||
|
||
assert dispatch_slash("/theme", session, console) is True
|
||
assert len(refreshed) == 1
|
||
assert refreshed[0]["session"] is session
|
||
assert refreshed[0]["theme_notice"] == "blue"
|
||
|
||
def test_theme_picker_lists_all_registered_themes(self, monkeypatch) -> None:
|
||
from platform.terminal.theme import list_theme_names
|
||
from surfaces.interactive_shell.command_registry import theme as theme_cmd
|
||
|
||
monkeypatch.setattr(theme_cmd, "repl_tty_interactive", lambda: True)
|
||
captured: dict[str, object] = {}
|
||
|
||
def _fake_choose_one(**kwargs: object) -> None:
|
||
captured.update(kwargs)
|
||
return None
|
||
|
||
monkeypatch.setattr(theme_cmd, "repl_choose_one", _fake_choose_one)
|
||
|
||
session = Session()
|
||
console, _buf = self._capture()
|
||
dispatch_slash("/theme", session, console)
|
||
|
||
choices = captured.get("choices")
|
||
assert isinstance(choices, list)
|
||
assert [value for value, _label in choices] == list(list_theme_names())
|
||
|
||
def test_theme_persist_drains_cpr_around_poster_and_defers_prompt_refresh(
|
||
self, monkeypatch
|
||
) -> None:
|
||
from surfaces.interactive_shell.command_registry import theme as theme_cmd
|
||
|
||
drains: list[str] = []
|
||
monkeypatch.setattr(
|
||
theme_cmd,
|
||
"_settle_and_drain_cpr",
|
||
lambda: drains.append("drain"),
|
||
)
|
||
monkeypatch.setattr(
|
||
"surfaces.interactive_shell.ui.components.rendering.refresh_welcome_poster",
|
||
lambda *_args, **_kwargs: drains.append("poster"),
|
||
)
|
||
monkeypatch.setattr("surfaces.cli.commands.config._load_config", lambda: {})
|
||
monkeypatch.setattr("surfaces.cli.commands.config._save_config", lambda _data: None)
|
||
|
||
session = Session()
|
||
console, _buf = self._capture()
|
||
theme_cmd._persist_and_report_theme(session, console, "pink")
|
||
|
||
assert drains == ["drain", "poster", "drain"]
|
||
assert session.terminal.pending_theme_refresh is True
|
||
|
||
|
||
def test_refresh_prompt_theme_skips_invalidate_when_app_not_running() -> None:
|
||
from surfaces.interactive_shell.ui.input_prompt.style import refresh_prompt_theme
|
||
|
||
invalidated: list[bool] = []
|
||
|
||
class _App:
|
||
is_running = False
|
||
style = None
|
||
placeholder = None
|
||
renderer = None
|
||
|
||
def invalidate(self) -> None:
|
||
invalidated.append(True)
|
||
|
||
session = Session()
|
||
session.terminal.prompt_app = _App()
|
||
refresh_prompt_theme(session)
|
||
assert invalidated == []
|
||
assert session.terminal.prompt_app.style is not None
|