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
353 lines
12 KiB
Python
353 lines
12 KiB
Python
"""Tests for prompt placeholder and prefill behavior."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from dataclasses import dataclass
|
|
|
|
import pytest
|
|
from prompt_toolkit.completion import Completion
|
|
|
|
from platform.common.task_types import TaskKind
|
|
from surfaces.interactive_shell.runtime.core import state as loop_state
|
|
from surfaces.interactive_shell.session import Session
|
|
from surfaces.interactive_shell.ui.input_prompt import completion as prompt_completion
|
|
from surfaces.interactive_shell.ui.input_prompt import rendering as prompt_rendering
|
|
from surfaces.interactive_shell.ui.input_prompt.completion import completion_preview_hint_ansi
|
|
from surfaces.interactive_shell.ui.input_prompt.refresh import wire_prompt_refresh
|
|
from surfaces.interactive_shell.ui.input_prompt.rendering import (
|
|
_DEFAULT_PLACEHOLDER_TEXT,
|
|
_prompt_counter_text,
|
|
_prompt_turn_number,
|
|
resolve_idle_hint_ansi,
|
|
resolve_prompt_placeholder,
|
|
resolve_prompt_prefix_ansi,
|
|
)
|
|
|
|
|
|
def _strip_ansi(text: str) -> str:
|
|
return re.sub(r"\x1b\[[0-9;]*m", "", text)
|
|
|
|
|
|
def _placeholder_text(session: Session) -> str:
|
|
return resolve_prompt_placeholder(session).value
|
|
|
|
|
|
class _RefreshFakeBuffer:
|
|
def __init__(self) -> None:
|
|
self.text = ""
|
|
self.submitted = False
|
|
|
|
def validate_and_handle(self) -> None:
|
|
self.submitted = True
|
|
|
|
|
|
class _RefreshFakeApp:
|
|
is_running = True
|
|
|
|
def __init__(self) -> None:
|
|
self.current_buffer = _RefreshFakeBuffer()
|
|
|
|
def invalidate(self) -> None:
|
|
pass
|
|
|
|
|
|
class _RefreshFakeLoop:
|
|
def call_soon_threadsafe(self, fn, *args) -> None: # type: ignore[no-untyped-def]
|
|
fn(*args)
|
|
|
|
|
|
class TestPromptRefreshAutoSubmit:
|
|
def test_queue_auto_command_fills_and_submits_prompt(self) -> None:
|
|
"""An agent-queued interactive command should be both prefilled and
|
|
auto-submitted so it dispatches through the exclusive-stdin path."""
|
|
session = Session()
|
|
app = _RefreshFakeApp()
|
|
wire_prompt_refresh(session, app, _RefreshFakeLoop())
|
|
session.terminal.set_auto_command("/integrations setup sentry")
|
|
assert app.current_buffer.text == "/integrations setup sentry"
|
|
assert app.current_buffer.submitted is True
|
|
|
|
def test_plain_prefill_does_not_auto_submit(self) -> None:
|
|
"""A prefill without the auto-submit flag must wait for the user (Enter)."""
|
|
session = Session()
|
|
app = _RefreshFakeApp()
|
|
wire_prompt_refresh(session, app, _RefreshFakeLoop())
|
|
session.terminal.pending_prompt_default = "why did it fail?"
|
|
session.terminal.notify_prompt_changed()
|
|
assert app.current_buffer.text == "why did it fail?"
|
|
assert app.current_buffer.submitted is False
|
|
|
|
|
|
class TestPromptTurnCounter:
|
|
def test_first_turn_is_numbered_one(self) -> None:
|
|
session = Session()
|
|
assert _prompt_turn_number(session) == 1
|
|
assert _prompt_counter_text(session) == "[1] "
|
|
|
|
def test_counter_advances_with_history(self) -> None:
|
|
session = Session()
|
|
session.record("chat", "hello")
|
|
assert _prompt_turn_number(session) == 2
|
|
assert _prompt_counter_text(session) == "[2] "
|
|
|
|
|
|
class TestResolveIdleHint:
|
|
def test_shows_connected_integrations_in_hint_bar(self) -> None:
|
|
session = Session()
|
|
session.configured_integrations_known = True
|
|
session.configured_integrations = ("datadog", "github", "grafana")
|
|
rendered = _strip_ansi(resolve_idle_hint_ansi(session))
|
|
assert "/ for commands" in rendered
|
|
assert "Datadog" in rendered
|
|
assert "GitHub" in rendered
|
|
assert "Grafana" in rendered
|
|
|
|
def test_omits_integrations_when_none_configured(self) -> None:
|
|
session = Session()
|
|
session.configured_integrations_known = True
|
|
session.configured_integrations = ()
|
|
rendered = _strip_ansi(resolve_idle_hint_ansi(session))
|
|
assert "Datadog" not in rendered
|
|
assert "/ for commands" in rendered
|
|
|
|
|
|
class TestResolvePromptPlaceholder:
|
|
def test_default_when_no_session_context(self) -> None:
|
|
session = Session()
|
|
assert _DEFAULT_PLACEHOLDER_TEXT in _placeholder_text(session)
|
|
|
|
def test_shows_trust_mode(self) -> None:
|
|
session = Session()
|
|
session.terminal.trust_mode = True
|
|
text = _placeholder_text(session)
|
|
assert "trust on" in text
|
|
assert _DEFAULT_PLACEHOLDER_TEXT not in text
|
|
|
|
def test_shows_running_task_count(self) -> None:
|
|
session = Session()
|
|
task = session.task_registry.create(TaskKind.SYNTHETIC_TEST)
|
|
task.mark_running()
|
|
assert "1 task running" in _placeholder_text(session)
|
|
|
|
second = session.task_registry.create(TaskKind.INVESTIGATION)
|
|
second.mark_running()
|
|
assert "2 tasks running" in _placeholder_text(session)
|
|
|
|
def test_shows_resumed_session_name(self) -> None:
|
|
session = Session()
|
|
session.resumed_from_name = "redis-incident"
|
|
text = _placeholder_text(session)
|
|
assert "resumed: redis-incident" in text
|
|
|
|
def test_combines_multiple_state_segments(self) -> None:
|
|
session = Session()
|
|
session.terminal.trust_mode = True
|
|
session.resumed_from_name = "redis-incident"
|
|
task = session.task_registry.create(TaskKind.WATCHDOG)
|
|
task.mark_running()
|
|
text = _placeholder_text(session)
|
|
assert "trust on" in text
|
|
assert "1 task running" in text
|
|
assert "resumed: redis-incident" in text
|
|
assert " · " in text
|
|
|
|
|
|
@dataclass
|
|
class _FakeCompleteState:
|
|
completions: list[Completion]
|
|
current_completion: Completion | None = None
|
|
|
|
|
|
@dataclass
|
|
class _FakeBuffer:
|
|
text: str
|
|
complete_state: _FakeCompleteState | None = None
|
|
|
|
|
|
@dataclass
|
|
class _FakeOutput:
|
|
columns: int = 120
|
|
|
|
def get_size(self) -> _FakeOutput:
|
|
return self
|
|
|
|
|
|
@dataclass
|
|
class _FakeApp:
|
|
current_buffer: _FakeBuffer
|
|
output: _FakeOutput
|
|
|
|
|
|
class TestCompletionPreviewHint:
|
|
def test_returns_empty_when_no_app(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr(prompt_completion, "get_app_or_none", lambda: None)
|
|
assert completion_preview_hint_ansi() == ""
|
|
|
|
def test_shows_full_slash_command_description(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
completion = Completion(
|
|
"/investigate",
|
|
start_position=-1,
|
|
display="/investigate",
|
|
display_meta="Run an RCA investigation from a file or sample templa…",
|
|
)
|
|
app = _FakeApp(
|
|
current_buffer=_FakeBuffer(
|
|
text="/",
|
|
complete_state=_FakeCompleteState(
|
|
completions=[completion],
|
|
current_completion=completion,
|
|
),
|
|
),
|
|
output=_FakeOutput(),
|
|
)
|
|
monkeypatch.setattr(prompt_completion, "get_app_or_none", lambda: app)
|
|
|
|
rendered = _strip_ansi(completion_preview_hint_ansi())
|
|
assert rendered.startswith("/investigate — ")
|
|
assert len(rendered) > len("/investigate — " + completion.display_meta_text)
|
|
assert "…" not in rendered
|
|
|
|
def test_unregistered_slash_completion_uses_display_label(
|
|
self, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
completion = Completion(
|
|
"/plugin-cmd",
|
|
start_position=-1,
|
|
display="/plugin-cmd",
|
|
display_meta="Plugin-provided slash command.",
|
|
)
|
|
app = _FakeApp(
|
|
current_buffer=_FakeBuffer(
|
|
text="/",
|
|
complete_state=_FakeCompleteState(
|
|
completions=[completion],
|
|
current_completion=completion,
|
|
),
|
|
),
|
|
output=_FakeOutput(),
|
|
)
|
|
monkeypatch.setattr(prompt_completion, "get_app_or_none", lambda: app)
|
|
|
|
rendered = _strip_ansi(completion_preview_hint_ansi())
|
|
assert rendered == "/plugin-cmd — Plugin-provided slash command."
|
|
|
|
def test_shows_subcommand_label_with_parent_command(
|
|
self, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
completion = Completion(
|
|
"high",
|
|
start_position=-1,
|
|
display="high",
|
|
display_meta="favor more thorough reasoning",
|
|
)
|
|
app = _FakeApp(
|
|
current_buffer=_FakeBuffer(
|
|
text="/effort ",
|
|
complete_state=_FakeCompleteState(
|
|
completions=[completion],
|
|
current_completion=completion,
|
|
),
|
|
),
|
|
output=_FakeOutput(),
|
|
)
|
|
monkeypatch.setattr(prompt_completion, "get_app_or_none", lambda: app)
|
|
|
|
rendered = _strip_ansi(completion_preview_hint_ansi())
|
|
assert rendered == "/effort high — favor more thorough reasoning"
|
|
|
|
def test_falls_back_to_first_completion_when_none_selected(
|
|
self, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
first = Completion(
|
|
"/plugin-cmd",
|
|
start_position=-1,
|
|
display="/plugin-cmd",
|
|
display_meta="Plugin-provided slash command.",
|
|
)
|
|
app = _FakeApp(
|
|
current_buffer=_FakeBuffer(
|
|
text="/",
|
|
complete_state=_FakeCompleteState(
|
|
completions=[first],
|
|
current_completion=None,
|
|
),
|
|
),
|
|
output=_FakeOutput(),
|
|
)
|
|
monkeypatch.setattr(prompt_completion, "get_app_or_none", lambda: app)
|
|
|
|
rendered = _strip_ansi(completion_preview_hint_ansi())
|
|
assert rendered == "/plugin-cmd — Plugin-provided slash command."
|
|
|
|
def test_clips_preview_to_terminal_width(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
long_meta = (
|
|
"Plugin-provided slash command with a deliberately long description "
|
|
"that must be clipped to the terminal width."
|
|
)
|
|
completion = Completion(
|
|
"/plugin-cmd",
|
|
start_position=-1,
|
|
display="/plugin-cmd",
|
|
display_meta=long_meta,
|
|
)
|
|
app = _FakeApp(
|
|
current_buffer=_FakeBuffer(
|
|
text="/",
|
|
complete_state=_FakeCompleteState(
|
|
completions=[completion],
|
|
current_completion=completion,
|
|
),
|
|
),
|
|
output=_FakeOutput(columns=40),
|
|
)
|
|
monkeypatch.setattr(prompt_completion, "get_app_or_none", lambda: app)
|
|
|
|
rendered = _strip_ansi(completion_preview_hint_ansi())
|
|
assert rendered.endswith("…")
|
|
assert len(rendered) <= 40
|
|
assert rendered.startswith("/plugin-cmd — ")
|
|
|
|
|
|
class TestResolvePromptPrefix:
|
|
def test_prefers_inline_spinner_over_completion_preview(
|
|
self, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.setattr(
|
|
prompt_rendering,
|
|
"completion_preview_hint_ansi",
|
|
lambda: "preview line",
|
|
)
|
|
spinner = loop_state.SpinnerState()
|
|
spinner.start()
|
|
prefix = resolve_prompt_prefix_ansi(
|
|
inline_spinner=spinner.inline_spinner_ansi(),
|
|
idle_hint=spinner.idle_hint_ansi(),
|
|
)
|
|
assert "preview line" not in prefix
|
|
assert "esc to cancel" in _strip_ansi(prefix)
|
|
|
|
def test_prefers_completion_preview_over_idle_hint(
|
|
self, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.setattr(
|
|
prompt_rendering,
|
|
"completion_preview_hint_ansi",
|
|
lambda: "preview line",
|
|
)
|
|
spinner = loop_state.SpinnerState()
|
|
prefix = resolve_prompt_prefix_ansi(
|
|
inline_spinner=spinner.inline_spinner_ansi(),
|
|
idle_hint=spinner.idle_hint_ansi(),
|
|
)
|
|
assert prefix == "preview line"
|
|
assert "/ for commands" not in prefix
|
|
|
|
def test_falls_back_to_idle_hint_when_no_preview(self) -> None:
|
|
spinner = loop_state.SpinnerState()
|
|
prefix = resolve_prompt_prefix_ansi(
|
|
inline_spinner=spinner.inline_spinner_ansi(),
|
|
idle_hint=spinner.idle_hint_ansi(),
|
|
)
|
|
assert "/ for commands" in _strip_ansi(prefix)
|