chore: import upstream snapshot with attribution
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# Package marker for mirrored tests.
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Tests for interactive-shell action rendering."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from dataclasses import dataclass
|
||||
|
||||
import pytest
|
||||
from rich.console import Console
|
||||
|
||||
import tools.interactive_shell.actions.slash as slash_tool
|
||||
from core.agent_harness.turns.turn_results import ToolCallingTurnResult
|
||||
from surfaces.interactive_shell.runtime.action_turn import run_action_tool_turn
|
||||
from surfaces.interactive_shell.runtime.shell_turn_execution import execute_shell_turn
|
||||
from surfaces.interactive_shell.session import Session
|
||||
from surfaces.interactive_shell.ui.action_rendering import ActionRenderObserver
|
||||
from surfaces.interactive_shell.ui.input_prompt.rendering import _prompt_turn_number
|
||||
from tests.core.agent.orchestration.action_execution_test_harness import (
|
||||
ActionExecutionHarness,
|
||||
FakeActionLLM,
|
||||
no_tool_response,
|
||||
)
|
||||
|
||||
|
||||
def test_slash_invoke_tool_start_does_not_record_cli_agent() -> None:
|
||||
session = Session()
|
||||
buffer = io.StringIO()
|
||||
console = Console(file=buffer, force_terminal=False, highlight=False)
|
||||
observer = ActionRenderObserver(session=session, console=console, message="/model show")
|
||||
|
||||
observer(
|
||||
"tool_start",
|
||||
{"name": "slash_invoke", "input": {"command": "/model", "args": ["show"]}},
|
||||
)
|
||||
|
||||
assert session.history == []
|
||||
assert observer.planned_count == 1
|
||||
assert buffer.getvalue() == ""
|
||||
|
||||
|
||||
def test_shell_run_tool_start_does_not_record_cli_agent() -> None:
|
||||
session = Session()
|
||||
console = Console(file=io.StringIO(), force_terminal=False, highlight=False)
|
||||
observer = ActionRenderObserver(session=session, console=console, message="!true")
|
||||
|
||||
observer("tool_start", {"name": "shell_run", "input": {"command": "true"}})
|
||||
|
||||
assert session.history == []
|
||||
assert observer.planned_count == 1
|
||||
|
||||
|
||||
def test_literal_slash_command_records_single_history_entry(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
dispatched: list[str] = []
|
||||
|
||||
def _fake_dispatch(
|
||||
command: str,
|
||||
session: Session,
|
||||
console: Console,
|
||||
**_kwargs: object,
|
||||
) -> bool:
|
||||
dispatched.append(command)
|
||||
session.record("slash", command, ok=True)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(slash_tool, "dispatch_slash", _fake_dispatch)
|
||||
session = Session()
|
||||
harness = ActionExecutionHarness(llm=FakeActionLLM([no_tool_response()]))
|
||||
|
||||
result = run_action_tool_turn(
|
||||
"/model show",
|
||||
session,
|
||||
harness.console,
|
||||
deps=harness.deps,
|
||||
)
|
||||
|
||||
assert result.handled is True
|
||||
assert dispatched == ["/model show"]
|
||||
assert session.history == [{"type": "slash", "text": "/model show", "ok": True}]
|
||||
assert _prompt_turn_number(session) == 2
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeLlmRun:
|
||||
response_text: str = "hello back"
|
||||
|
||||
|
||||
def test_chat_turn_records_single_cli_agent_history_entry() -> None:
|
||||
session = Session()
|
||||
console = Console(file=io.StringIO(), force_terminal=False, highlight=False)
|
||||
|
||||
def _no_actions(
|
||||
_text: str,
|
||||
_session: Session,
|
||||
_console: Console,
|
||||
**kwargs: object,
|
||||
) -> ToolCallingTurnResult:
|
||||
return ToolCallingTurnResult(
|
||||
planned_count=0,
|
||||
executed_count=0,
|
||||
executed_success_count=0,
|
||||
has_unhandled_clause=False,
|
||||
handled=False,
|
||||
accounting_status="not_run",
|
||||
)
|
||||
|
||||
def _answer(
|
||||
_text: str,
|
||||
_session: Session,
|
||||
_console: Console,
|
||||
**kwargs: object,
|
||||
) -> _FakeLlmRun:
|
||||
return _FakeLlmRun()
|
||||
|
||||
execute_shell_turn(
|
||||
"what broke in prod?",
|
||||
session,
|
||||
console,
|
||||
recorder=None,
|
||||
execute_actions=_no_actions,
|
||||
answer_agent=_answer,
|
||||
)
|
||||
|
||||
assert session.history == [{"type": "cli_agent", "text": "what broke in prod?", "ok": True}]
|
||||
assert _prompt_turn_number(session) == 2
|
||||
@@ -0,0 +1,361 @@
|
||||
"""Pure rendering tests for the ``/fleet`` dashboard table (issue #1488).
|
||||
|
||||
These cover ``render_agents_table`` in isolation — no slash-command
|
||||
dispatch, no real registry I/O. The integration tests in
|
||||
``test_agents_commands.py`` cover the dispatch path that consumes
|
||||
this function.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
from surfaces.interactive_shell.ui.agents import agents_view as agents_view_mod
|
||||
from surfaces.interactive_shell.ui.agents.agents_view import _build_agents_table
|
||||
from tools.system.fleet_monitoring import config as config_mod
|
||||
from tools.system.fleet_monitoring.probe import ProcessSnapshot
|
||||
from tools.system.fleet_monitoring.registry import AgentRecord
|
||||
from tools.system.fleet_monitoring.token_rate import TOKEN_RATE_TRACKER
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolated_agents_yaml(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:
|
||||
"""Autouse: redirect ``agents_config_path`` to a per-test tmp file
|
||||
so the rendering tests don't read the developer's real
|
||||
``~/.opensre/agents.yaml`` (which would let real budgets
|
||||
leak into the placeholder assertions and create cross-machine
|
||||
flakes).
|
||||
"""
|
||||
target = tmp_path / "agents.yaml"
|
||||
monkeypatch.setattr(config_mod, "agents_config_path", lambda: target)
|
||||
return target
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_sampler_module_state() -> None:
|
||||
"""Reset module-level state in the sampler before each test.
|
||||
|
||||
Three globals can leak across test files:
|
||||
|
||||
- :data:`tools.system.fleet_monitoring.sampler._latest` (probe snapshots dict)
|
||||
- :data:`tools.system.fleet_monitoring.token_rate.TOKEN_RATE_TRACKER` (per-PID deque)
|
||||
- :data:`tools.system.fleet_monitoring.sampler._TickCache.registry_snapshot` and
|
||||
:data:`_TickCache.agents_config` (tick caches added in #2023 to
|
||||
avoid re-reading the disk on every render)
|
||||
|
||||
Sampler tests populate all of them; view tests must start clean
|
||||
so placeholder assertions are stable under ``pytest-xdist`` and
|
||||
in arbitrary alphabetical order.
|
||||
"""
|
||||
from tools.system.fleet_monitoring import sampler as sampler_mod
|
||||
|
||||
sampler_mod._latest.clear()
|
||||
sampler_mod._TickCache.registry_snapshot = {}
|
||||
sampler_mod._TickCache.agents_config = None
|
||||
for pid in list(TOKEN_RATE_TRACKER.known_pids()):
|
||||
TOKEN_RATE_TRACKER.forget(pid)
|
||||
|
||||
|
||||
# The columns this PR ships are the contract for #1490 and later
|
||||
# tickets that thread snapshot data into the rendering layer; pin
|
||||
# them here so a downstream reorder doesn't silently break the
|
||||
# dashboard preview.
|
||||
_DASHBOARD_COLUMNS: tuple[str, ...] = (
|
||||
"agent",
|
||||
"pid",
|
||||
"uptime",
|
||||
"cpu%",
|
||||
"tokens/min",
|
||||
"$/hr",
|
||||
"status",
|
||||
)
|
||||
|
||||
|
||||
def _render(records: list[AgentRecord]) -> tuple[Table, str]:
|
||||
"""Build the table and capture the printed form for substring assertions."""
|
||||
table = _build_agents_table(records)
|
||||
buf = io.StringIO()
|
||||
Console(file=buf, force_terminal=False, highlight=False, width=120).print(table)
|
||||
return table, buf.getvalue()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Column structure — the contract downstream tickets lean on
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_table_has_full_dashboard_column_set_in_documented_order() -> None:
|
||||
table, _ = _render([])
|
||||
headers = tuple(str(col.header) for col in table.columns)
|
||||
assert headers == _DASHBOARD_COLUMNS
|
||||
|
||||
|
||||
def test_pid_column_is_right_justified_to_match_numeric_dashboard_preview() -> None:
|
||||
"""Numeric columns (pid, uptime, cpu%, tokens/min, $/hr) are
|
||||
right-justified; agent name and status are left-justified. This
|
||||
matches the spacing in the issue's mock and keeps later
|
||||
snapshot-injected cells aligned without re-styling."""
|
||||
table, _ = _render([])
|
||||
by_header = {str(col.header): col for col in table.columns}
|
||||
assert by_header["pid"].justify == "right"
|
||||
assert by_header["uptime"].justify == "right"
|
||||
assert by_header["cpu%"].justify == "right"
|
||||
assert by_header["tokens/min"].justify == "right"
|
||||
assert by_header["$/hr"].justify == "right"
|
||||
assert by_header["agent"].justify == "left"
|
||||
assert by_header["status"].justify == "left"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Empty state
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_empty_records_renders_table_with_zero_rows() -> None:
|
||||
table, _ = _render([])
|
||||
assert table.row_count == 0
|
||||
|
||||
|
||||
def test_empty_records_caption_announces_empty_state() -> None:
|
||||
"""Empty-state UX: the table caption tells the user the fleet
|
||||
is empty rather than leaving a blank table that looks like a bug.
|
||||
"""
|
||||
_, out = _render([])
|
||||
assert "no agents discovered or registered yet" in out
|
||||
|
||||
|
||||
def test_non_empty_records_have_no_caption() -> None:
|
||||
"""When the registry has rows, the caption is suppressed —
|
||||
the table content speaks for itself and a caption would be noise."""
|
||||
table, _ = _render([AgentRecord(name="claude-code", pid=8421, command="claude")])
|
||||
assert table.caption is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Row content
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_row_contains_agent_name_and_pid() -> None:
|
||||
_, out = _render([AgentRecord(name="claude-code", pid=8421, command="claude")])
|
||||
assert "claude-code" in out
|
||||
assert "8421" in out
|
||||
|
||||
|
||||
def test_metric_cells_are_placeholders_when_no_sampler_data() -> None:
|
||||
"""All five metric cells render ``-`` when the sampler has no
|
||||
data for the PID (REPL not running, non-interactive ``opensre
|
||||
agents list``, or fresh registration that has not yet been
|
||||
sampled). #2023 split ``tokens/min`` and ``$/hr`` from the
|
||||
yaml budget; both still fall back to ``-`` here for the same
|
||||
"no data" reason.
|
||||
"""
|
||||
table, _ = _render([AgentRecord(name="claude-code", pid=8421, command="claude")])
|
||||
# row_count == 1, so iterate directly to inspect the rendered cells
|
||||
assert table.row_count == 1
|
||||
rendered_cells = [list(col.cells)[0] for col in table.columns]
|
||||
# cells[0] = agent, cells[1] = pid, then metric cells and status.
|
||||
assert rendered_cells[2:] == ["-", "-", "-", "-", "-"]
|
||||
|
||||
|
||||
def test_table_shows_live_probe_data_when_snapshot_exists(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
fixed_now = datetime(2026, 5, 10, 14, 0, 0, tzinfo=UTC)
|
||||
started_at = datetime(2026, 5, 10, 12, 0, 0, tzinfo=UTC) # exactly 2h before
|
||||
|
||||
class FrozenDatetime(datetime):
|
||||
@classmethod
|
||||
def now(cls, _tz=None): # type: ignore[override]
|
||||
return fixed_now
|
||||
|
||||
monkeypatch.setattr(agents_view_mod, "datetime", FrozenDatetime)
|
||||
|
||||
fake_snapshot = ProcessSnapshot(
|
||||
pid=8421,
|
||||
cpu_percent=23.5,
|
||||
rss_mb=128.0,
|
||||
num_fds=42,
|
||||
num_connections=3,
|
||||
status="running",
|
||||
started_at=started_at,
|
||||
)
|
||||
monkeypatch.setattr(agents_view_mod, "get_snapshot", lambda _pid: fake_snapshot)
|
||||
|
||||
table, _ = _render([AgentRecord(name="cursor", pid=8444, command="cursor")])
|
||||
|
||||
rendered_cells = [list(col.cells)[0] for col in table.columns]
|
||||
# ``tokens/min`` and ``$/hr`` are still ``-`` because no token
|
||||
# tracker entry exists for this PID — the snapshot fixture covers
|
||||
# only the resource side. The full live-data case is covered by
|
||||
# ``test_table_shows_tokens_and_cost_when_tracker_has_data``.
|
||||
# Without recent output activity, the status heuristic falls back
|
||||
# to the process start time and renders STUCK with a progress-time annotation.
|
||||
assert rendered_cells[2:] == ["2h0m", "23.5", "-", "-", "[red]stuck (2h0m no progress)[/red]"]
|
||||
|
||||
|
||||
def test_recent_agent_output_keeps_status_active(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
fixed_now = datetime(2026, 5, 10, 14, 0, 0, tzinfo=UTC)
|
||||
|
||||
class FrozenDatetime(datetime):
|
||||
@classmethod
|
||||
def now(cls, _tz=None): # type: ignore[override]
|
||||
return fixed_now
|
||||
|
||||
monkeypatch.setattr(agents_view_mod, "datetime", FrozenDatetime)
|
||||
|
||||
fake_snapshot = ProcessSnapshot(
|
||||
pid=8421,
|
||||
cpu_percent=23.5,
|
||||
rss_mb=128.0,
|
||||
num_fds=42,
|
||||
num_connections=3,
|
||||
status="running",
|
||||
started_at=fixed_now - timedelta(hours=2),
|
||||
last_output_at=fixed_now - timedelta(seconds=30),
|
||||
)
|
||||
monkeypatch.setattr(agents_view_mod, "get_snapshot", lambda _pid: fake_snapshot)
|
||||
|
||||
table, _ = _render([AgentRecord(name="cursor", pid=8444, command="cursor")])
|
||||
rendered_cells = [list(col.cells)[0] for col in table.columns]
|
||||
|
||||
assert rendered_cells[6] == "[green]active[/green]"
|
||||
|
||||
|
||||
def test_stuck_message_anchors_to_last_output_at_not_started_at(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
fixed_now = datetime(2026, 5, 10, 14, 0, 0, tzinfo=UTC)
|
||||
|
||||
class FrozenDatetime(datetime):
|
||||
@classmethod
|
||||
def now(cls, _tz=None): # type: ignore[override]
|
||||
return fixed_now
|
||||
|
||||
monkeypatch.setattr(agents_view_mod, "datetime", FrozenDatetime)
|
||||
|
||||
fake_snapshot = ProcessSnapshot(
|
||||
pid=8421,
|
||||
cpu_percent=5.0,
|
||||
rss_mb=64.0,
|
||||
num_fds=10,
|
||||
num_connections=1,
|
||||
status="running",
|
||||
started_at=fixed_now - timedelta(hours=3),
|
||||
last_output_at=fixed_now - timedelta(minutes=10),
|
||||
)
|
||||
monkeypatch.setattr(agents_view_mod, "get_snapshot", lambda _pid: fake_snapshot)
|
||||
|
||||
table, _ = _render([AgentRecord(name="cursor", pid=8444, command="cursor")])
|
||||
rendered_cells = [list(col.cells)[0] for col in table.columns]
|
||||
|
||||
assert rendered_cells[6] == "[red]stuck (10m no progress)[/red]"
|
||||
|
||||
|
||||
def test_table_shows_tokens_and_cost_when_tracker_has_data(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""End-to-end of the #2023 wiring on the view side: when the
|
||||
sampler's accessors return live numbers, the view formats them
|
||||
into the ``tokens/min`` and ``$/hr`` columns.
|
||||
"""
|
||||
monkeypatch.setattr(agents_view_mod, "get_tokens_per_min", lambda _pid: 120.0)
|
||||
monkeypatch.setattr(agents_view_mod, "get_usd_per_hour", lambda _pid: 0.27)
|
||||
|
||||
table, _ = _render([AgentRecord(name="claude-code-8421", pid=8421, command="claude")])
|
||||
|
||||
rendered_cells = [list(col.cells)[0] for col in table.columns]
|
||||
# cells[4] = tokens/min, cells[5] = $/hr
|
||||
assert rendered_cells[4] == "120"
|
||||
assert rendered_cells[5] == "$0.27"
|
||||
|
||||
|
||||
def test_tokens_per_min_formatter_uses_k_suffix_above_1000(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A busy session emitting > 1k tokens/min must not blow out the
|
||||
column width; the formatter falls back to ``1.2k`` shorthand.
|
||||
"""
|
||||
monkeypatch.setattr(agents_view_mod, "get_tokens_per_min", lambda _pid: 1234.0)
|
||||
monkeypatch.setattr(agents_view_mod, "get_usd_per_hour", lambda _pid: None)
|
||||
|
||||
table, _ = _render([AgentRecord(name="claude-code", pid=8421, command="claude")])
|
||||
rendered_cells = [list(col.cells)[0] for col in table.columns]
|
||||
assert rendered_cells[4] == "1.2k"
|
||||
|
||||
|
||||
def test_idle_observed_agent_renders_zero_tokens_per_min_not_dash(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The ``0`` vs ``-`` distinction the tracker enforces must
|
||||
propagate to the cell: a known-but-idle agent renders ``0``,
|
||||
a never-observed agent renders ``-``.
|
||||
"""
|
||||
monkeypatch.setattr(agents_view_mod, "get_tokens_per_min", lambda _pid: 0.0)
|
||||
monkeypatch.setattr(agents_view_mod, "get_usd_per_hour", lambda _pid: 0.0)
|
||||
|
||||
table, _ = _render([AgentRecord(name="claude-code", pid=8421, command="claude")])
|
||||
rendered_cells = [list(col.cells)[0] for col in table.columns]
|
||||
assert rendered_cells[4] == "0"
|
||||
assert rendered_cells[5] == "$0.00"
|
||||
|
||||
|
||||
def test_usd_per_hour_renders_dash_when_model_unknown(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""``get_usd_per_hour`` returns ``None`` when the model cannot
|
||||
be resolved (and therefore pricing cannot be applied). The cell
|
||||
must render ``-`` rather than ``$0.00`` so the user does not
|
||||
misread "unknown" as "free".
|
||||
"""
|
||||
monkeypatch.setattr(agents_view_mod, "get_tokens_per_min", lambda _pid: 500.0)
|
||||
monkeypatch.setattr(agents_view_mod, "get_usd_per_hour", lambda _pid: None)
|
||||
|
||||
table, _ = _render([AgentRecord(name="claude-code", pid=8421, command="claude")])
|
||||
rendered_cells = [list(col.cells)[0] for col in table.columns]
|
||||
# tokens/min still shows the live figure …
|
||||
assert rendered_cells[4] == "500"
|
||||
# … but $/hr is honest about the missing rate.
|
||||
assert rendered_cells[5] == "-"
|
||||
|
||||
|
||||
def test_multiple_records_are_each_rendered_in_order() -> None:
|
||||
records = [
|
||||
AgentRecord(name="claude-code", pid=8421, command="claude"),
|
||||
AgentRecord(name="cursor-tab", pid=9133, command="cursor"),
|
||||
AgentRecord(name="aider", pid=7702, command="aider"),
|
||||
]
|
||||
table, out = _render(records)
|
||||
assert table.row_count == 3
|
||||
# Substring order in the rendered output preserves input order:
|
||||
pos_claude = out.index("claude-code")
|
||||
pos_cursor = out.index("cursor-tab")
|
||||
pos_aider = out.index("aider")
|
||||
assert pos_claude < pos_cursor < pos_aider
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Defense against Rich-markup injection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_record_name_is_rich_escaped_so_markup_does_not_render() -> None:
|
||||
"""An adversarial agent name containing Rich markup tags must
|
||||
render literally, not interpreted. Without ``escape()``, a name
|
||||
like ``[bold red]ghost[/]`` would visually mimic a styled cell
|
||||
and could mask other dashboard content."""
|
||||
records = [
|
||||
AgentRecord(name="[bold red]ghost[/]", pid=1, command="bin"),
|
||||
]
|
||||
_, out = _render(records)
|
||||
# Literal brackets survive in the rendered output:
|
||||
assert "[bold red]ghost[/]" in out
|
||||
|
||||
|
||||
# Resilience to a schema-invalid ``agents.yaml`` is now enforced by
|
||||
# the sampler's catch-all around ``load_agents_config`` (see
|
||||
# ``test_sampler.py``); the view no longer touches the config.
|
||||
@@ -0,0 +1,318 @@
|
||||
"""End-to-end integration tests for the ``/fleet`` token wiring (#2023).
|
||||
|
||||
These tests drive the *real* pipeline — provider resolver, real
|
||||
:class:`ClaudeCodeJsonlSource` / :class:`CodexRolloutSource`, real
|
||||
:class:`ClaudeCodeMeter` / :class:`CodexMeter`, real
|
||||
:class:`TokenRateTracker`, real ``_resolved_model_for_pid``, real
|
||||
``_format_tokens_per_min`` / ``_format_usd_per_hour`` — against a
|
||||
fixture on disk.
|
||||
|
||||
The only stubs are :func:`tools.system.fleet_monitoring.probe.cwd_for_pid` and
|
||||
:func:`started_at_for_pid` (no real PIDs in a unit test) and the
|
||||
``AgentRegistry`` lookup (no real ``~/.opensre/agents.jsonl``).
|
||||
Everything else exercises production code.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from rich.console import Console
|
||||
|
||||
from surfaces.interactive_shell.ui.agents.agents_view import _build_agents_table
|
||||
from tools.system.fleet_monitoring import sampler as sampler_mod
|
||||
from tools.system.fleet_monitoring.probe import ProcessSnapshot
|
||||
from tools.system.fleet_monitoring.registry import AgentRecord
|
||||
from tools.system.fleet_monitoring.token_rate import TOKEN_RATE_TRACKER
|
||||
from tools.system.fleet_monitoring.token_sources import claude_code as claude_source_mod
|
||||
from tools.system.fleet_monitoring.token_sources import codex as codex_source_mod
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_sampler_globals() -> None:
|
||||
"""Same isolation pattern as the unit tests in this directory."""
|
||||
sampler_mod._latest.clear()
|
||||
sampler_mod._TickCache.registry_snapshot = {}
|
||||
sampler_mod._TickCache.agents_config = None
|
||||
for pid in list(TOKEN_RATE_TRACKER.known_pids()):
|
||||
TOKEN_RATE_TRACKER.forget(pid)
|
||||
|
||||
|
||||
def _render(records: list[AgentRecord]) -> str:
|
||||
table = _build_agents_table(records)
|
||||
buf = io.StringIO()
|
||||
Console(file=buf, force_terminal=False, highlight=False, width=140).print(table)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
# ---------- Claude Code end-to-end ----------------------------------
|
||||
|
||||
|
||||
def test_claude_code_end_to_end_renders_real_tokens_and_cost(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Real pipeline: psutil-stubbed cwd → real JSONL on disk →
|
||||
real source → real meter → real tracker → real view formatter.
|
||||
The view shows live ``tokens/min`` and ``$/hr`` cells, not ``-``.
|
||||
"""
|
||||
# Build a Claude Code project directory and session JSONL.
|
||||
fake_cwd = tmp_path / "repo"
|
||||
fake_cwd.mkdir()
|
||||
projects_root = tmp_path / "claude_projects"
|
||||
project_dir = projects_root / str(fake_cwd).replace("/", "-")
|
||||
project_dir.mkdir(parents=True)
|
||||
session = project_dir / "session-abc.jsonl"
|
||||
# Start with one historical event — the source seeks past it on
|
||||
# first read (no retro-pricing) so it should not contribute.
|
||||
session.write_text(
|
||||
'{"type":"system","subtype":"init","session_id":"abc","model":"claude-sonnet-4-5"}\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# Stub psutil-fenced helpers — no real PID lookups in unit tests.
|
||||
monkeypatch.setattr(
|
||||
"tools.system.fleet_monitoring.token_sources.claude_code.cwd_for_pid",
|
||||
lambda _pid: fake_cwd,
|
||||
)
|
||||
# After the Greptile-driven fix, the claude source requires
|
||||
# fd-level evidence to attribute a JSONL to a PID (no more
|
||||
# silent fallback to newest-by-mtime — mirrors codex).
|
||||
monkeypatch.setattr(
|
||||
"tools.system.fleet_monitoring.token_sources.claude_code.open_files_for_pid",
|
||||
lambda _pid: (session,),
|
||||
)
|
||||
|
||||
# Wire a fresh source against the test projects root so the
|
||||
# production singleton (which would hit ``~/.claude/projects/``)
|
||||
# never enters this test's resolution path.
|
||||
isolated_source = claude_source_mod.ClaudeCodeJsonlSource(projects_root=projects_root)
|
||||
monkeypatch.setattr(
|
||||
"tools.system.fleet_monitoring.sampler.get_token_source",
|
||||
lambda provider: isolated_source if provider == "claude-code" else None,
|
||||
)
|
||||
|
||||
# Registered agent: Claude Code at PID 8421.
|
||||
record = AgentRecord(
|
||||
name="claude-code-8421",
|
||||
pid=8421,
|
||||
command="claude --output-format stream-json",
|
||||
provider="claude-code",
|
||||
)
|
||||
|
||||
class _Registry:
|
||||
def list(self) -> list[AgentRecord]:
|
||||
return [record]
|
||||
|
||||
def get(self, pid: int) -> AgentRecord | None:
|
||||
return record if pid == 8421 else None
|
||||
|
||||
monkeypatch.setattr("tools.system.fleet_monitoring.sampler.AgentRegistry", _Registry)
|
||||
|
||||
# Stub probe so the snapshot path is also alive (uptime/cpu%).
|
||||
fake_snapshot = ProcessSnapshot(
|
||||
pid=8421,
|
||||
cpu_percent=18.4,
|
||||
rss_mb=128.0,
|
||||
num_fds=42,
|
||||
num_connections=3,
|
||||
status="running",
|
||||
started_at=datetime(2026, 5, 14, 10, 0, 0, tzinfo=UTC),
|
||||
)
|
||||
monkeypatch.setattr("tools.system.fleet_monitoring.sampler.probe", lambda _pid: fake_snapshot)
|
||||
|
||||
# First sampler tick: cold-start the source (seeks to EOF) and
|
||||
# populate registry caches. No tokens yet — historical content
|
||||
# below EOF is correctly ignored.
|
||||
sampler_mod._TickCache.registry_snapshot[record.pid] = record
|
||||
sampler_mod._TickCache.agents_config = None
|
||||
sampler_mod._sample_tokens(record)
|
||||
|
||||
# Append a real Claude assistant turn with usage. This is the
|
||||
# delta the next sample call should pick up.
|
||||
with session.open("a", encoding="utf-8") as fh:
|
||||
fh.write(
|
||||
'{"type":"assistant","message":{"model":"claude-sonnet-4-5",'
|
||||
'"usage":{"input_tokens":200,"output_tokens":50}}}\n'
|
||||
)
|
||||
# Brief wait so mtime > started_at - 5s on every filesystem
|
||||
# (APFS sub-second mtime quirks rarely matter, but cheap).
|
||||
time.sleep(0.01)
|
||||
|
||||
sampler_mod._sample_tokens(record)
|
||||
|
||||
# Render through the real view.
|
||||
out = _render([record])
|
||||
|
||||
# ``tokens/min`` populated: 250 tokens in a 60 s window → 250.
|
||||
assert "250" in out
|
||||
|
||||
# ``$/hr`` populated with a real dollar figure for sonnet-4-5
|
||||
# (3 USD/M input × 0.7 + 15 USD/M output × 0.3 blend ≈ 6.6e-6
|
||||
# per token; 250 tok/min × 60 × 6.6e-6 ≈ $0.099). The actual
|
||||
# rendering rounds to two decimals, so look for the ``$`` prefix.
|
||||
assert "$0.0" in out or "$0.1" in out
|
||||
|
||||
# And the placeholder is gone for the metric columns.
|
||||
assert "claude-code-8421" in out
|
||||
|
||||
|
||||
def test_claude_code_end_to_end_shows_dash_when_source_resolves_nothing(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""When psutil cannot read the cwd (macOS hardened-runtime
|
||||
denials, cross-user processes), the dashboard stays honest:
|
||||
``tokens/min`` and ``$/hr`` render ``-`` rather than ``0`` or a
|
||||
bogus number.
|
||||
"""
|
||||
monkeypatch.setattr(
|
||||
"tools.system.fleet_monitoring.token_sources.claude_code.cwd_for_pid",
|
||||
lambda _pid: None,
|
||||
)
|
||||
isolated_source = claude_source_mod.ClaudeCodeJsonlSource(
|
||||
projects_root=tmp_path / "claude_projects",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"tools.system.fleet_monitoring.sampler.get_token_source",
|
||||
lambda provider: isolated_source if provider == "claude-code" else None,
|
||||
)
|
||||
|
||||
record = AgentRecord(
|
||||
name="claude-code-8421",
|
||||
pid=8421,
|
||||
command="claude",
|
||||
provider="claude-code",
|
||||
)
|
||||
|
||||
class _Registry:
|
||||
def list(self) -> list[AgentRecord]:
|
||||
return [record]
|
||||
|
||||
def get(self, pid: int) -> AgentRecord | None:
|
||||
return record if pid == 8421 else None
|
||||
|
||||
monkeypatch.setattr("tools.system.fleet_monitoring.sampler.AgentRegistry", _Registry)
|
||||
monkeypatch.setattr("tools.system.fleet_monitoring.sampler.probe", lambda _pid: None)
|
||||
|
||||
sampler_mod._TickCache.registry_snapshot[record.pid] = record
|
||||
sampler_mod._sample_tokens(record)
|
||||
sampler_mod._sample_tokens(record)
|
||||
|
||||
# Drive the real render and inspect the actual table cells rather
|
||||
# than substring-matching the printed form (which contains the
|
||||
# ``$/hr`` header literal).
|
||||
table = _build_agents_table([record])
|
||||
assert table.row_count == 1
|
||||
rendered_cells = [list(col.cells)[0] for col in table.columns]
|
||||
# cells[0]=agent, [1]=pid, [2..6]=uptime/cpu/tokens/min/$/hr/status.
|
||||
# Every metric cell — including ``$/hr`` — falls back to ``-``.
|
||||
assert rendered_cells[2:] == ["-", "-", "-", "-", "-"]
|
||||
# The printed form should still include the agent name (so
|
||||
# rendering didn't silently bail).
|
||||
out = _render([record])
|
||||
assert "claude-code-8421" in out
|
||||
|
||||
|
||||
# ---------- Codex end-to-end ----------------------------------------
|
||||
|
||||
|
||||
def test_codex_end_to_end_renders_real_tokens_and_cost(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Mirrors the Claude Code test for the Codex rollout source."""
|
||||
codex_home = tmp_path / "codex_home"
|
||||
started_at_epoch = time.time() - 30 # process started 30 s ago
|
||||
started_dt = datetime.fromtimestamp(started_at_epoch, tz=UTC)
|
||||
rollout_dir = (
|
||||
codex_home
|
||||
/ "sessions"
|
||||
/ started_dt.strftime("%Y")
|
||||
/ started_dt.strftime("%m")
|
||||
/ started_dt.strftime("%d")
|
||||
)
|
||||
rollout_dir.mkdir(parents=True)
|
||||
rollout = rollout_dir / "rollout-001-abc.jsonl"
|
||||
rollout.write_text(
|
||||
'{"type":"thread.started","model":"gpt-5-codex"}\n{"type":"turn.started"}\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
import os as _os
|
||||
|
||||
_os.utime(rollout, (started_at_epoch + 1, started_at_epoch + 1))
|
||||
|
||||
monkeypatch.setattr(
|
||||
"tools.system.fleet_monitoring.token_sources.codex.started_at_for_pid",
|
||||
lambda _pid: started_at_epoch,
|
||||
)
|
||||
# Source now requires fd-level confirmation that the PID owns
|
||||
# the rollout (production: each codex holds its rollout fd open).
|
||||
monkeypatch.setattr(
|
||||
"tools.system.fleet_monitoring.token_sources.codex.open_files_for_pid",
|
||||
lambda _pid: (rollout,),
|
||||
)
|
||||
isolated_source = codex_source_mod.CodexRolloutSource(codex_home=codex_home)
|
||||
monkeypatch.setattr(
|
||||
"tools.system.fleet_monitoring.sampler.get_token_source",
|
||||
lambda provider: isolated_source if provider == "codex" else None,
|
||||
)
|
||||
|
||||
record = AgentRecord(
|
||||
name="codex-9999",
|
||||
pid=9999,
|
||||
command="codex exec",
|
||||
provider="codex",
|
||||
)
|
||||
|
||||
class _Registry:
|
||||
def list(self) -> list[AgentRecord]:
|
||||
return [record]
|
||||
|
||||
def get(self, pid: int) -> AgentRecord | None:
|
||||
return record if pid == 9999 else None
|
||||
|
||||
monkeypatch.setattr("tools.system.fleet_monitoring.sampler.AgentRegistry", _Registry)
|
||||
|
||||
fake_snapshot = ProcessSnapshot(
|
||||
pid=9999,
|
||||
cpu_percent=4.2,
|
||||
rss_mb=64.0,
|
||||
num_fds=20,
|
||||
num_connections=1,
|
||||
status="running",
|
||||
started_at=started_dt,
|
||||
)
|
||||
monkeypatch.setattr("tools.system.fleet_monitoring.sampler.probe", lambda _pid: fake_snapshot)
|
||||
|
||||
sampler_mod._TickCache.registry_snapshot[record.pid] = record
|
||||
sampler_mod._TickCache.agents_config = None
|
||||
sampler_mod._sample_tokens(record)
|
||||
|
||||
# Append a real per-turn token_count event (the on-disk format
|
||||
# codex-cli 0.130.0 writes — distinct from ``codex exec --json``
|
||||
# stdout). ``turn_context`` carries the model; ``event_msg`` with
|
||||
# ``payload.type == "token_count"`` carries ``last_token_usage``.
|
||||
with rollout.open("a", encoding="utf-8") as fh:
|
||||
fh.write(
|
||||
'{"type":"turn_context","payload":{"turn_id":"t_1","model":"gpt-5-codex"}}\n'
|
||||
'{"type":"event_msg","payload":{"type":"token_count","info":'
|
||||
'{"last_token_usage":'
|
||||
'{"input_tokens":175,"cached_input_tokens":0,'
|
||||
'"output_tokens":50,"reasoning_output_tokens":0,"total_tokens":225}}}}\n'
|
||||
)
|
||||
time.sleep(0.01)
|
||||
|
||||
sampler_mod._sample_tokens(record)
|
||||
|
||||
out = _render([record])
|
||||
|
||||
# 225 tokens (input + output) over a 60 s window.
|
||||
assert "225" in out
|
||||
# Real cost figure from the gpt-5-codex price table.
|
||||
assert "$0.0" in out or "$0.1" in out
|
||||
assert "codex-9999" in out
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Tests for the interactive-shell launch banner."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
from surfaces.interactive_shell.ui.banner import banner as banner_module
|
||||
from surfaces.interactive_shell.ui.banner import banner_state as banner_state_module
|
||||
from surfaces.interactive_shell.ui.components import rendering as rendering_module
|
||||
|
||||
|
||||
def test_banner_shows_ollama_model(monkeypatch: object) -> None:
|
||||
monkeypatch.setenv("LLM_PROVIDER", "ollama")
|
||||
monkeypatch.setenv("OLLAMA_MODEL", "qwen2.5:7b")
|
||||
console_file = io.StringIO()
|
||||
console = Console(file=console_file, force_terminal=False, highlight=False)
|
||||
|
||||
banner_module.render_banner(console)
|
||||
|
||||
output = console_file.getvalue()
|
||||
assert "ollama" in output
|
||||
assert "qwen2.5:7b" in output
|
||||
assert "ollama · default" not in output
|
||||
|
||||
|
||||
def test_ready_box_uses_active_theme_palette() -> None:
|
||||
from platform.terminal.theme import set_active_theme
|
||||
|
||||
set_active_theme("pink")
|
||||
pink_rgb = "255;179;217"
|
||||
green_rgb = "185;237;175"
|
||||
|
||||
console = Console(record=True, width=120)
|
||||
console.print(banner_module.build_ready_panel(console))
|
||||
|
||||
styled = console.export_text(styles=True)
|
||||
assert pink_rgb in styled
|
||||
assert green_rgb not in styled
|
||||
|
||||
|
||||
def test_splash_gradient_style_for_gradient_theme() -> None:
|
||||
from platform.terminal.theme import set_active_theme
|
||||
|
||||
set_active_theme("webflux")
|
||||
assert banner_module._splash_block_style(1, 3) == "bold #864C96"
|
||||
assert banner_module._splash_block_style(0, 1) == "bold #E23636"
|
||||
|
||||
|
||||
def test_splash_gradient_style_falls_back_to_highlight_without_gradient() -> None:
|
||||
from platform.terminal.theme import HIGHLIGHT, set_active_theme
|
||||
|
||||
set_active_theme("nord")
|
||||
assert banner_module._splash_block_style(0, 1) == f"bold {HIGHLIGHT}"
|
||||
|
||||
|
||||
def test_refresh_welcome_poster_uses_repl_safe_render(monkeypatch: object) -> None:
|
||||
console = Console(record=True, width=120)
|
||||
render_calls: list[dict[str, object | None]] = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
"surfaces.interactive_shell.ui.components.rendering.repl_clear_screen",
|
||||
lambda: None,
|
||||
)
|
||||
|
||||
def _fake_render(
|
||||
_console: Console,
|
||||
*,
|
||||
session: object = None,
|
||||
theme_notice: str | None = None,
|
||||
) -> None:
|
||||
render_calls.append({"session": session, "theme_notice": theme_notice})
|
||||
|
||||
monkeypatch.setattr(
|
||||
"surfaces.interactive_shell.ui.components.rendering.repl_render_launch_poster",
|
||||
_fake_render,
|
||||
)
|
||||
|
||||
rendering_module.refresh_welcome_poster(console, session="sess", theme_notice="pink")
|
||||
|
||||
assert render_calls == [{"session": "sess", "theme_notice": "pink"}]
|
||||
|
||||
|
||||
def test_get_username_prefers_github_handle(monkeypatch: object) -> None:
|
||||
monkeypatch.setattr(banner_module, "_github_username", lambda: "octocat")
|
||||
monkeypatch.setattr(banner_module.getpass, "getuser", lambda: "system-user")
|
||||
|
||||
assert banner_module._get_username() == "octocat"
|
||||
|
||||
|
||||
def test_get_username_falls_back_to_system_user(monkeypatch: object) -> None:
|
||||
monkeypatch.setattr(banner_module, "_github_username", lambda: "")
|
||||
monkeypatch.setattr(banner_module.getpass, "getuser", lambda: "system-user")
|
||||
|
||||
assert banner_module._get_username() == "system-user"
|
||||
|
||||
|
||||
def test_github_username_reads_saved_credential(monkeypatch: object) -> None:
|
||||
monkeypatch.setattr(
|
||||
"integrations.store.get_integration",
|
||||
lambda service: {"credentials": {"username": "octocat"}} if service == "github" else None,
|
||||
)
|
||||
|
||||
assert banner_module._github_username() == "octocat"
|
||||
|
||||
|
||||
def test_github_username_empty_when_not_configured(monkeypatch: object) -> None:
|
||||
monkeypatch.setattr("integrations.store.get_integration", lambda _service: None)
|
||||
|
||||
assert banner_module._github_username() == ""
|
||||
|
||||
|
||||
def test_github_username_survives_identity_import_failure(monkeypatch: object) -> None:
|
||||
import builtins
|
||||
|
||||
real_import = builtins.__import__
|
||||
|
||||
def _fail_github_identity(name: str, *args: object, **kwargs: object) -> object:
|
||||
if name == "integrations.github.identity":
|
||||
raise ImportError("simulated heavy import failure")
|
||||
return real_import(name, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(builtins, "__import__", _fail_github_identity)
|
||||
|
||||
assert banner_module._github_username() == ""
|
||||
|
||||
|
||||
def test_ambient_column_marks_incomplete_integration(monkeypatch: object) -> None:
|
||||
# A hosted MCP record saved without an API token is "present" but cannot
|
||||
# connect; the banner must mark it rather than imply it works.
|
||||
monkeypatch.setattr(
|
||||
banner_state_module,
|
||||
"_load_integration_health",
|
||||
lambda: [("Sentry", "ok"), ("Posthog_Mcp", "incomplete")],
|
||||
)
|
||||
monkeypatch.setattr(banner_state_module, "_is_alert_listener_active", lambda: False)
|
||||
|
||||
text = banner_state_module._build_ambient_right_column().plain
|
||||
|
||||
assert "Sentry" in text
|
||||
assert "Posthog_Mcp ⚠" in text
|
||||
assert "⚠ incomplete — run /integrations verify" in text
|
||||
|
||||
|
||||
def test_ambient_column_no_warning_when_all_healthy(monkeypatch: object) -> None:
|
||||
monkeypatch.setattr(
|
||||
banner_state_module,
|
||||
"_load_integration_health",
|
||||
lambda: [("Sentry", "ok"), ("GitHub", "ok")],
|
||||
)
|
||||
monkeypatch.setattr(banner_state_module, "_is_alert_listener_active", lambda: False)
|
||||
|
||||
text = banner_state_module._build_ambient_right_column().plain
|
||||
|
||||
assert "Sentry" in text
|
||||
assert "GitHub" in text
|
||||
assert "⚠" not in text
|
||||
|
||||
|
||||
def test_ready_box_expands_to_console_width() -> None:
|
||||
console_file = io.StringIO()
|
||||
console = Console(file=console_file, force_terminal=False, highlight=False, width=120)
|
||||
|
||||
banner_module.render_ready_box(console)
|
||||
|
||||
lines = [
|
||||
line for line in console_file.getvalue().splitlines() if line.startswith(("╭", "╰", "│"))
|
||||
]
|
||||
assert lines
|
||||
assert max(len(line) for line in lines) == 120
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Tests for inline raw-terminal choice menu rendering."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import re
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
|
||||
from surfaces.interactive_shell.ui.components import choice_menu
|
||||
|
||||
_ANSI_RE = re.compile(r"\x1b\[[0-9;:]*[A-Za-z]")
|
||||
|
||||
|
||||
def test_draw_menu_uses_carriage_return_newlines(monkeypatch) -> None:
|
||||
"""Raw-mode terminals do not translate LF to CRLF for us.
|
||||
|
||||
Plain ``\n`` makes each line begin at the previous line's ending column,
|
||||
which renders the picker as a diagonal staircase. The inline menu should
|
||||
write explicit ``\r\n`` newlines and reset to column zero for every row.
|
||||
"""
|
||||
out = io.StringIO()
|
||||
monkeypatch.setattr(sys, "stdout", out)
|
||||
monkeypatch.setattr(choice_menu, "_cols", lambda: 80)
|
||||
|
||||
choice_menu._draw_menu(
|
||||
title="integrations",
|
||||
crumb="/integrations",
|
||||
labels=["/integrations list", "/integrations verify"],
|
||||
index=0,
|
||||
erase_lines=0,
|
||||
)
|
||||
|
||||
rendered = out.getvalue()
|
||||
plain = _ANSI_RE.sub("", rendered)
|
||||
assert "\n" in rendered
|
||||
assert all(rendered[index - 1] == "\r" for index, char in enumerate(rendered) if char == "\n")
|
||||
assert "\rintegrations" in plain
|
||||
assert "\r/integrations" in plain
|
||||
assert "\r > /integrations list" in plain
|
||||
|
||||
|
||||
def test_erase_menu_block_resets_to_column_zero(monkeypatch) -> None:
|
||||
out = io.StringIO()
|
||||
monkeypatch.setattr(sys, "stdout", out)
|
||||
|
||||
choice_menu._erase_menu("crumb", ["one", "two"])
|
||||
|
||||
rendered = out.getvalue()
|
||||
assert rendered.startswith("\r\x1b[")
|
||||
assert "A\r\x1b[J" in rendered
|
||||
assert rendered.endswith("\r")
|
||||
|
||||
|
||||
def test_reset_tty_column_writes_carriage_return(monkeypatch) -> None:
|
||||
out = io.StringIO()
|
||||
monkeypatch.setattr(sys, "stdout", out)
|
||||
|
||||
choice_menu.reset_tty_column()
|
||||
|
||||
assert out.getvalue() == "\r"
|
||||
|
||||
|
||||
def test_pick_ignores_unmapped_keys(monkeypatch) -> None:
|
||||
out = io.StringIO()
|
||||
actions = iter(["ignore", "enter"])
|
||||
monkeypatch.setattr(sys, "stdout", out)
|
||||
monkeypatch.setattr(choice_menu, "_cols", lambda: 80)
|
||||
monkeypatch.setattr(choice_menu, "_read_action", lambda: next(actions))
|
||||
|
||||
assert choice_menu._pick(title="test", crumb="", labels=["one"]) == 0
|
||||
|
||||
rendered = out.getvalue()
|
||||
assert rendered.count("test") == 2
|
||||
assert "A\r\x1b[J" in rendered
|
||||
|
||||
|
||||
def test_read_action_treats_space_as_enter(monkeypatch) -> None:
|
||||
monkeypatch.setattr(choice_menu.os, "name", "nt")
|
||||
monkeypatch.setitem(sys.modules, "msvcrt", SimpleNamespace(getch=lambda: b" "))
|
||||
|
||||
assert choice_menu._read_action() == "enter"
|
||||
|
||||
|
||||
def test_read_action_treats_right_arrow_as_enter(monkeypatch) -> None:
|
||||
keys = iter([b"\xe0", b"M"])
|
||||
monkeypatch.setattr(choice_menu.os, "name", "nt")
|
||||
monkeypatch.setitem(sys.modules, "msvcrt", SimpleNamespace(getch=lambda: next(keys)))
|
||||
|
||||
assert choice_menu._read_action() == "enter"
|
||||
|
||||
|
||||
def test_repl_choose_one_starts_at_initial_value(monkeypatch) -> None:
|
||||
out = io.StringIO()
|
||||
actions = iter(["enter"])
|
||||
monkeypatch.setattr(choice_menu, "repl_tty_interactive", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
"surfaces.interactive_shell.ui.components.cpr_stdin.drain_stale_cpr_bytes",
|
||||
lambda: None,
|
||||
)
|
||||
monkeypatch.setattr(sys, "stdout", out)
|
||||
monkeypatch.setattr(choice_menu, "_cols", lambda: 80)
|
||||
monkeypatch.setattr(choice_menu, "_read_action", lambda: next(actions))
|
||||
|
||||
result = choice_menu.repl_choose_one(
|
||||
title="theme",
|
||||
breadcrumb="/theme",
|
||||
choices=[("green", "green"), ("blue", "blue (current)"), ("pink", "pink")],
|
||||
initial_value="blue",
|
||||
)
|
||||
|
||||
assert result == "blue"
|
||||
plain = _ANSI_RE.sub("", out.getvalue())
|
||||
assert "> blue (current)" in plain
|
||||
|
||||
|
||||
def test_read_action_ignores_left_arrow(monkeypatch) -> None:
|
||||
keys = iter([b"\xe0", b"K"])
|
||||
monkeypatch.setattr(choice_menu.os, "name", "nt")
|
||||
monkeypatch.setitem(sys.modules, "msvcrt", SimpleNamespace(getch=lambda: next(keys)))
|
||||
|
||||
assert choice_menu._read_action() == "ignore"
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Tests for CPR stdin hygiene helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from surfaces.interactive_shell.ui.components.cpr_stdin import (
|
||||
contains_cpr_sequence,
|
||||
strip_cpr_sequences,
|
||||
)
|
||||
|
||||
|
||||
@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", ""),
|
||||
],
|
||||
)
|
||||
def test_strip_cpr_sequences_removes_terminal_cursor_replies(
|
||||
text: str,
|
||||
expected: str,
|
||||
) -> None:
|
||||
assert strip_cpr_sequences(text) == expected
|
||||
|
||||
|
||||
def test_contains_cpr_sequence_detects_leaked_bytes() -> None:
|
||||
assert contains_cpr_sequence("\x1b[12;80R")
|
||||
assert not contains_cpr_sequence("plain prompt text")
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Tests for the execution-policy interaction layer (``execution_allowed``).
|
||||
|
||||
These cover the terminal-facing half of the execution gate: console output and
|
||||
the confirmation prompt. The pure decision is tested in
|
||||
``tests/tools/interactive_shell/shared/test_execution_policy.py``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
from surfaces.interactive_shell.session import Session
|
||||
from surfaces.interactive_shell.ui.execution_confirm import execution_allowed
|
||||
from tools.interactive_shell.shared import (
|
||||
ExecutionPolicyResult,
|
||||
allow_tool,
|
||||
)
|
||||
|
||||
|
||||
def _ask_result() -> ExecutionPolicyResult:
|
||||
"""An explicit ``ask`` verdict (the default policy no longer emits these)."""
|
||||
return ExecutionPolicyResult(
|
||||
verdict="ask",
|
||||
tool_type="slash",
|
||||
reason="this command may change configuration or run heavy work",
|
||||
)
|
||||
|
||||
|
||||
# --- execution_allowed: default-allow runs without prompting ----------------
|
||||
|
||||
|
||||
def test_allow_verdict_runs_without_prompt() -> None:
|
||||
session = Session()
|
||||
buf = io.StringIO()
|
||||
console = Console(file=buf, force_terminal=False)
|
||||
|
||||
def _confirm(_: str) -> str: # pragma: no cover - must never be called
|
||||
raise AssertionError("default-allow must not prompt for confirmation")
|
||||
|
||||
r = allow_tool("slash")
|
||||
assert execution_allowed(
|
||||
r,
|
||||
session=session,
|
||||
console=console,
|
||||
action_summary="/integrations verify foo",
|
||||
confirm_fn=_confirm,
|
||||
is_tty=True,
|
||||
)
|
||||
assert "Confirm" not in buf.getvalue()
|
||||
|
||||
|
||||
def test_non_tty_allows_default_policy() -> None:
|
||||
"""Default-allow no longer fails closed on non-interactive stdin."""
|
||||
session = Session()
|
||||
buf = io.StringIO()
|
||||
console = Console(file=buf, force_terminal=False)
|
||||
r = allow_tool("slash")
|
||||
assert execution_allowed(
|
||||
r,
|
||||
session=session,
|
||||
console=console,
|
||||
action_summary="/save out.md",
|
||||
is_tty=False,
|
||||
)
|
||||
|
||||
|
||||
def test_deny_verdict_blocks() -> None:
|
||||
session = Session()
|
||||
buf = io.StringIO()
|
||||
console = Console(file=buf, force_terminal=False)
|
||||
# The default policy never emits a deny; construct one explicitly to cover
|
||||
# the execution_allowed deny path.
|
||||
r = ExecutionPolicyResult(
|
||||
verdict="deny",
|
||||
tool_type="shell",
|
||||
reason="empty command.",
|
||||
hint="Enter a command to run.",
|
||||
)
|
||||
assert not execution_allowed(
|
||||
r,
|
||||
session=session,
|
||||
console=console,
|
||||
action_summary="!",
|
||||
is_tty=True,
|
||||
)
|
||||
assert "blocked" in buf.getvalue()
|
||||
|
||||
|
||||
# --- Retained ask machinery (reachable only via explicit ask) ---------------
|
||||
|
||||
|
||||
def test_explicit_ask_trust_mode_allows() -> None:
|
||||
session = Session()
|
||||
session.terminal.trust_mode = True
|
||||
buf = io.StringIO()
|
||||
console = Console(file=buf, force_terminal=False)
|
||||
assert execution_allowed(
|
||||
_ask_result(),
|
||||
session=session,
|
||||
console=console,
|
||||
action_summary="/investigate x",
|
||||
confirm_fn=lambda _: "n",
|
||||
is_tty=True,
|
||||
)
|
||||
|
||||
|
||||
def test_explicit_ask_non_tty_blocks() -> None:
|
||||
session = Session()
|
||||
session.terminal.trust_mode = False
|
||||
buf = io.StringIO()
|
||||
console = Console(file=buf, force_terminal=False)
|
||||
assert not execution_allowed(
|
||||
_ask_result(),
|
||||
session=session,
|
||||
console=console,
|
||||
action_summary="/save out.md",
|
||||
is_tty=False,
|
||||
)
|
||||
assert "not a TTY" in buf.getvalue()
|
||||
|
||||
|
||||
def test_explicit_ask_tty_accepts_empty_confirmation() -> None:
|
||||
session = Session()
|
||||
buf = io.StringIO()
|
||||
console = Console(file=buf, force_terminal=False)
|
||||
captured: list[str] = []
|
||||
|
||||
def _confirm(prompt: str) -> str:
|
||||
captured.append(prompt)
|
||||
return ""
|
||||
|
||||
assert execution_allowed(
|
||||
_ask_result(),
|
||||
session=session,
|
||||
console=console,
|
||||
action_summary="/integrations verify foo",
|
||||
confirm_fn=_confirm,
|
||||
is_tty=True,
|
||||
)
|
||||
assert captured == ["Proceed? [Y/n] "]
|
||||
|
||||
|
||||
def test_explicit_ask_tty_rejects_explicit_no() -> None:
|
||||
session = Session()
|
||||
buf = io.StringIO()
|
||||
console = Console(file=buf, force_terminal=False)
|
||||
assert not execution_allowed(
|
||||
_ask_result(),
|
||||
session=session,
|
||||
console=console,
|
||||
action_summary="/integrations verify foo",
|
||||
confirm_fn=lambda _: "n",
|
||||
is_tty=True,
|
||||
)
|
||||
assert "cancelled" in buf.getvalue()
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Tests for post-investigation feedback UI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from rich.console import Console
|
||||
|
||||
from surfaces.interactive_shell.ui.feedback import (
|
||||
_CHOICES,
|
||||
_format_root_cause_lines,
|
||||
_print_context,
|
||||
_root_cause_width,
|
||||
_run_select,
|
||||
)
|
||||
|
||||
|
||||
def _fixed_terminal_size(*_args: object, **_kwargs: object) -> os.terminal_size:
|
||||
return os.terminal_size((60, 24))
|
||||
|
||||
|
||||
def _wide_terminal_size(*_args: object, **_kwargs: object) -> os.terminal_size:
|
||||
return os.terminal_size((160, 24))
|
||||
|
||||
|
||||
def test_root_cause_width_uses_full_terminal_not_capped(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr("shutil.get_terminal_size", _wide_terminal_size)
|
||||
|
||||
assert _root_cause_width(console=None) == 160
|
||||
|
||||
|
||||
def test_print_context_stdout_uses_full_terminal_width(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
monkeypatch.setattr("shutil.get_terminal_size", _wide_terminal_size)
|
||||
root = "Schema validation failed because payment_method is missing."
|
||||
|
||||
_print_context({"root_cause": root}, console=None)
|
||||
|
||||
captured = capsys.readouterr().out
|
||||
assert captured.startswith("\n" + "─" * 160 + "\n")
|
||||
assert captured.rstrip().endswith("─" * 160)
|
||||
|
||||
|
||||
def test_format_root_cause_lines_wraps_long_text_without_truncation() -> None:
|
||||
root = (
|
||||
"The Kubernetes job 'etl-transform-error' for pipeline "
|
||||
"'kubernetes_etl_pipeline' failed in namespace 'tracer-test' because "
|
||||
"schema validation requires 'payment_method'."
|
||||
)
|
||||
|
||||
lines = _format_root_cause_lines(root, cols=60)
|
||||
|
||||
assert len(lines) > 1
|
||||
assert all("…" not in line for line in lines)
|
||||
assert " ".join(line.strip() for line in lines) == f"Root cause: {root}"
|
||||
|
||||
|
||||
def test_print_context_shows_full_root_cause_in_rich_path() -> None:
|
||||
root = (
|
||||
"The Kubernetes job 'etl-transform-error' for pipeline "
|
||||
"'kubernetes_etl_pipeline' failed because schema validation requires "
|
||||
"'payment_method'."
|
||||
)
|
||||
buf = io.StringIO()
|
||||
console = Console(file=buf, force_terminal=False, width=60)
|
||||
|
||||
_print_context({"root_cause": root}, console=console)
|
||||
|
||||
output = buf.getvalue()
|
||||
assert root in output
|
||||
assert "…" not in output
|
||||
|
||||
|
||||
def test_print_context_escapes_rich_markup_in_root() -> None:
|
||||
root = "Pod restart [1/3] failed because schema validation requires 'payment_method'."
|
||||
buf = io.StringIO()
|
||||
console = Console(file=buf, force_terminal=False, width=80)
|
||||
|
||||
_print_context({"root_cause": root}, console=console)
|
||||
|
||||
output = buf.getvalue()
|
||||
assert "[1/3]" in output
|
||||
assert "…" not in output
|
||||
|
||||
|
||||
def test_print_context_shows_full_root_cause_in_stdout_path(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
monkeypatch.setattr("shutil.get_terminal_size", _fixed_terminal_size)
|
||||
root = (
|
||||
"The Kubernetes job 'etl-transform-error' for pipeline "
|
||||
"'kubernetes_etl_pipeline' failed because schema validation requires "
|
||||
"'payment_method'."
|
||||
)
|
||||
|
||||
_print_context({"root_cause": root}, console=None)
|
||||
|
||||
captured = capsys.readouterr().out
|
||||
assert "…" not in captured
|
||||
assert " ".join(captured.split()) == ("─" * 60 + " Root cause: " + root + " " + "─" * 60)
|
||||
|
||||
|
||||
def test_run_select_returns_highlighted_choice_on_enter(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
monkeypatch.setattr("sys.stdin.isatty", lambda: True)
|
||||
monkeypatch.setattr("sys.stdout.isatty", lambda: True)
|
||||
calls = {"n": 0}
|
||||
|
||||
def read_then_enter(**_kwargs: object) -> str:
|
||||
calls["n"] += 1
|
||||
return "enter" if calls["n"] > 1 else "down"
|
||||
|
||||
monkeypatch.setattr(
|
||||
"surfaces.interactive_shell.ui.feedback.read_key_unix",
|
||||
read_then_enter,
|
||||
)
|
||||
monkeypatch.setattr("surfaces.interactive_shell.ui.feedback.flush_stdin_unix", lambda: None)
|
||||
monkeypatch.setattr(
|
||||
"surfaces.interactive_shell.ui.feedback.restore_stdin_terminal",
|
||||
lambda: None,
|
||||
)
|
||||
|
||||
assert _run_select(_CHOICES) == "partial"
|
||||
captured = capsys.readouterr().out
|
||||
assert "Partially accurate" in captured
|
||||
assert "↑↓" in captured
|
||||
@@ -0,0 +1,359 @@
|
||||
"""Tests for the grouped /help picker renderer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import re
|
||||
import sys
|
||||
|
||||
from platform.terminal import theme as ui_theme
|
||||
from surfaces.interactive_shell.command_registry.types import SlashCommand
|
||||
from surfaces.interactive_shell.ui.help import help_menu
|
||||
|
||||
_ANSI_RE = re.compile(r"\x1b\[[0-9;:]*[A-Za-z]")
|
||||
|
||||
|
||||
def _cmd(name: str, description: str | None = None) -> SlashCommand:
|
||||
return SlashCommand(name, description or f"{name} description", lambda *_args: True)
|
||||
|
||||
|
||||
def test_flatten_help_rows_preserves_category_headers() -> None:
|
||||
rows = help_menu._flatten_help_rows(
|
||||
[
|
||||
("Session", [_cmd("/status")]),
|
||||
("Tasks", [_cmd("/tasks"), _cmd("/cancel")]),
|
||||
]
|
||||
)
|
||||
|
||||
assert [row.section for row in rows if row.section is not None] == ["Session", "Tasks"]
|
||||
assert sum(1 for row in rows if row.separator) == 1
|
||||
assert [row.command.name for row in rows if row.command is not None] == [
|
||||
"/status",
|
||||
"/tasks",
|
||||
"/cancel",
|
||||
]
|
||||
|
||||
|
||||
def test_navigation_skips_category_headers() -> None:
|
||||
rows = help_menu._flatten_help_rows(
|
||||
[
|
||||
("Session", [_cmd("/status")]),
|
||||
("Tasks", [_cmd("/tasks")]),
|
||||
]
|
||||
)
|
||||
|
||||
assert help_menu._first_selectable_index(rows) == 1
|
||||
assert help_menu._next_selectable_index(rows, 1, 1) == 4
|
||||
assert help_menu._next_selectable_index(rows, 4, -1) == 1
|
||||
|
||||
|
||||
def test_draw_help_menu_renders_horizontal_category_dividers(monkeypatch) -> None:
|
||||
rows = help_menu._flatten_help_rows(
|
||||
[
|
||||
("Session", [_cmd("/status")]),
|
||||
("Tasks", [_cmd("/tasks")]),
|
||||
]
|
||||
)
|
||||
out = io.StringIO()
|
||||
monkeypatch.setattr(sys, "stdout", out)
|
||||
monkeypatch.setattr(help_menu, "menu_columns", lambda: 40)
|
||||
|
||||
help_menu._draw_help_menu(
|
||||
rows,
|
||||
selected=1,
|
||||
expanded=None,
|
||||
erase_lines=0,
|
||||
viewport_height=7,
|
||||
)
|
||||
|
||||
plain = _ANSI_RE.sub("", out.getvalue())
|
||||
assert "Session" in plain
|
||||
assert "Tasks" in plain
|
||||
assert plain.count(help_menu._separator_rule(40)) >= 2
|
||||
assert "┼" in plain
|
||||
assert help_menu._render_grid_row("Session", "", 40) in plain
|
||||
assert help_menu._render_grid_row("Tasks", "", 40) in plain
|
||||
|
||||
|
||||
def test_section_rows_keep_divider_dim() -> None:
|
||||
rendered = help_menu._render_help_row(
|
||||
help_menu.HelpRow(section="Investigation"),
|
||||
selected=False,
|
||||
expanded=False,
|
||||
width=40,
|
||||
)
|
||||
|
||||
assert f"{ui_theme.BOLD_BRAND_ANSI}Investigation" in rendered
|
||||
assert f"{ui_theme.DIM_COUNTER_ANSI}│" in rendered
|
||||
|
||||
|
||||
def test_detail_rows_use_text_labels_dim_values_and_dim_divider() -> None:
|
||||
label = help_menu._render_display_row(
|
||||
help_menu.HelpDisplayRow(detail=help_menu.HelpDetailLine("usage:", "label")),
|
||||
selected=False,
|
||||
expanded=False,
|
||||
width=40,
|
||||
)
|
||||
value = help_menu._render_display_row(
|
||||
help_menu.HelpDisplayRow(detail=help_menu.HelpDetailLine(" /help", "value")),
|
||||
selected=False,
|
||||
expanded=False,
|
||||
width=40,
|
||||
)
|
||||
|
||||
assert f"{ui_theme.DIM_COUNTER_ANSI}{' ' * help_menu._left_column_width(40)}│ " in label
|
||||
assert f"{ui_theme.TEXT_ANSI}usage:" in label
|
||||
assert f"{ui_theme.DIM_COUNTER_ANSI}{' ' * help_menu._left_column_width(40)}│ " in value
|
||||
assert f"{ui_theme.DIM_COUNTER_ANSI} /help" in value
|
||||
|
||||
|
||||
def test_draw_help_menu_centers_title(monkeypatch) -> None:
|
||||
rows = help_menu._flatten_help_rows([("Session", [_cmd("/status")])])
|
||||
out = io.StringIO()
|
||||
monkeypatch.setattr(sys, "stdout", out)
|
||||
monkeypatch.setattr(help_menu, "menu_columns", lambda: 40)
|
||||
|
||||
help_menu._draw_help_menu(
|
||||
rows,
|
||||
selected=1,
|
||||
expanded=None,
|
||||
erase_lines=0,
|
||||
viewport_height=7,
|
||||
)
|
||||
|
||||
plain_lines = _ANSI_RE.sub("", out.getvalue()).splitlines()
|
||||
assert " Slash commands " in plain_lines
|
||||
|
||||
|
||||
def test_draw_help_menu_uses_bounded_viewport_and_category_context(monkeypatch) -> None:
|
||||
sections = [
|
||||
("Session", [_cmd(f"/session-{index}") for index in range(10)]),
|
||||
("Tasks", [_cmd(f"/task-{index}") for index in range(10)]),
|
||||
]
|
||||
rows = help_menu._flatten_help_rows(sections)
|
||||
selected = next(
|
||||
index for index, row in enumerate(rows) if row.command and row.command.name == "/task-2"
|
||||
)
|
||||
out = io.StringIO()
|
||||
monkeypatch.setattr(sys, "stdout", out)
|
||||
monkeypatch.setattr(help_menu, "menu_columns", lambda: 90)
|
||||
|
||||
height = help_menu._draw_help_menu(
|
||||
rows,
|
||||
selected=selected,
|
||||
expanded=None,
|
||||
erase_lines=0,
|
||||
viewport_height=7,
|
||||
)
|
||||
|
||||
rendered = out.getvalue()
|
||||
plain = _ANSI_RE.sub("", rendered)
|
||||
assert height == 13
|
||||
assert "Tasks" in plain
|
||||
assert "/task-2" in plain
|
||||
assert "/session-0" not in plain
|
||||
assert "/task-9" not in plain
|
||||
|
||||
|
||||
def test_draw_help_menu_expands_selected_command_inline(monkeypatch) -> None:
|
||||
command = SlashCommand(
|
||||
"/new",
|
||||
"Start a new session keeping conversation context.",
|
||||
lambda *_args: True,
|
||||
usage=("/new",),
|
||||
notes=("Unlike /clear, /new preserves the LLM conversation context.",),
|
||||
)
|
||||
rows = help_menu._flatten_help_rows([("Session", [command])])
|
||||
out = io.StringIO()
|
||||
monkeypatch.setattr(sys, "stdout", out)
|
||||
monkeypatch.setattr(help_menu, "menu_columns", lambda: 90)
|
||||
|
||||
help_menu._draw_help_menu(
|
||||
rows,
|
||||
selected=1,
|
||||
expanded=1,
|
||||
erase_lines=0,
|
||||
viewport_height=7,
|
||||
)
|
||||
|
||||
plain = _ANSI_RE.sub("", out.getvalue())
|
||||
assert "/new" in plain
|
||||
assert "> ▾ /new" in plain
|
||||
assert "│ Start a new session" in plain
|
||||
assert help_menu._render_grid_row("", "usage:", 90) in plain
|
||||
assert "usage:" in plain
|
||||
assert "LLM conversation context" in plain
|
||||
|
||||
|
||||
def test_draw_help_menu_marks_expandable_and_plain_commands(monkeypatch) -> None:
|
||||
expandable = SlashCommand(
|
||||
"/trust",
|
||||
"Manage trust mode.",
|
||||
lambda *_args: True,
|
||||
usage=("/trust on", "/trust off"),
|
||||
)
|
||||
plain_command = _cmd("/status", "Show session status.")
|
||||
rows = help_menu._flatten_help_rows([("Session", [expandable, plain_command])])
|
||||
out = io.StringIO()
|
||||
monkeypatch.setattr(sys, "stdout", out)
|
||||
monkeypatch.setattr(help_menu, "menu_columns", lambda: 90)
|
||||
|
||||
help_menu._draw_help_menu(
|
||||
rows,
|
||||
selected=1,
|
||||
expanded=None,
|
||||
erase_lines=0,
|
||||
viewport_height=7,
|
||||
)
|
||||
|
||||
plain = _ANSI_RE.sub("", out.getvalue())
|
||||
assert "> ▸ /trust" in plain
|
||||
assert "│ Manage trust mode." in plain
|
||||
assert " /status" in plain
|
||||
assert "▸ /status" not in plain
|
||||
|
||||
|
||||
def test_command_rows_highlight_only_unselected_command_name() -> None:
|
||||
rendered = help_menu._render_command_row(
|
||||
SlashCommand(
|
||||
"/trust",
|
||||
"Manage trust mode.",
|
||||
lambda *_args: True,
|
||||
usage=("/trust on", "/trust off"),
|
||||
),
|
||||
selected=False,
|
||||
expanded=False,
|
||||
width=60,
|
||||
)
|
||||
|
||||
assert (
|
||||
f"{ui_theme.DIM_COUNTER_ANSI} ▸ {ui_theme.ANSI_RESET}{ui_theme.HIGHLIGHT_ANSI}/trust"
|
||||
) in rendered
|
||||
assert f"{ui_theme.HIGHLIGHT_ANSI}▸" not in rendered
|
||||
|
||||
|
||||
def test_help_menu_command_rows_track_active_theme() -> None:
|
||||
from platform.terminal.theme import set_active_theme
|
||||
|
||||
set_active_theme("green")
|
||||
green_highlight = ui_theme.HIGHLIGHT_ANSI
|
||||
set_active_theme("purple")
|
||||
rendered = help_menu._render_command_row(
|
||||
SlashCommand("/trust", "Manage trust mode.", lambda *_args: True),
|
||||
selected=False,
|
||||
expanded=False,
|
||||
width=60,
|
||||
)
|
||||
|
||||
assert ui_theme.HIGHLIGHT_ANSI in rendered
|
||||
assert green_highlight not in rendered
|
||||
|
||||
|
||||
def test_expanded_detail_lines_include_all_usage_examples_and_notes() -> None:
|
||||
command = SlashCommand(
|
||||
"/model",
|
||||
"Show model settings.",
|
||||
lambda *_args: True,
|
||||
usage=tuple(f"/model action {index}" for index in range(10)),
|
||||
examples=("/model set openai",),
|
||||
notes=("Provider defaults can be restored.",),
|
||||
)
|
||||
|
||||
lines = help_menu._expanded_detail_lines(command)
|
||||
text_lines = [line.text for line in lines]
|
||||
|
||||
assert lines[0].role == "label"
|
||||
assert lines[1].role == "value"
|
||||
assert "/model action 0" in lines[1].text
|
||||
assert " /model action 9" in text_lines
|
||||
assert "examples:" in text_lines
|
||||
assert " /model set openai" in text_lines
|
||||
assert "notes:" in text_lines
|
||||
assert " Provider defaults can be restored." in text_lines
|
||||
assert "…" not in text_lines
|
||||
|
||||
|
||||
def test_draw_help_menu_expands_viewport_to_show_all_details(monkeypatch) -> None:
|
||||
command = SlashCommand(
|
||||
"/model",
|
||||
"Show model settings.",
|
||||
lambda *_args: True,
|
||||
usage=tuple(f"/model action {index}" for index in range(10)),
|
||||
)
|
||||
rows = help_menu._flatten_help_rows([("Models", [command, _cmd("/status")])])
|
||||
out = io.StringIO()
|
||||
monkeypatch.setattr(sys, "stdout", out)
|
||||
monkeypatch.setattr(help_menu, "menu_columns", lambda: 90)
|
||||
|
||||
height = help_menu._draw_help_menu(
|
||||
rows,
|
||||
selected=1,
|
||||
expanded=1,
|
||||
erase_lines=0,
|
||||
viewport_height=7,
|
||||
)
|
||||
|
||||
plain = _ANSI_RE.sub("", out.getvalue())
|
||||
assert height > 13
|
||||
assert "/model action 0" in plain
|
||||
assert "/model action 9" in plain
|
||||
assert "…" not in plain
|
||||
|
||||
|
||||
def test_choose_help_command_space_toggles_inline_details_and_exits(monkeypatch) -> None:
|
||||
sections = [
|
||||
(
|
||||
"Session",
|
||||
[
|
||||
_cmd("/status"),
|
||||
SlashCommand(
|
||||
"/trust",
|
||||
"Manage trust mode.",
|
||||
lambda *_args: True,
|
||||
usage=("/trust on", "/trust off"),
|
||||
),
|
||||
],
|
||||
)
|
||||
]
|
||||
out = io.StringIO()
|
||||
actions = iter(["down", "space", "cancel"])
|
||||
monkeypatch.setattr(sys, "stdout", out)
|
||||
monkeypatch.setattr(help_menu, "menu_columns", lambda: 80)
|
||||
monkeypatch.setattr(help_menu, "read_menu_action", lambda: next(actions))
|
||||
|
||||
selected = help_menu.choose_help_command(sections)
|
||||
|
||||
assert selected is None
|
||||
rendered = out.getvalue()
|
||||
assert "\x1b[" in rendered
|
||||
assert "A\r\x1b[J" in rendered
|
||||
plain = _ANSI_RE.sub("", rendered)
|
||||
assert "/trust on" in plain
|
||||
|
||||
|
||||
def test_choose_help_command_enter_selects_command_without_details(monkeypatch) -> None:
|
||||
sections = [("Session", [_cmd("/status")])]
|
||||
out = io.StringIO()
|
||||
actions = iter(["enter"])
|
||||
monkeypatch.setattr(sys, "stdout", out)
|
||||
monkeypatch.setattr(help_menu, "menu_columns", lambda: 80)
|
||||
monkeypatch.setattr(help_menu, "read_menu_action", lambda: next(actions))
|
||||
|
||||
assert help_menu.choose_help_command(sections) == "/status"
|
||||
|
||||
plain = _ANSI_RE.sub("", out.getvalue())
|
||||
assert "No additional usage." not in plain
|
||||
|
||||
|
||||
def test_choose_help_command_ignores_unknown_actions(monkeypatch) -> None:
|
||||
sections = [("Session", [_cmd("/status")])]
|
||||
out = io.StringIO()
|
||||
actions = iter(["ignore", "cancel"])
|
||||
monkeypatch.setattr(sys, "stdout", out)
|
||||
monkeypatch.setattr(help_menu, "menu_columns", lambda: 80)
|
||||
monkeypatch.setattr(help_menu, "read_menu_action", lambda: next(actions))
|
||||
|
||||
assert help_menu.choose_help_command(sections) is None
|
||||
|
||||
rendered = out.getvalue()
|
||||
assert rendered.count("Slash commands") == 2
|
||||
@@ -0,0 +1,316 @@
|
||||
"""Tests for incoming alert rendering in the REPL."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
from core.domain.alerts.inbox import AlertInbox, IncomingAlert
|
||||
from surfaces.interactive_shell.runtime import Session
|
||||
from surfaces.interactive_shell.ui.alerts import (
|
||||
drain_and_render_incoming,
|
||||
format_incoming_alert,
|
||||
time_ago,
|
||||
)
|
||||
|
||||
|
||||
class TestTimeAgo:
|
||||
"""Test time_ago helper."""
|
||||
|
||||
def test_seconds_ago(self) -> None:
|
||||
now = datetime.now(UTC)
|
||||
then = now - timedelta(seconds=5)
|
||||
result = time_ago(then)
|
||||
assert "5s ago" in result
|
||||
|
||||
def test_one_second_ago(self) -> None:
|
||||
now = datetime.now(UTC)
|
||||
then = now - timedelta(seconds=1)
|
||||
result = time_ago(then)
|
||||
assert "1s ago" in result
|
||||
|
||||
def test_minutes_ago(self) -> None:
|
||||
now = datetime.now(UTC)
|
||||
then = now - timedelta(minutes=3)
|
||||
result = time_ago(then)
|
||||
assert "3m ago" in result
|
||||
|
||||
def test_hours_ago(self) -> None:
|
||||
now = datetime.now(UTC)
|
||||
then = now - timedelta(hours=2)
|
||||
result = time_ago(then)
|
||||
assert "2h ago" in result
|
||||
|
||||
def test_days_ago(self) -> None:
|
||||
now = datetime.now(UTC)
|
||||
then = now - timedelta(days=1)
|
||||
result = time_ago(then)
|
||||
assert "1d ago" in result
|
||||
|
||||
def test_none_datetime(self) -> None:
|
||||
result = time_ago(None)
|
||||
assert result == "unknown"
|
||||
|
||||
|
||||
class TestFormatIncomingAlert:
|
||||
"""Test format_incoming_alert rendering."""
|
||||
|
||||
def test_renders_with_all_fields(self) -> None:
|
||||
alert = IncomingAlert(
|
||||
text="disk usage at 95%",
|
||||
alert_name="disk_alert",
|
||||
severity="critical",
|
||||
source="datadog-webhook",
|
||||
received_at=datetime.now(UTC),
|
||||
)
|
||||
renderable = format_incoming_alert(alert)
|
||||
# Just verify the alert object was created without error
|
||||
assert renderable is not None
|
||||
|
||||
def test_renders_with_minimal_fields(self) -> None:
|
||||
alert = IncomingAlert(text="something happened")
|
||||
renderable = format_incoming_alert(alert)
|
||||
# Just verify the alert object was created without error
|
||||
assert renderable is not None
|
||||
|
||||
def test_renders_without_source(self) -> None:
|
||||
alert = IncomingAlert(
|
||||
text="test alert",
|
||||
severity="warning",
|
||||
received_at=datetime.now(UTC),
|
||||
)
|
||||
renderable = format_incoming_alert(alert)
|
||||
# Just verify the alert object was created without error
|
||||
assert renderable is not None
|
||||
|
||||
def test_renders_without_severity(self) -> None:
|
||||
alert = IncomingAlert(
|
||||
text="test alert",
|
||||
source="custom",
|
||||
received_at=datetime.now(UTC),
|
||||
)
|
||||
renderable = format_incoming_alert(alert)
|
||||
# Just verify the alert object was created without error
|
||||
assert renderable is not None
|
||||
|
||||
def test_escapes_severity_markup(self) -> None:
|
||||
alert = IncomingAlert(
|
||||
text="payload",
|
||||
severity="critical] [red]pwned",
|
||||
source="webhook",
|
||||
received_at=datetime.now(UTC),
|
||||
)
|
||||
console = Console(record=True)
|
||||
console.print(format_incoming_alert(alert))
|
||||
output = console.export_text()
|
||||
|
||||
assert "[critical] [red]pwned]" in output
|
||||
assert "pwned]" in output
|
||||
|
||||
def test_severity_like_rich_style_tag_is_literal(self) -> None:
|
||||
"""Severity values resembling Rich markup must not apply styles."""
|
||||
alert = IncomingAlert(
|
||||
text="body",
|
||||
severity="bold red",
|
||||
received_at=datetime.now(UTC),
|
||||
)
|
||||
console = Console(record=True)
|
||||
console.print(format_incoming_alert(alert))
|
||||
output = console.export_text()
|
||||
|
||||
assert "[bold red]" in output
|
||||
|
||||
|
||||
class TestDrainAndRenderIncoming:
|
||||
"""Test drain_and_render_incoming functionality."""
|
||||
|
||||
def test_drains_fifo_order(self) -> None:
|
||||
session = Session()
|
||||
inbox = AlertInbox(maxsize=10)
|
||||
console = Console()
|
||||
|
||||
# Add alerts in order
|
||||
alert1 = IncomingAlert(text="first")
|
||||
alert2 = IncomingAlert(text="second")
|
||||
alert3 = IncomingAlert(text="third")
|
||||
|
||||
inbox.put(alert1)
|
||||
inbox.put(alert2)
|
||||
inbox.put(alert3)
|
||||
|
||||
# Drain and render
|
||||
count = drain_and_render_incoming(session, console, inbox)
|
||||
|
||||
assert count == 3
|
||||
assert len(session.alerts.entries) == 3
|
||||
assert session.alerts.entries[0].text == "first"
|
||||
assert session.alerts.entries[1].text == "second"
|
||||
assert session.alerts.entries[2].text == "third"
|
||||
|
||||
def test_records_in_history(self) -> None:
|
||||
session = Session()
|
||||
inbox = AlertInbox(maxsize=10)
|
||||
console = Console()
|
||||
|
||||
alert = IncomingAlert(text="test alert")
|
||||
inbox.put(alert)
|
||||
|
||||
drain_and_render_incoming(session, console, inbox)
|
||||
|
||||
# Check history
|
||||
assert len(session.history) == 1
|
||||
assert session.history[0]["type"] == "incoming_alert"
|
||||
assert session.history[0]["text"] == "test alert"
|
||||
assert session.history[0]["ok"] is True
|
||||
|
||||
def test_renders_to_console(self) -> None:
|
||||
session = Session()
|
||||
inbox = AlertInbox(maxsize=10)
|
||||
console = Console()
|
||||
|
||||
alert = IncomingAlert(text="test message")
|
||||
inbox.put(alert)
|
||||
|
||||
# Just verify drain_and_render doesn't raise an exception
|
||||
count = drain_and_render_incoming(session, console, inbox)
|
||||
assert count == 1
|
||||
|
||||
def test_returns_count(self) -> None:
|
||||
session = Session()
|
||||
inbox = AlertInbox(maxsize=10)
|
||||
console = Console()
|
||||
|
||||
inbox.put(IncomingAlert(text="alert1"))
|
||||
inbox.put(IncomingAlert(text="alert2"))
|
||||
|
||||
count = drain_and_render_incoming(session, console, inbox)
|
||||
|
||||
assert count == 2
|
||||
|
||||
def test_drains_empty_inbox(self) -> None:
|
||||
session = Session()
|
||||
inbox = AlertInbox(maxsize=10)
|
||||
console = Console()
|
||||
|
||||
count = drain_and_render_incoming(session, console, inbox)
|
||||
|
||||
assert count == 0
|
||||
assert len(session.history) == 0
|
||||
|
||||
def test_caps_incoming_alerts_at_max(self) -> None:
|
||||
session = Session()
|
||||
inbox = AlertInbox(maxsize=10)
|
||||
console = Console()
|
||||
|
||||
# Add more alerts than the session cap
|
||||
for i in range(300):
|
||||
alert = IncomingAlert(text=f"alert_{i}")
|
||||
inbox.put(alert)
|
||||
|
||||
drain_and_render_incoming(session, console, inbox)
|
||||
|
||||
# Should be capped at _INCOMING_ALERTS_MAX (256)
|
||||
assert len(session.alerts.entries) <= session.alerts._max
|
||||
|
||||
|
||||
class TestSessionIncomingAlerts:
|
||||
"""Test Session handling of incoming alerts."""
|
||||
|
||||
def test_clear_resets_incoming_alerts(self) -> None:
|
||||
session = Session()
|
||||
inbox = AlertInbox()
|
||||
console = Console()
|
||||
|
||||
# Add some alerts
|
||||
inbox.put(IncomingAlert(text="alert1"))
|
||||
inbox.put(IncomingAlert(text="alert2"))
|
||||
drain_and_render_incoming(session, console, inbox)
|
||||
|
||||
assert len(session.alerts.entries) == 2
|
||||
|
||||
# Clear session
|
||||
session.clear()
|
||||
|
||||
assert len(session.alerts.entries) == 0
|
||||
assert len(session.history) == 0
|
||||
|
||||
def test_record_incoming_alert_kind(self) -> None:
|
||||
session = Session()
|
||||
alert = IncomingAlert(text="test alert")
|
||||
|
||||
session.record_incoming_alert(alert)
|
||||
|
||||
assert len(session.history) == 1
|
||||
assert session.history[0]["type"] == "incoming_alert"
|
||||
assert session.history[0]["text"] == "test alert"
|
||||
assert session.history[0]["ok"] is True
|
||||
assert len(session.alerts.entries) == 1
|
||||
assert session.alerts.entries[0].text == "test alert"
|
||||
|
||||
def test_record_incoming_alert_always_ok(self) -> None:
|
||||
session = Session()
|
||||
alert = IncomingAlert(text="test alert")
|
||||
|
||||
session.record_incoming_alert(alert)
|
||||
|
||||
assert session.history[0]["ok"] is True
|
||||
|
||||
def test_incoming_alerts_fifo_list(self) -> None:
|
||||
session = Session()
|
||||
|
||||
session.record_incoming_alert(IncomingAlert(text="first"))
|
||||
session.record_incoming_alert(IncomingAlert(text="second"))
|
||||
session.record_incoming_alert(IncomingAlert(text="third"))
|
||||
|
||||
assert len(session.alerts.entries) == 3
|
||||
assert session.alerts.entries[0].text == "first"
|
||||
assert session.alerts.entries[1].text == "second"
|
||||
assert session.alerts.entries[2].text == "third"
|
||||
|
||||
|
||||
class TestAlertInboxEventClearing:
|
||||
"""Test AlertInbox pending_event behavior."""
|
||||
|
||||
def test_event_set_on_put(self) -> None:
|
||||
inbox = AlertInbox()
|
||||
alert = IncomingAlert(text="test")
|
||||
|
||||
# Event should not be set initially
|
||||
assert not inbox.pending_event.is_set()
|
||||
|
||||
inbox.put(alert)
|
||||
|
||||
# Event should be set after put
|
||||
assert inbox.pending_event.is_set()
|
||||
|
||||
def test_event_cleared_on_iter_pending(self) -> None:
|
||||
inbox = AlertInbox()
|
||||
alert = IncomingAlert(text="test")
|
||||
|
||||
inbox.put(alert)
|
||||
assert inbox.pending_event.is_set()
|
||||
|
||||
inbox.iter_pending()
|
||||
|
||||
# Event should be cleared after draining
|
||||
assert not inbox.pending_event.is_set()
|
||||
|
||||
def test_event_not_cleared_if_queue_not_empty(self) -> None:
|
||||
inbox = AlertInbox()
|
||||
|
||||
inbox.put(IncomingAlert(text="first"))
|
||||
inbox.put(IncomingAlert(text="second"))
|
||||
|
||||
# Pop only one
|
||||
inbox.pop_nowait()
|
||||
|
||||
# Drain but queue still has one item
|
||||
# (this is artificial; normally iter_pending drains all)
|
||||
# Let's test with iter_pending which should drain all
|
||||
inbox2 = AlertInbox()
|
||||
inbox2.put(IncomingAlert(text="alert"))
|
||||
inbox2.iter_pending()
|
||||
|
||||
# After draining all, event should be cleared
|
||||
assert not inbox2.pending_event.is_set()
|
||||
@@ -0,0 +1,352 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,261 @@
|
||||
"""Tests for structured investigation outcomes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from rich.console import Console
|
||||
|
||||
from core.llm.shared.llm_retry import LLMCreditExhaustedError
|
||||
from platform.common.errors import OpenSREError
|
||||
from platform.common.task_types import TaskRecord
|
||||
from surfaces.interactive_shell.session import Session
|
||||
from surfaces.interactive_shell.ui.foreground_investigation import run_foreground_investigation
|
||||
from surfaces.interactive_shell.ui.investigation_outcome import (
|
||||
classify_investigation_failure,
|
||||
normalize_investigation_target,
|
||||
user_facing_error_message,
|
||||
)
|
||||
|
||||
|
||||
def test_normalize_investigation_target_template() -> None:
|
||||
assert normalize_investigation_target("generic") == "generic"
|
||||
assert normalize_investigation_target("template:datadog") == "datadog"
|
||||
|
||||
|
||||
def test_normalize_investigation_target_file_path() -> None:
|
||||
assert normalize_investigation_target(
|
||||
"alerts/checkout.json", path=Path("alerts/checkout.json")
|
||||
) == ("checkout.json")
|
||||
|
||||
|
||||
def test_classify_integration_failure() -> None:
|
||||
category, integration, _detail = classify_investigation_failure(
|
||||
RuntimeError("grafana query failed: 401 unauthorized")
|
||||
)
|
||||
assert category == "integration"
|
||||
assert integration == "grafana"
|
||||
|
||||
|
||||
def test_user_facing_error_message_includes_suggestion() -> None:
|
||||
message = user_facing_error_message(
|
||||
OpenSREError("jenkins is not configured", suggestion="Run /integrations setup jenkins")
|
||||
)
|
||||
assert "jenkins is not configured" in message
|
||||
assert "Suggestion:" in message
|
||||
|
||||
|
||||
def test_run_foreground_investigation_early_cancel_omits_stale_investigation_id(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
session = Session()
|
||||
session.last_investigation_id = "inv-old"
|
||||
console = Console(force_terminal=False, color_system=None, highlight=False)
|
||||
task = MagicMock(spec=TaskRecord)
|
||||
task.cancel_requested = False
|
||||
monkeypatch.setattr(
|
||||
session.task_registry,
|
||||
"create",
|
||||
lambda *_args, **_kwargs: task,
|
||||
)
|
||||
|
||||
def _raise_interrupt(_task: TaskRecord) -> dict[str, object]:
|
||||
raise KeyboardInterrupt
|
||||
|
||||
outcome = run_foreground_investigation(
|
||||
session=session,
|
||||
console=console,
|
||||
task_command="/investigate generic",
|
||||
run=_raise_interrupt,
|
||||
exception_context="test",
|
||||
target="generic",
|
||||
)
|
||||
|
||||
assert outcome.status == "cancelled"
|
||||
assert outcome.investigation_id == ""
|
||||
task.mark_cancelled.assert_called_once()
|
||||
|
||||
|
||||
def test_run_foreground_investigation_credit_exhausted_shows_auth_login_hint(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
session = Session()
|
||||
console = Console(force_terminal=False, color_system=None, highlight=False)
|
||||
task = MagicMock(spec=TaskRecord)
|
||||
task.cancel_requested = False
|
||||
monkeypatch.setattr(
|
||||
session.task_registry,
|
||||
"create",
|
||||
lambda *_args, **_kwargs: task,
|
||||
)
|
||||
|
||||
def _raise_credit_exhausted(_task: TaskRecord) -> dict[str, object]:
|
||||
raise LLMCreditExhaustedError(
|
||||
"Anthropic credit exhausted (provider billing/quota). Original error: 400"
|
||||
)
|
||||
|
||||
outcome = run_foreground_investigation(
|
||||
session=session,
|
||||
console=console,
|
||||
task_command="/investigate alert.json",
|
||||
run=_raise_credit_exhausted,
|
||||
exception_context="test",
|
||||
target="alert.json",
|
||||
)
|
||||
|
||||
output = capsys.readouterr().out
|
||||
assert outcome.status == "failed"
|
||||
assert "/model" in output
|
||||
assert "/auth login" in output
|
||||
task.mark_failed.assert_called_once()
|
||||
|
||||
|
||||
def test_run_foreground_investigation_opensre_error_does_not_duplicate_auth_hint(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
session = Session()
|
||||
console = Console(force_terminal=False, color_system=None, highlight=False)
|
||||
task = MagicMock(spec=TaskRecord)
|
||||
task.cancel_requested = False
|
||||
monkeypatch.setattr(
|
||||
session.task_registry,
|
||||
"create",
|
||||
lambda *_args, **_kwargs: task,
|
||||
)
|
||||
|
||||
def _raise_credit_exhausted_opensre_error(_task: TaskRecord) -> dict[str, object]:
|
||||
raise OpenSREError(
|
||||
"Anthropic credit exhausted (provider billing/quota). Original error: 400",
|
||||
suggestion=(
|
||||
"Run /auth login <provider> to re-authenticate or add a different provider."
|
||||
),
|
||||
)
|
||||
|
||||
outcome = run_foreground_investigation(
|
||||
session=session,
|
||||
console=console,
|
||||
task_command="/investigate alert.json",
|
||||
run=_raise_credit_exhausted_opensre_error,
|
||||
exception_context="test",
|
||||
target="alert.json",
|
||||
)
|
||||
|
||||
output = capsys.readouterr().out
|
||||
assert outcome.status == "failed"
|
||||
assert output.count("/auth login") == 1
|
||||
task.mark_failed.assert_called_once()
|
||||
|
||||
|
||||
def test_run_foreground_investigation_skips_feedback_when_pt_app_running(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""#3690: prompt_app is on session.terminal; must not run raw stdin menu while active."""
|
||||
session = Session()
|
||||
session.terminal.prompt_app = MagicMock(is_running=True)
|
||||
console = Console(force_terminal=False, color_system=None, highlight=False)
|
||||
task = MagicMock(spec=TaskRecord)
|
||||
task.cancel_requested = False
|
||||
monkeypatch.setattr(
|
||||
session.task_registry,
|
||||
"create",
|
||||
lambda *_args, **_kwargs: task,
|
||||
)
|
||||
feedback = MagicMock()
|
||||
monkeypatch.setattr(
|
||||
"surfaces.interactive_shell.ui.feedback.prompt_investigation_feedback",
|
||||
feedback,
|
||||
)
|
||||
|
||||
outcome = run_foreground_investigation(
|
||||
session=session,
|
||||
console=console,
|
||||
task_command="/investigate grafana",
|
||||
run=lambda _task: {"root_cause": "sample"},
|
||||
exception_context="test",
|
||||
target="grafana",
|
||||
)
|
||||
|
||||
assert outcome.status == "completed"
|
||||
feedback.assert_not_called()
|
||||
|
||||
|
||||
def test_run_foreground_investigation_prompts_feedback_when_pt_app_idle(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
session = Session()
|
||||
session.terminal.prompt_app = MagicMock(is_running=False)
|
||||
console = Console(force_terminal=False, color_system=None, highlight=False)
|
||||
task = MagicMock(spec=TaskRecord)
|
||||
task.cancel_requested = False
|
||||
monkeypatch.setattr(
|
||||
session.task_registry,
|
||||
"create",
|
||||
lambda *_args, **_kwargs: task,
|
||||
)
|
||||
feedback = MagicMock()
|
||||
monkeypatch.setattr(
|
||||
"surfaces.interactive_shell.ui.feedback.prompt_investigation_feedback",
|
||||
feedback,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"surfaces.interactive_shell.ui.components.choice_menu.repl_tty_interactive",
|
||||
lambda: True,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"surfaces.interactive_shell.ui.components.key_reader.restore_stdin_terminal",
|
||||
lambda: None,
|
||||
)
|
||||
|
||||
run_foreground_investigation(
|
||||
session=session,
|
||||
console=console,
|
||||
task_command="/investigate",
|
||||
run=lambda _task: {"root_cause": "sample"},
|
||||
exception_context="test",
|
||||
target="",
|
||||
)
|
||||
|
||||
feedback.assert_called_once_with({"root_cause": "sample"})
|
||||
|
||||
|
||||
def test_run_foreground_investigation_skips_feedback_on_headless_session(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Gateway SessionCore must not block on the RCA feedback picker."""
|
||||
from core.agent_harness.session import SessionCore
|
||||
from core.agent_harness.session.persistence.memory import InMemorySessionStorage
|
||||
|
||||
session = SessionCore(storage=InMemorySessionStorage())
|
||||
console = Console(force_terminal=False, color_system=None, highlight=False)
|
||||
task = MagicMock(spec=TaskRecord)
|
||||
task.cancel_requested = False
|
||||
monkeypatch.setattr(
|
||||
session.task_registry,
|
||||
"create",
|
||||
lambda *_args, **_kwargs: task,
|
||||
)
|
||||
feedback = MagicMock()
|
||||
monkeypatch.setattr(
|
||||
"surfaces.interactive_shell.ui.feedback.prompt_investigation_feedback",
|
||||
feedback,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"surfaces.interactive_shell.ui.components.choice_menu.repl_tty_interactive",
|
||||
lambda: True,
|
||||
)
|
||||
|
||||
outcome = run_foreground_investigation(
|
||||
session=session,
|
||||
console=console,
|
||||
task_command="/investigate grafana",
|
||||
run=lambda _task: {"root_cause": "sample"},
|
||||
exception_context="test",
|
||||
target="grafana",
|
||||
)
|
||||
|
||||
assert outcome.status == "completed"
|
||||
feedback.assert_not_called()
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Tests for terminal cooked-mode restore after raw-mode menus."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from surfaces.interactive_shell.ui.components import key_reader
|
||||
|
||||
|
||||
def test_restore_stdin_terminal_recooks_input_flags(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# A raw menu (tty.setraw) clears ICRNL, so Enter (CR) stops submitting until it
|
||||
# is restored. Pin that restore re-enables the cooked-mode flags on a raw snapshot.
|
||||
termios = pytest.importorskip("termios")
|
||||
|
||||
monkeypatch.setattr(key_reader.os, "name", "posix")
|
||||
monkeypatch.setattr(
|
||||
key_reader.sys, "stdin", SimpleNamespace(isatty=lambda: True, fileno=lambda: 0)
|
||||
)
|
||||
|
||||
raw_attrs = [0, 0, 0, 0, 0, 0, []] # iflag/oflag/cflag/lflag all cleared (raw mode)
|
||||
written: dict[str, list[object]] = {}
|
||||
monkeypatch.setattr(termios, "tcgetattr", lambda _fd: list(raw_attrs))
|
||||
monkeypatch.setattr(
|
||||
termios, "tcsetattr", lambda _fd, _when, attrs: written.__setitem__("attrs", attrs)
|
||||
)
|
||||
monkeypatch.setattr(termios, "tcflush", lambda _fd, _queue: None)
|
||||
|
||||
key_reader.restore_stdin_terminal()
|
||||
|
||||
attrs = written["attrs"]
|
||||
assert attrs[0] & termios.ICRNL # CR -> NL so Enter submits again
|
||||
assert attrs[1] & termios.OPOST # output newline post-processing
|
||||
assert attrs[3] & termios.ICANON # line editing
|
||||
assert attrs[3] & termios.ECHO # keystrokes visible
|
||||
|
||||
|
||||
def test_restore_stdin_terminal_is_a_no_op_when_not_a_tty(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(key_reader.os, "name", "posix")
|
||||
monkeypatch.setattr(
|
||||
key_reader.sys,
|
||||
"stdin",
|
||||
SimpleNamespace(isatty=lambda: False, fileno=lambda: (_ for _ in ()).throw(AssertionError)),
|
||||
)
|
||||
key_reader.restore_stdin_terminal() # returns without touching termios
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Tests for the shared interactive-shell LLM loader."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from typing import Any
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
from surfaces.interactive_shell.ui.components import loaders
|
||||
from surfaces.interactive_shell.ui.components.loaders import llm_loader
|
||||
|
||||
|
||||
def _terminal_console() -> tuple[Console, io.StringIO]:
|
||||
buf = io.StringIO()
|
||||
return (
|
||||
Console(file=buf, force_terminal=True, color_system=None, width=80, highlight=False),
|
||||
buf,
|
||||
)
|
||||
|
||||
|
||||
def _plain_console() -> tuple[Console, io.StringIO]:
|
||||
"""Console that reports ``is_terminal == False`` — the CI / piped case."""
|
||||
buf = io.StringIO()
|
||||
return (Console(file=buf, force_terminal=False, color_system=None, width=80), buf)
|
||||
|
||||
|
||||
class TestLLMLoader:
|
||||
def test_yields_to_caller_and_runs_wrapped_block(self) -> None:
|
||||
console, _ = _terminal_console()
|
||||
ran: list[bool] = []
|
||||
with llm_loader(console):
|
||||
ran.append(True)
|
||||
assert ran == [True]
|
||||
|
||||
def test_skips_spinner_when_console_is_not_a_terminal(self) -> None:
|
||||
"""In CI / piped output we must NOT pollute logs with spinner frames."""
|
||||
console, buf = _plain_console()
|
||||
with llm_loader(console):
|
||||
pass
|
||||
# Nothing should be written — no label, no escape sequences, nothing.
|
||||
assert buf.getvalue() == ""
|
||||
|
||||
def test_loader_uses_subtle_spinner_style(self, monkeypatch: Any) -> None:
|
||||
"""The loader uses a dim, quiet spinner — less visual noise than a bright accent."""
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
# Fake context manager so we can introspect the kwargs without
|
||||
# triggering Rich's Live renderer in the test.
|
||||
class _FakeStatus:
|
||||
def __enter__(self) -> _FakeStatus:
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc: object) -> None:
|
||||
return None
|
||||
|
||||
def _fake_status(text: str, **kwargs: Any) -> _FakeStatus:
|
||||
captured["text"] = text
|
||||
captured["kwargs"] = kwargs
|
||||
return _FakeStatus()
|
||||
|
||||
console, _ = _terminal_console()
|
||||
monkeypatch.setattr(console, "status", _fake_status)
|
||||
|
||||
with llm_loader(console, label="consulting the model"):
|
||||
pass
|
||||
|
||||
from platform.terminal.theme import SECONDARY
|
||||
|
||||
assert SECONDARY in captured["text"]
|
||||
assert "consulting the model" in captured["text"]
|
||||
assert captured["kwargs"]["spinner"] == "dots"
|
||||
assert captured["kwargs"]["spinner_style"] == SECONDARY
|
||||
|
||||
|
||||
def test_module_exports_loader_and_default_label() -> None:
|
||||
assert "llm_loader" in loaders.__all__
|
||||
assert "DEFAULT_LOADER_LABEL" in loaders.__all__
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Tests for REPL provider/model resolution used by the welcome banner."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from core.agent_harness.llm_resolution import resolve_provider_models
|
||||
from surfaces.interactive_shell.ui.tables import provider as ui_provider
|
||||
|
||||
|
||||
def test_ui_provider_reexports_core_resolver() -> None:
|
||||
assert ui_provider.resolve_provider_models is resolve_provider_models
|
||||
|
||||
|
||||
def test_antigravity_cli_reads_model_env(monkeypatch: object) -> None:
|
||||
monkeypatch.setenv("ANTIGRAVITY_CLI_MODEL", "gemini-2.5-pro")
|
||||
|
||||
reasoning, toolcall = resolve_provider_models(SimpleNamespace(), "antigravity-cli")
|
||||
|
||||
assert reasoning == "gemini-2.5-pro"
|
||||
assert toolcall == "gemini-2.5-pro"
|
||||
|
||||
|
||||
def test_antigravity_cli_falls_back_to_cli_default(monkeypatch: object) -> None:
|
||||
monkeypatch.delenv("ANTIGRAVITY_CLI_MODEL", raising=False)
|
||||
|
||||
reasoning, toolcall = resolve_provider_models(SimpleNamespace(), "antigravity-cli")
|
||||
|
||||
assert reasoning == "CLI default"
|
||||
assert toolcall == "CLI default"
|
||||
|
||||
|
||||
def test_antigravity_cli_does_not_use_hyphenated_settings_attr() -> None:
|
||||
"""Regression: hyphenated provider ids are not reachable via getattr(settings, ...)."""
|
||||
settings = SimpleNamespace(**{"antigravity-cli_model": "should-not-win"})
|
||||
|
||||
reasoning, toolcall = resolve_provider_models(settings, "antigravity-cli")
|
||||
|
||||
assert reasoning == "CLI default"
|
||||
assert toolcall == "CLI default"
|
||||
|
||||
|
||||
def test_openai_oauth_displays_codex_model(monkeypatch: object) -> None:
|
||||
monkeypatch.setenv("LLM_AUTH_METHOD", "oauth")
|
||||
monkeypatch.setenv("CODEX_MODEL", "gpt-5.5")
|
||||
|
||||
reasoning, toolcall = resolve_provider_models(SimpleNamespace(), "openai")
|
||||
|
||||
assert reasoning == "gpt-5.5"
|
||||
assert toolcall == "gpt-5.5"
|
||||
@@ -0,0 +1,287 @@
|
||||
"""Tests for Rich rendering helpers used by the interactive shell."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import re
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
from rich.console import Console
|
||||
|
||||
from surfaces.interactive_shell.runtime.core.state import SpinnerState
|
||||
from surfaces.interactive_shell.ui.components.rendering import (
|
||||
_repl_write_buffer,
|
||||
print_repl_json,
|
||||
refresh_welcome_poster,
|
||||
repl_print,
|
||||
repl_render_launch_poster,
|
||||
repl_table,
|
||||
)
|
||||
from surfaces.interactive_shell.ui.streaming.console import StreamingConsole
|
||||
from surfaces.interactive_shell.ui.tables import (
|
||||
render_integrations_table,
|
||||
render_mcp_table,
|
||||
)
|
||||
|
||||
|
||||
def test_repl_table_minimal_box() -> None:
|
||||
t = repl_table(title="T")
|
||||
assert t.title == "T"
|
||||
|
||||
|
||||
def test_print_repl_json_tty_uses_single_buffered_write(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
class _FakeStdout:
|
||||
def __init__(self) -> None:
|
||||
self.writes: list[str] = []
|
||||
|
||||
def write(self, text: str) -> int:
|
||||
self.writes.append(text)
|
||||
return len(text)
|
||||
|
||||
def flush(self) -> None:
|
||||
return None
|
||||
|
||||
def isatty(self) -> bool:
|
||||
return True
|
||||
|
||||
fake_stdout = _FakeStdout()
|
||||
monkeypatch.setattr("sys.stdout", fake_stdout)
|
||||
|
||||
console = Console(file=fake_stdout, force_terminal=True, width=80)
|
||||
print_repl_json(console, '{"ok": true}')
|
||||
|
||||
assert len(fake_stdout.writes) == 1
|
||||
rendered = re.sub(r"\x1b\[[0-9;]*m", "", fake_stdout.writes[0])
|
||||
assert rendered.startswith("\r\n")
|
||||
assert '"ok": true' in rendered
|
||||
|
||||
|
||||
def test_render_integrations_table_empty_shows_hint() -> None:
|
||||
buf = io.StringIO()
|
||||
console = Console(file=buf, force_terminal=False)
|
||||
render_integrations_table(console, [])
|
||||
assert "opensre onboard" in buf.getvalue()
|
||||
|
||||
|
||||
def test_repl_print_resets_before_each_line(monkeypatch) -> None:
|
||||
resets: list[bool] = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
"surfaces.interactive_shell.ui.components.choice_menu.prepare_repl_output_line",
|
||||
lambda: resets.append(True),
|
||||
)
|
||||
|
||||
buf = io.StringIO()
|
||||
console = Console(file=buf, force_terminal=False, width=80)
|
||||
repl_print(console, "line one")
|
||||
repl_print(console, "line two")
|
||||
|
||||
assert len(resets) == 2
|
||||
|
||||
|
||||
def test_repl_print_does_not_double_prepare_with_streaming_console(monkeypatch) -> None:
|
||||
resets: list[bool] = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
"surfaces.interactive_shell.ui.components.choice_menu.prepare_repl_output_line",
|
||||
lambda: resets.append(True),
|
||||
)
|
||||
|
||||
console = StreamingConsole(
|
||||
SpinnerState(),
|
||||
threading.Event(),
|
||||
file=io.StringIO(),
|
||||
force_terminal=False,
|
||||
width=80,
|
||||
)
|
||||
repl_print(console, "line")
|
||||
|
||||
assert len(resets) == 1
|
||||
|
||||
|
||||
def test_repl_print_streaming_console_prepares_tty_once_when_interactive(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
class _FakeStdout:
|
||||
def __init__(self) -> None:
|
||||
self.writes: list[str] = []
|
||||
|
||||
def write(self, text: str) -> int:
|
||||
self.writes.append(text)
|
||||
return len(text)
|
||||
|
||||
def flush(self) -> None:
|
||||
return None
|
||||
|
||||
def isatty(self) -> bool:
|
||||
return True
|
||||
|
||||
fake_stdout = _FakeStdout()
|
||||
monkeypatch.setattr("sys.stdout", fake_stdout)
|
||||
monkeypatch.setattr(
|
||||
"surfaces.interactive_shell.ui.components.choice_menu.repl_tty_interactive",
|
||||
lambda: True,
|
||||
)
|
||||
|
||||
console = StreamingConsole(
|
||||
SpinnerState(),
|
||||
threading.Event(),
|
||||
file=io.StringIO(),
|
||||
force_terminal=False,
|
||||
width=80,
|
||||
)
|
||||
repl_print(console, "line")
|
||||
|
||||
assert fake_stdout.writes == ["\r\n", "\r"]
|
||||
|
||||
|
||||
def test_repl_render_launch_poster_uses_crlf_on_tty(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
class _FakeStdout:
|
||||
def __init__(self) -> None:
|
||||
self.writes: list[str] = []
|
||||
|
||||
def write(self, text: str) -> int:
|
||||
self.writes.append(text)
|
||||
return len(text)
|
||||
|
||||
def flush(self) -> None:
|
||||
return None
|
||||
|
||||
def isatty(self) -> bool:
|
||||
return True
|
||||
|
||||
fake_stdout = _FakeStdout()
|
||||
monkeypatch.setattr("sys.stdout", fake_stdout)
|
||||
|
||||
from platform.terminal.theme import set_active_theme
|
||||
|
||||
set_active_theme("blue")
|
||||
console = Console(
|
||||
file=fake_stdout,
|
||||
force_terminal=True,
|
||||
highlight=False,
|
||||
color_system="truecolor",
|
||||
width=120,
|
||||
)
|
||||
repl_render_launch_poster(console, theme_notice="blue")
|
||||
|
||||
written = "".join(fake_stdout.writes)
|
||||
assert "theme set:" in written
|
||||
assert "blue" in written
|
||||
assert "38;2;168;212;255" in written
|
||||
assert "185;237;175" not in written
|
||||
assert "opensre" in written
|
||||
assert "Welcome back" in written
|
||||
assert "\r\n" in written
|
||||
# REPL path must not emit bare \\n (causes double-spaced splash under patch_stdout).
|
||||
assert "\r" not in written.replace("\r\n", "")
|
||||
|
||||
|
||||
def test_repl_write_buffer_strips_only_escaped_cpr_sequences(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
class _FakeStdout:
|
||||
def __init__(self) -> None:
|
||||
self.writes: list[str] = []
|
||||
|
||||
def write(self, text: str) -> int:
|
||||
self.writes.append(text)
|
||||
return len(text)
|
||||
|
||||
def flush(self) -> None:
|
||||
return None
|
||||
|
||||
fake_stdout = _FakeStdout()
|
||||
monkeypatch.setattr("sys.stdout", fake_stdout)
|
||||
|
||||
_repl_write_buffer("\x1b[1;1Rtheme set: pink 12;5R\r\n")
|
||||
|
||||
written = "".join(fake_stdout.writes)
|
||||
assert "theme set: pink" in written
|
||||
assert "12;5R" in written
|
||||
assert "\x1b[1;1R" not in written
|
||||
|
||||
|
||||
def test_refresh_welcome_poster_drains_cpr_after_clear(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
drains: list[str] = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
"surfaces.interactive_shell.ui.components.rendering.repl_clear_screen",
|
||||
lambda: drains.append("clear"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"surfaces.interactive_shell.ui.components.cpr_stdin.drain_stale_cpr_bytes",
|
||||
lambda: drains.append("drain"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"surfaces.interactive_shell.ui.components.rendering.repl_render_launch_poster",
|
||||
lambda *_args, **_kwargs: drains.append("render"),
|
||||
)
|
||||
|
||||
console = Console(file=io.StringIO(), force_terminal=False)
|
||||
refresh_welcome_poster(console)
|
||||
|
||||
assert drains == ["clear", "drain", "render"]
|
||||
|
||||
|
||||
def test_render_integrations_table_renders_content(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""print_repl_table on a non-TTY console writes via console.print to stdout.
|
||||
|
||||
The cursor-reset (prepare_repl_output_line) is no longer called from the
|
||||
table rendering path; on a real TTY the blank line and \\r\\n normalisation
|
||||
are folded into a single sys.stdout.write call in print_repl_table.
|
||||
"""
|
||||
console = Console(force_terminal=False, width=80)
|
||||
render_integrations_table(
|
||||
console,
|
||||
[
|
||||
{
|
||||
"service": "grafana",
|
||||
"source": "local store",
|
||||
"status": "passed",
|
||||
"detail": "Connected to https://example.grafana.net",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
assert "grafana" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_render_integrations_table_sorts_services_and_includes_mcp(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
console = Console(force_terminal=False, width=80)
|
||||
render_integrations_table(
|
||||
console,
|
||||
[
|
||||
{"service": "sentry", "source": "-", "status": "missing", "detail": "missing"},
|
||||
{"service": "github", "source": "-", "status": "missing", "detail": "missing"},
|
||||
{"service": "datadog", "source": "env", "status": "passed", "detail": "ok"},
|
||||
],
|
||||
)
|
||||
|
||||
output = capsys.readouterr().out
|
||||
assert output.index("datadog") < output.index("github") < output.index("sentry")
|
||||
assert "github" in output
|
||||
|
||||
|
||||
def test_render_mcp_table_renders_content(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
console = Console(force_terminal=False, width=80)
|
||||
render_mcp_table(
|
||||
console,
|
||||
[
|
||||
{
|
||||
"service": "github",
|
||||
"source": "local store",
|
||||
"status": "configured",
|
||||
"detail": "Connected",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
assert "github" in capsys.readouterr().out
|
||||
@@ -0,0 +1,983 @@
|
||||
"""Tests for the shared live-streaming renderer used by interactive-shell handlers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import re
|
||||
import threading
|
||||
from collections.abc import Iterator
|
||||
|
||||
import pytest
|
||||
from rich.console import Console
|
||||
|
||||
from surfaces.interactive_shell.ui.streaming import (
|
||||
format_token_count_short,
|
||||
render_response_header,
|
||||
stream_to_console,
|
||||
)
|
||||
|
||||
|
||||
def _strip_ansi(text: str) -> str:
|
||||
"""Drop ANSI escapes so assertions check the visible output."""
|
||||
return re.sub(r"\x1b\[[0-9;?]*[A-Za-z]", "", text)
|
||||
|
||||
|
||||
def _tty_console() -> tuple[Console, io.StringIO]:
|
||||
"""Build a Console that thinks it is a terminal so Rich.Live actually renders."""
|
||||
buf = io.StringIO()
|
||||
return (
|
||||
Console(file=buf, force_terminal=True, color_system=None, width=80, highlight=False),
|
||||
buf,
|
||||
)
|
||||
|
||||
|
||||
def _non_tty_console() -> tuple[Console, io.StringIO]:
|
||||
buf = io.StringIO()
|
||||
return Console(file=buf, force_terminal=False, color_system=None, width=80), buf
|
||||
|
||||
|
||||
def _yield_chunks(chunks: list[str]) -> Iterator[str]:
|
||||
yield from chunks
|
||||
|
||||
|
||||
class TestNonTtyFallback:
|
||||
"""On a non-terminal console the helper drains, prints, and returns the full text."""
|
||||
|
||||
def test_drains_stream_and_prints_without_live_artifacts(self) -> None:
|
||||
console, buf = _non_tty_console()
|
||||
result = stream_to_console(
|
||||
console,
|
||||
label="assistant",
|
||||
chunks=_yield_chunks(["Hel", "lo, ", "world"]),
|
||||
)
|
||||
|
||||
output = buf.getvalue()
|
||||
assert result == "Hello, world"
|
||||
# Bullet header + label + text reach piped output so captured
|
||||
# logs are useful. ``●`` is the row marker; ``assistant`` is the
|
||||
# dim label alongside it.
|
||||
assert "●" in output
|
||||
assert "assistant" in output
|
||||
assert "Hello, world" in output
|
||||
# No spinner / Live cursor-movement artifacts in non-TTY captures.
|
||||
assert "thinking" not in output
|
||||
|
||||
def test_suppression_drains_silently_in_non_tty(self) -> None:
|
||||
"""Suppressed payloads (machine-readable payloads) must not appear in piped output."""
|
||||
console, buf = _non_tty_console()
|
||||
result = stream_to_console(
|
||||
console,
|
||||
label="assistant",
|
||||
chunks=_yield_chunks(['{"actions"', ":[]}"]),
|
||||
suppress_if_starts_with="{",
|
||||
)
|
||||
|
||||
assert result == '{"actions":[]}'
|
||||
output = buf.getvalue()
|
||||
# No bullet header for suppressed responses.
|
||||
assert "●" not in output
|
||||
assert '{"actions"' not in output
|
||||
|
||||
|
||||
class TestTtyParagraphRender:
|
||||
"""On a terminal console paragraphs render as Markdown the moment
|
||||
each ``\\n\\n`` boundary closes them; the final paragraph is
|
||||
force-flushed at end-of-stream. Code blocks are kept whole (we
|
||||
don't split mid-fence). The spinner indicator drives the live
|
||||
streaming feedback within a paragraph.
|
||||
"""
|
||||
|
||||
def test_renders_label_and_streamed_content_as_markdown(self) -> None:
|
||||
console, buf = _tty_console()
|
||||
result = stream_to_console(
|
||||
console,
|
||||
label="assistant",
|
||||
chunks=_yield_chunks(["Run **opensre", " investigate** to start."]),
|
||||
)
|
||||
|
||||
output = _strip_ansi(buf.getvalue())
|
||||
assert result == "Run **opensre investigate** to start."
|
||||
# Bullet row marker pinned above the rendered paragraph.
|
||||
assert "●" in output
|
||||
# End-of-stream force-flush rendered Markdown — ``**`` stripped.
|
||||
assert "**opensre" not in output
|
||||
assert "opensre investigate" in output
|
||||
|
||||
def test_renders_first_paragraph_before_second_completes(self) -> None:
|
||||
"""A complete paragraph (``\\n\\n``) flushes immediately, even
|
||||
when more chunks would still arrive after it. The second
|
||||
paragraph stays buffered until its own boundary or EOS."""
|
||||
chunks: list[str] = []
|
||||
|
||||
def _capture_chunks() -> Iterator[str]:
|
||||
for c in ["First **para**.\n\n", "Second **para**."]:
|
||||
chunks.append(c)
|
||||
yield c
|
||||
|
||||
console, buf = _tty_console()
|
||||
result = stream_to_console(
|
||||
console,
|
||||
label="assistant",
|
||||
chunks=_capture_chunks(),
|
||||
)
|
||||
|
||||
output = _strip_ansi(buf.getvalue())
|
||||
assert result == "First **para**.\n\nSecond **para**."
|
||||
# Both paragraphs are rendered (``**`` stripped).
|
||||
assert "First para." in output
|
||||
assert "Second para." in output
|
||||
assert "**para**" not in output
|
||||
|
||||
def test_paragraph_break_across_chunk_boundary_flushes(self) -> None:
|
||||
"""The cross-chunk seam — chunk N ends with ``\\n``, chunk N+1
|
||||
starts with ``\\n`` — must be detected as a paragraph break.
|
||||
|
||||
Without the seam check the fast-path skips the join (no
|
||||
``\\n\\n`` *inside* either chunk) and the boundary is missed
|
||||
until end-of-stream.
|
||||
"""
|
||||
from surfaces.interactive_shell.ui import streaming as streaming_module
|
||||
|
||||
parse_count = [0]
|
||||
real_markdown = streaming_module.Markdown
|
||||
|
||||
class _SpyMarkdown(real_markdown): # type: ignore[misc, valid-type]
|
||||
def __init__(self, text: str, **kwargs) -> None:
|
||||
parse_count[0] += 1
|
||||
super().__init__(text, **kwargs)
|
||||
|
||||
# Patch only on this thread; restored by the test fixture's GC.
|
||||
original_markdown = streaming_module.Markdown
|
||||
streaming_module.Markdown = _SpyMarkdown
|
||||
try:
|
||||
console, _ = _tty_console()
|
||||
# ``"first.\n"`` then ``"\nsecond."`` — neither chunk
|
||||
# contains ``\n\n`` standalone, but joined they form a
|
||||
# paragraph break at the seam.
|
||||
stream_to_console(
|
||||
console,
|
||||
label="assistant",
|
||||
chunks=_yield_chunks(["first.\n", "\nsecond."]),
|
||||
)
|
||||
finally:
|
||||
streaming_module.Markdown = original_markdown
|
||||
|
||||
# 2 parses: first paragraph flushed at the seam, then second
|
||||
# tail force-flushed at end-of-stream.
|
||||
assert parse_count[0] == 2, (
|
||||
f"seam check missed the cross-chunk break — got {parse_count[0]} parses"
|
||||
)
|
||||
|
||||
def test_peeked_chunks_seed_prev_chunk_for_seam_detection(self) -> None:
|
||||
"""When ``suppress_if_starts_with`` peeks chunks but doesn't
|
||||
suppress, those peeked chunks become history for the seam
|
||||
check on the very first main-loop chunk.
|
||||
|
||||
Concretely: suppression-peek pulls ``"hello\\n"`` (didn't match
|
||||
``"{"``); main loop starts with ``"\\nworld"``. The seam should
|
||||
be detected — ``peeked[-1]`` is the initial ``prev_chunk``.
|
||||
"""
|
||||
from surfaces.interactive_shell.ui import streaming as streaming_module
|
||||
|
||||
parse_count = [0]
|
||||
real_markdown = streaming_module.Markdown
|
||||
|
||||
class _SpyMarkdown(real_markdown): # type: ignore[misc, valid-type]
|
||||
def __init__(self, text: str, **kwargs) -> None:
|
||||
parse_count[0] += 1
|
||||
super().__init__(text, **kwargs)
|
||||
|
||||
original_markdown = streaming_module.Markdown
|
||||
streaming_module.Markdown = _SpyMarkdown
|
||||
try:
|
||||
console, _ = _tty_console()
|
||||
stream_to_console(
|
||||
console,
|
||||
label="assistant",
|
||||
chunks=_yield_chunks(["hello\n", "\nworld"]),
|
||||
suppress_if_starts_with="{",
|
||||
)
|
||||
finally:
|
||||
streaming_module.Markdown = original_markdown
|
||||
|
||||
# 2 parses — peeked chunk + first main-loop chunk form a seam,
|
||||
# producing one paragraph; tail is force-flushed at EOS.
|
||||
assert parse_count[0] == 2
|
||||
|
||||
def test_open_code_block_is_not_split_mid_fence(self) -> None:
|
||||
"""``\\n\\n`` inside an open code block must NOT trigger a
|
||||
flush — splitting would render a partial fenced block whose
|
||||
formatting breaks. The fence stays whole until it closes."""
|
||||
chunks_with_open_fence = [
|
||||
"Header\n\n",
|
||||
"```python\n",
|
||||
"x = 1\n\n", # blank line inside code block — must not flush
|
||||
"y = 2\n",
|
||||
"```\n\n",
|
||||
"Trailing.",
|
||||
]
|
||||
|
||||
console, buf = _tty_console()
|
||||
result = stream_to_console(
|
||||
console,
|
||||
label="assistant",
|
||||
chunks=_yield_chunks(chunks_with_open_fence),
|
||||
)
|
||||
|
||||
# Full text returned unchanged.
|
||||
assert "x = 1" in result
|
||||
assert "y = 2" in result
|
||||
# Both code lines must appear in the rendered output (i.e. the
|
||||
# fence wasn't split before its closing ``` was seen).
|
||||
output = _strip_ansi(buf.getvalue())
|
||||
assert "x = 1" in output
|
||||
assert "y = 2" in output
|
||||
assert "Trailing" in output
|
||||
|
||||
def test_post_fence_paragraph_renders_after_block_with_embedded_blank_line(
|
||||
self,
|
||||
) -> None:
|
||||
"""A code block containing a blank line must not bury later
|
||||
paragraphs at EOS. Once the closing fence arrives, the
|
||||
completed code-block paragraph flushes and any subsequent
|
||||
``\\n\\n``-terminated paragraph flushes mid-stream too.
|
||||
"""
|
||||
from surfaces.interactive_shell.ui import streaming as streaming_module
|
||||
|
||||
parse_count = [0]
|
||||
real_markdown = streaming_module.Markdown
|
||||
|
||||
class _SpyMarkdown(real_markdown): # type: ignore[misc, valid-type]
|
||||
def __init__(self, text: str, **kwargs) -> None:
|
||||
parse_count[0] += 1
|
||||
super().__init__(text, **kwargs)
|
||||
|
||||
original_markdown = streaming_module.Markdown
|
||||
streaming_module.Markdown = _SpyMarkdown
|
||||
try:
|
||||
console, _ = _tty_console()
|
||||
stream_to_console(
|
||||
console,
|
||||
label="assistant",
|
||||
chunks=_yield_chunks(
|
||||
[
|
||||
"Intro.\n\n",
|
||||
"```python\n",
|
||||
"x = 1\n\n",
|
||||
"y = 2\n",
|
||||
"```\n\n",
|
||||
"Conclusion.\n\n",
|
||||
]
|
||||
),
|
||||
)
|
||||
finally:
|
||||
streaming_module.Markdown = original_markdown
|
||||
|
||||
# 3 parses — intro flushes on its own ``\n\n``; the fenced block
|
||||
# flushes once the closing fence + ``\n\n`` arrive (skipping the
|
||||
# embedded blank line); conclusion flushes on its own ``\n\n``
|
||||
# before EOS. Without the skip-past-open-fence logic, the fenced
|
||||
# block + conclusion would defer to a single force-flush at EOS
|
||||
# → 2 parses.
|
||||
assert parse_count[0] == 3, (
|
||||
f"post-fence paragraph deferred to EOS — got {parse_count[0]} parses"
|
||||
)
|
||||
|
||||
def test_multiple_blank_lines_inside_single_fence_render_as_one_block(
|
||||
self,
|
||||
) -> None:
|
||||
"""A single code block with several embedded ``\\n\\n`` must render
|
||||
once when its fence closes. Exercises ``search_from`` advancing
|
||||
repeatedly within a single ``_flush_paragraphs`` call.
|
||||
"""
|
||||
from surfaces.interactive_shell.ui import streaming as streaming_module
|
||||
|
||||
parse_count = [0]
|
||||
real_markdown = streaming_module.Markdown
|
||||
|
||||
class _SpyMarkdown(real_markdown): # type: ignore[misc, valid-type]
|
||||
def __init__(self, text: str, **kwargs) -> None:
|
||||
parse_count[0] += 1
|
||||
super().__init__(text, **kwargs)
|
||||
|
||||
original_markdown = streaming_module.Markdown
|
||||
streaming_module.Markdown = _SpyMarkdown
|
||||
try:
|
||||
console, _ = _tty_console()
|
||||
stream_to_console(
|
||||
console,
|
||||
label="assistant",
|
||||
chunks=_yield_chunks(
|
||||
[
|
||||
"Intro.\n\n",
|
||||
"```python\n",
|
||||
"a\n\n", # first embedded blank
|
||||
"b\n\n", # second embedded blank
|
||||
"c\n\n", # third embedded blank
|
||||
"d\n",
|
||||
"```\n\n",
|
||||
"After.\n\n",
|
||||
]
|
||||
),
|
||||
)
|
||||
finally:
|
||||
streaming_module.Markdown = original_markdown
|
||||
|
||||
# 3 parses with the fix: intro + fenced block (rendered once when
|
||||
# fence closes, after 3 skip iterations advance search_from past
|
||||
# each embedded blank) + after. Without the fix, the inner loop
|
||||
# would break on the first odd-fence boundary and defer the block
|
||||
# + after to a single EOS force-flush → 2 parses.
|
||||
assert parse_count[0] == 3, (
|
||||
f"expected 3 parses (intro + block + after), got {parse_count[0]}"
|
||||
)
|
||||
|
||||
def test_two_consecutive_fences_each_with_blank_line_render_independently(
|
||||
self,
|
||||
) -> None:
|
||||
"""Two fenced blocks back-to-back, each containing an embedded
|
||||
``\\n\\n``. Each block must render as its own paragraph when its
|
||||
fence closes — ``search_from`` is reset to 0 after each render so
|
||||
the second block isn't blocked by stale state from the first.
|
||||
"""
|
||||
from surfaces.interactive_shell.ui import streaming as streaming_module
|
||||
|
||||
parse_count = [0]
|
||||
real_markdown = streaming_module.Markdown
|
||||
|
||||
class _SpyMarkdown(real_markdown): # type: ignore[misc, valid-type]
|
||||
def __init__(self, text: str, **kwargs) -> None:
|
||||
parse_count[0] += 1
|
||||
super().__init__(text, **kwargs)
|
||||
|
||||
original_markdown = streaming_module.Markdown
|
||||
streaming_module.Markdown = _SpyMarkdown
|
||||
try:
|
||||
console, _ = _tty_console()
|
||||
stream_to_console(
|
||||
console,
|
||||
label="assistant",
|
||||
chunks=_yield_chunks(
|
||||
[
|
||||
"Intro.\n\n",
|
||||
"```py\nfoo\n\nbar\n```\n\n", # block 1, embedded blank
|
||||
"```py\nbaz\n\nqux\n```\n\n", # block 2, embedded blank
|
||||
"End.\n\n",
|
||||
]
|
||||
),
|
||||
)
|
||||
finally:
|
||||
streaming_module.Markdown = original_markdown
|
||||
|
||||
# 4 parses with the fix: intro + block1 + block2 + end. Without
|
||||
# the fix, block1's leading embedded ``\n\n`` would lock the inner
|
||||
# loop on the odd-fence break for every subsequent chunk, so the
|
||||
# entire tail (block1 + block2 + end) collapses into one EOS
|
||||
# force-flush → 2 parses total.
|
||||
assert parse_count[0] == 4, (
|
||||
f"expected 4 parses (intro + 2 blocks + end), got {parse_count[0]}"
|
||||
)
|
||||
|
||||
def test_inline_triple_backtick_mention_does_not_block_paragraph(self) -> None:
|
||||
"""Single inline ``\\`\\`\\``` mention inside flowing text must not
|
||||
be miscounted as an open fence. The substring count would flip to
|
||||
odd (1), skipping the paragraph's ``\\n\\n`` boundary and deferring
|
||||
rendering to EOS. Only line-start fences count, so two paragraphs
|
||||
each render incrementally as their ``\\n\\n`` arrives.
|
||||
"""
|
||||
from surfaces.interactive_shell.ui import streaming as streaming_module
|
||||
|
||||
parse_count = [0]
|
||||
real_markdown = streaming_module.Markdown
|
||||
|
||||
class _SpyMarkdown(real_markdown): # type: ignore[misc, valid-type]
|
||||
def __init__(self, text: str, **kwargs) -> None:
|
||||
parse_count[0] += 1
|
||||
super().__init__(text, **kwargs)
|
||||
|
||||
original_markdown = streaming_module.Markdown
|
||||
streaming_module.Markdown = _SpyMarkdown
|
||||
try:
|
||||
console, _ = _tty_console()
|
||||
stream_to_console(
|
||||
console,
|
||||
label="assistant",
|
||||
chunks=_yield_chunks(
|
||||
[
|
||||
"The ``` marker opens a code block in markdown.\n\n",
|
||||
"Use it whenever you want to fence example code.\n\n",
|
||||
]
|
||||
),
|
||||
)
|
||||
finally:
|
||||
streaming_module.Markdown = original_markdown
|
||||
|
||||
# 2 parses: each paragraph flushes on its own ``\n\n``. Without
|
||||
# the line-start fence check, the single inline ``` would flip
|
||||
# the count to odd, both ``\n\n`` boundaries would be skipped,
|
||||
# and the whole stream would force-flush as 1 parse at EOS.
|
||||
assert parse_count[0] == 2, (
|
||||
f"inline ``` mention blocked paragraph flush — got {parse_count[0]} parses"
|
||||
)
|
||||
|
||||
def test_mid_line_triple_backtick_does_not_count_as_fence(self) -> None:
|
||||
"""A ``\\`\\`\\``` that appears mid-line (not at line start) must
|
||||
NOT be counted as a fence boundary by the parity check. Real
|
||||
code blocks must keep accumulating across paragraphs until a
|
||||
line-start closing fence arrives — the mid-line backticks are
|
||||
inline content (often quoted/embedded in prose), not Markdown
|
||||
syntax.
|
||||
|
||||
Regression for the drive-by review point: a chunk like
|
||||
``\\nresult: ok\\`\\`\\`\\nmore text`` (closing-fence-shaped
|
||||
characters mid-line because the chunk boundary fell there)
|
||||
used to be a worry. The ``^\\`\\`\\``` regex with
|
||||
``re.MULTILINE`` matches only line-start fences, so this
|
||||
scenario stays correct.
|
||||
"""
|
||||
from surfaces.interactive_shell.ui import streaming as streaming_module
|
||||
|
||||
parse_count = [0]
|
||||
real_markdown = streaming_module.Markdown
|
||||
|
||||
class _SpyMarkdown(real_markdown): # type: ignore[misc, valid-type]
|
||||
def __init__(self, text: str, **kwargs) -> None:
|
||||
parse_count[0] += 1
|
||||
super().__init__(text, **kwargs)
|
||||
|
||||
original_markdown = streaming_module.Markdown
|
||||
streaming_module.Markdown = _SpyMarkdown
|
||||
try:
|
||||
console, buf = _tty_console()
|
||||
stream_to_console(
|
||||
console,
|
||||
label="assistant",
|
||||
chunks=_yield_chunks(
|
||||
[
|
||||
# Real fence opens at line start.
|
||||
"```py\n",
|
||||
"x = 1\n",
|
||||
# Mid-line backticks inside the still-open fence.
|
||||
# MUST be ignored by the parity check — fence
|
||||
# stays open until a real closing fence at line
|
||||
# start.
|
||||
"result: ok```\n",
|
||||
"y = 2\n",
|
||||
# Now the real closing fence at line start.
|
||||
"```\n\n",
|
||||
"After the block.\n\n",
|
||||
]
|
||||
),
|
||||
)
|
||||
finally:
|
||||
streaming_module.Markdown = original_markdown
|
||||
|
||||
# 2 parses: (1) the entire fenced code block as one Markdown
|
||||
# parse — proves the mid-line ``` didn't prematurely flush it;
|
||||
# (2) the "After the block." paragraph. Without the line-anchor,
|
||||
# the mid-line ``` would flip the parity, close the fence
|
||||
# early, and we'd see 3+ parses with broken code rendering.
|
||||
assert parse_count[0] == 2, f"mid-line ``` was miscounted — got {parse_count[0]} parses"
|
||||
# And the mid-line backticks must reach the rendered output as
|
||||
# plain text (inside the code block), not get eaten as syntax.
|
||||
output = _strip_ansi(buf.getvalue())
|
||||
assert "result: ok" in output
|
||||
|
||||
def test_unclosed_fence_with_embedded_blank_line_renders_at_eos(self) -> None:
|
||||
"""Unclosed fence containing an embedded ``\\n\\n`` must not hang
|
||||
the inner loop and must surface the partial buffer at end-of-stream.
|
||||
|
||||
With the skip-past-open-fence logic, the inner loop advances
|
||||
``search_from`` past each embedded ``\\n\\n``, eventually returns
|
||||
``-1`` from ``find``, and exits cleanly. The outer ``finally``
|
||||
then force-flushes the partial buffer so the user sees the
|
||||
truncated response rather than nothing.
|
||||
"""
|
||||
chunks = [
|
||||
"```py\n",
|
||||
"a = 1\n\n", # blank inside fence
|
||||
"b = 2\n", # stream ends without closing the fence
|
||||
]
|
||||
|
||||
console, buf = _tty_console()
|
||||
result = stream_to_console(
|
||||
console,
|
||||
label="assistant",
|
||||
chunks=_yield_chunks(chunks),
|
||||
)
|
||||
|
||||
# Both code lines must appear in the rendered output — the
|
||||
# partial fence is force-flushed at EOS so the user sees what
|
||||
# was streamed before the LLM cut off.
|
||||
output = _strip_ansi(buf.getvalue())
|
||||
assert "a = 1" in output
|
||||
assert "b = 2" in output
|
||||
assert "a = 1" in result
|
||||
assert "b = 2" in result
|
||||
|
||||
def test_returns_empty_string_when_stream_is_empty(self) -> None:
|
||||
"""An empty stream must not leave a frozen spinner on screen."""
|
||||
console, buf = _tty_console()
|
||||
result = stream_to_console(
|
||||
console,
|
||||
label="assistant",
|
||||
chunks=_yield_chunks([]),
|
||||
)
|
||||
|
||||
assert result == ""
|
||||
# Bullet still printed (header fires before chunk processing),
|
||||
# but no spinner residue at finalize.
|
||||
assert "●" in _strip_ansi(buf.getvalue())
|
||||
|
||||
|
||||
class TestMidStreamError:
|
||||
"""Errors inside the stream propagate while the partial buffer stays on screen."""
|
||||
|
||||
def test_exception_propagates_with_partial_visible(self) -> None:
|
||||
def _broken_stream() -> Iterator[str]:
|
||||
yield "partial "
|
||||
yield "answer"
|
||||
raise RuntimeError("upstream 503")
|
||||
|
||||
console, buf = _tty_console()
|
||||
|
||||
with pytest.raises(RuntimeError, match="upstream 503"):
|
||||
stream_to_console(
|
||||
console,
|
||||
label="assistant",
|
||||
chunks=_broken_stream(),
|
||||
)
|
||||
|
||||
# The partial response was rendered before the exception propagated,
|
||||
# so the caller can surface an error label below it.
|
||||
output = _strip_ansi(buf.getvalue())
|
||||
assert "partial answer" in output
|
||||
|
||||
def test_keyboard_interrupt_propagates_with_partial_visible(self) -> None:
|
||||
"""KeyboardInterrupt mid-stream propagates after the partial renders.
|
||||
|
||||
The double-press absorption logic that used to live here was moved
|
||||
to the prompt_toolkit cancel key bindings (see
|
||||
:func:`interactive_shell.ui.input_prompt.key_bindings.build_cancel_key_bindings`)
|
||||
— the streaming code just lets ``KeyboardInterrupt`` propagate,
|
||||
and the ``finally`` block in :func:`stream_to_console` ensures
|
||||
the partial buffer is rendered.
|
||||
"""
|
||||
|
||||
class _ChunksThenKbd:
|
||||
__slots__ = ("_i",)
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._i = 0
|
||||
|
||||
def __iter__(self) -> Iterator[str]:
|
||||
return self
|
||||
|
||||
def __next__(self) -> str:
|
||||
parts = ("partial ", "answer")
|
||||
if self._i < len(parts):
|
||||
c = parts[self._i]
|
||||
self._i += 1
|
||||
return c
|
||||
raise KeyboardInterrupt
|
||||
|
||||
console, buf = _tty_console()
|
||||
with pytest.raises(KeyboardInterrupt):
|
||||
stream_to_console(
|
||||
console,
|
||||
label="assistant",
|
||||
chunks=iter(_ChunksThenKbd()),
|
||||
)
|
||||
|
||||
output = _strip_ansi(buf.getvalue())
|
||||
# Partial is rendered before the KI propagates — the ``finally``
|
||||
# in stream_to_console fires the Markdown render of the buffer.
|
||||
assert "partial answer" in output
|
||||
|
||||
|
||||
class TestTimingFooter:
|
||||
"""A small dim ``· Ns`` footer appears after a rendered live response."""
|
||||
|
||||
def test_footer_printed_after_streamed_response(self) -> None:
|
||||
console, buf = _tty_console()
|
||||
stream_to_console(
|
||||
console,
|
||||
label="assistant",
|
||||
chunks=_yield_chunks(["hello"]),
|
||||
)
|
||||
|
||||
output = _strip_ansi(buf.getvalue())
|
||||
assert re.search(r"·\s+\d+\.\d+s", output) is not None
|
||||
|
||||
def test_footer_skipped_when_stream_is_empty(self) -> None:
|
||||
"""Empty stream must not print a timing footer under nothing."""
|
||||
console, buf = _tty_console()
|
||||
stream_to_console(
|
||||
console,
|
||||
label="assistant",
|
||||
chunks=_yield_chunks([]),
|
||||
)
|
||||
|
||||
output = _strip_ansi(buf.getvalue())
|
||||
assert re.search(r"·\s+\d+\.\d+s", output) is None
|
||||
|
||||
def test_footer_skipped_when_response_is_suppressed(self) -> None:
|
||||
"""Suppressed machine-readable payloads should not get a timing footer either."""
|
||||
console, buf = _tty_console()
|
||||
stream_to_console(
|
||||
console,
|
||||
label="assistant",
|
||||
chunks=_yield_chunks(['{"actions"', ":[]}"]),
|
||||
suppress_if_starts_with="{",
|
||||
)
|
||||
|
||||
output = _strip_ansi(buf.getvalue())
|
||||
assert re.search(r"·\s+\d+\.\d+s", output) is None
|
||||
|
||||
|
||||
class TestRenderResponseHeader:
|
||||
"""``render_response_header`` is the bullet-row marker shared with
|
||||
``action_turn.run_action_tool_turn`` — three call sites collapsed
|
||||
to one helper, so we lock in the visible output here.
|
||||
"""
|
||||
|
||||
def test_emits_bullet_glyph_and_label(self) -> None:
|
||||
console, buf = _tty_console()
|
||||
render_response_header(console, "assistant")
|
||||
output = _strip_ansi(buf.getvalue())
|
||||
assert "●" in output
|
||||
assert "assistant" in output
|
||||
|
||||
def test_label_is_passthrough(self) -> None:
|
||||
"""The function takes the label verbatim — callers pass either
|
||||
``STREAM_LABEL_ANSWER`` or ``STREAM_LABEL_ASSISTANT`` (or any
|
||||
free-form word). No filtering, no defaults."""
|
||||
console, buf = _tty_console()
|
||||
render_response_header(console, "answer")
|
||||
assert "answer" in _strip_ansi(buf.getvalue())
|
||||
|
||||
|
||||
class TestFormatTokenCountShort:
|
||||
"""Shared helper used by both the streaming footer and the live spinner."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("count", "expected"),
|
||||
[
|
||||
(0, "0"),
|
||||
(1, "1"),
|
||||
(999, "999"),
|
||||
(1000, "1.0k"),
|
||||
(1234, "1.2k"),
|
||||
(10000, "10.0k"),
|
||||
(123456, "123.5k"),
|
||||
],
|
||||
)
|
||||
def test_formats_at_boundaries(self, count: int, expected: str) -> None:
|
||||
assert format_token_count_short(count) == expected
|
||||
|
||||
|
||||
class _ProgressConsole(Console):
|
||||
"""Console with the loop's :class:`StreamingConsole` shape — exposes
|
||||
``update_streaming_progress`` and ``cancel_requested`` for the
|
||||
streaming layer's ``getattr`` dispatch.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
cancel_event: threading.Event | None = None,
|
||||
cancel_after_n_progress_calls: int | None = None,
|
||||
**kwargs: object,
|
||||
) -> None:
|
||||
super().__init__(**kwargs) # type: ignore[arg-type]
|
||||
self.progress_calls: list[int] = []
|
||||
self._cancel_event = cancel_event or threading.Event()
|
||||
self._cancel_after = cancel_after_n_progress_calls
|
||||
|
||||
def update_streaming_progress(self, bytes_received: int) -> None:
|
||||
self.progress_calls.append(bytes_received)
|
||||
if self._cancel_after is not None and len(self.progress_calls) >= self._cancel_after:
|
||||
self._cancel_event.set()
|
||||
|
||||
@property
|
||||
def cancel_requested(self) -> bool:
|
||||
return self._cancel_event.is_set()
|
||||
|
||||
|
||||
class TestProgressHook:
|
||||
"""``stream_to_console`` invokes the optional ``update_streaming_progress``
|
||||
hook on the console and throttles the call rate so worker-thread → UI
|
||||
cross-thread queueing isn't flooded on long streams.
|
||||
"""
|
||||
|
||||
def test_progress_hook_called_with_running_byte_count(self) -> None:
|
||||
buf = io.StringIO()
|
||||
console = _ProgressConsole(file=buf, force_terminal=True, color_system=None, width=80)
|
||||
chunks = ["Hello, ", "world", "!"]
|
||||
result = stream_to_console(
|
||||
console,
|
||||
label="assistant",
|
||||
chunks=_yield_chunks(chunks),
|
||||
)
|
||||
|
||||
assert result == "Hello, world!"
|
||||
assert console.progress_calls, "progress hook never fired"
|
||||
# Counts must be monotonically non-decreasing — the streaming
|
||||
# layer pushes a *running* byte total, never a per-chunk delta.
|
||||
assert console.progress_calls == sorted(console.progress_calls)
|
||||
# Each reported count must reflect bytes that *had* arrived by
|
||||
# that point in the stream — never exceed the final total.
|
||||
assert console.progress_calls[-1] <= len(result)
|
||||
|
||||
def test_progress_hook_throttled_on_burst_streams(self) -> None:
|
||||
"""A burst of 200 small chunks must not produce 200 hook calls.
|
||||
|
||||
Throttling target is ~10/s; the test stream finishes well under
|
||||
a second so we expect a small handful of calls (not one per
|
||||
chunk). The exact count is timing-dependent — assert ``<= 50``
|
||||
as a generous upper bound that still proves throttling fires.
|
||||
"""
|
||||
buf = io.StringIO()
|
||||
console = _ProgressConsole(file=buf, force_terminal=True, color_system=None, width=80)
|
||||
burst = ["x"] * 200
|
||||
stream_to_console(
|
||||
console,
|
||||
label="assistant",
|
||||
chunks=_yield_chunks(burst),
|
||||
)
|
||||
|
||||
assert len(console.progress_calls) <= 50, (
|
||||
f"throttle did not fire — got {len(console.progress_calls)} calls"
|
||||
)
|
||||
|
||||
def test_no_hook_when_console_lacks_method(self) -> None:
|
||||
"""Plain ``Console`` (no progress method) must stream cleanly."""
|
||||
console, buf = _tty_console()
|
||||
result = stream_to_console(
|
||||
console,
|
||||
label="assistant",
|
||||
chunks=_yield_chunks(["alpha", "beta"]),
|
||||
)
|
||||
assert result == "alphabeta"
|
||||
|
||||
def test_progress_hook_failure_does_not_truncate_response(self) -> None:
|
||||
"""A flaky status widget must never lose response content."""
|
||||
|
||||
class _BrokenConsole(Console):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
file=io.StringIO(),
|
||||
force_terminal=True,
|
||||
color_system=None,
|
||||
width=80,
|
||||
)
|
||||
|
||||
def update_streaming_progress(self, bytes_received: int) -> None: # noqa: ARG002
|
||||
raise RuntimeError("widget gone")
|
||||
|
||||
console = _BrokenConsole()
|
||||
result = stream_to_console(
|
||||
console,
|
||||
label="assistant",
|
||||
chunks=_yield_chunks(["full ", "answer"]),
|
||||
)
|
||||
assert result == "full answer"
|
||||
|
||||
|
||||
class TestParagraphFlushThrottle:
|
||||
"""Long single-paragraph streams must not pay O(n²) work re-joining
|
||||
the buffer on every chunk. The fast-path skips the join when no
|
||||
paragraph boundary could possibly land in the new chunk.
|
||||
"""
|
||||
|
||||
def _spy_markdown_parses(self, monkeypatch: pytest.MonkeyPatch) -> list[int]:
|
||||
"""Wrap ``streaming.Markdown`` so each construction increments a counter."""
|
||||
from surfaces.interactive_shell.ui import streaming as streaming_module
|
||||
|
||||
parse_count = [0]
|
||||
real_markdown = streaming_module.Markdown
|
||||
|
||||
class _SpyMarkdown(real_markdown): # type: ignore[misc, valid-type]
|
||||
def __init__(self, text: str, **kwargs) -> None:
|
||||
parse_count[0] += 1
|
||||
super().__init__(text, **kwargs)
|
||||
|
||||
monkeypatch.setattr(streaming_module, "Markdown", _SpyMarkdown)
|
||||
return parse_count
|
||||
|
||||
def test_long_single_paragraph_renders_once(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""No ``\\n\\n`` in any chunk → the only Markdown parse is the
|
||||
end-of-stream force-flush. Proves the fast-path skips the join
|
||||
on every intermediate chunk."""
|
||||
parse_count = self._spy_markdown_parses(monkeypatch)
|
||||
console, _ = _tty_console()
|
||||
|
||||
# 500 chunks, each a few words, no paragraph breaks.
|
||||
chunks = [f"word{i} " for i in range(500)]
|
||||
result = stream_to_console(console, label="assistant", chunks=_yield_chunks(chunks))
|
||||
|
||||
assert "word0" in result
|
||||
assert "word499" in result
|
||||
# End-of-stream force-flush is the only Markdown construction.
|
||||
assert parse_count[0] == 1, f"expected 1 parse (force-flush), got {parse_count[0]}"
|
||||
|
||||
def test_paragraph_boundary_per_chunk_renders_once_per_paragraph(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Each ``\\n\\n`` boundary triggers exactly one Markdown
|
||||
parse — the trailing tail is force-flushed at end."""
|
||||
parse_count = self._spy_markdown_parses(monkeypatch)
|
||||
console, _ = _tty_console()
|
||||
|
||||
chunks = [
|
||||
"para 1.\n\n",
|
||||
"para 2.\n\n",
|
||||
"para 3.\n\n",
|
||||
"trailing tail",
|
||||
]
|
||||
stream_to_console(console, label="assistant", chunks=_yield_chunks(chunks))
|
||||
|
||||
# 3 in-loop renders + 1 force-flush at end = 4 total.
|
||||
assert parse_count[0] == 4
|
||||
|
||||
def test_chunks_with_only_single_newlines_skip_flush(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Lists / code with single ``\\n`` separators don't trigger
|
||||
flush until a real ``\\n\\n`` boundary closes the block.
|
||||
Keeping multi-line list/table syntax intact is required for
|
||||
Rich's Markdown renderer to produce a proper Table / bullet
|
||||
list rather than rendering each row as a standalone block."""
|
||||
parse_count = self._spy_markdown_parses(monkeypatch)
|
||||
console, _ = _tty_console()
|
||||
|
||||
chunks = [
|
||||
"- item 1\n",
|
||||
"- item 2\n",
|
||||
"- item 3\n",
|
||||
# No \n\n — only force-flush at end.
|
||||
]
|
||||
stream_to_console(console, label="assistant", chunks=_yield_chunks(chunks))
|
||||
|
||||
assert parse_count[0] == 1
|
||||
|
||||
|
||||
class TestCancelPolling:
|
||||
"""``stream_to_console`` polls ``console.cancel_requested`` between
|
||||
chunks so an Esc-driven cancel signal stops the worker-thread stream
|
||||
before it drains the iterator.
|
||||
"""
|
||||
|
||||
def test_cancel_set_before_stream_returns_empty_partial(self) -> None:
|
||||
buf = io.StringIO()
|
||||
cancel_event = threading.Event()
|
||||
cancel_event.set() # cancel before any chunk is pulled
|
||||
console = _ProgressConsole(
|
||||
cancel_event=cancel_event,
|
||||
file=buf,
|
||||
force_terminal=True,
|
||||
color_system=None,
|
||||
width=80,
|
||||
)
|
||||
|
||||
# If the cancel poll didn't work, the iterator below would
|
||||
# raise (it's a single-use generator).
|
||||
chunks_iter = _yield_chunks(["a", "b", "c"])
|
||||
result = stream_to_console(console, label="assistant", chunks=chunks_iter)
|
||||
assert result == ""
|
||||
|
||||
def test_cancel_mid_stream_truncates_buffer(self) -> None:
|
||||
"""Cancel signalled mid-stream stops further chunk reads.
|
||||
|
||||
Uses a generator that flips the cancel flag from inside its own
|
||||
yield loop — that's deterministic regardless of throttling, since
|
||||
the next iteration of ``stream_to_console``'s loop checks the
|
||||
cancel flag *before* pulling the next chunk.
|
||||
"""
|
||||
buf = io.StringIO()
|
||||
cancel_event = threading.Event()
|
||||
console = _ProgressConsole(
|
||||
cancel_event=cancel_event,
|
||||
file=buf,
|
||||
force_terminal=True,
|
||||
color_system=None,
|
||||
width=80,
|
||||
)
|
||||
|
||||
chunks_yielded: list[int] = []
|
||||
|
||||
def _chunks_with_cancel() -> Iterator[str]:
|
||||
for i in range(20):
|
||||
chunks_yielded.append(i)
|
||||
if i == 3:
|
||||
cancel_event.set()
|
||||
yield f"chunk{i} "
|
||||
|
||||
result = stream_to_console(console, label="assistant", chunks=_chunks_with_cancel())
|
||||
|
||||
# The generator should not have been pumped through to chunk 19 —
|
||||
# ``stream_to_console`` should have broken out of its loop once
|
||||
# the cancel event was visible.
|
||||
assert max(chunks_yielded) < 19, (
|
||||
f"generator yielded too many chunks — got up to {max(chunks_yielded)}"
|
||||
)
|
||||
# The result must include chunks read before the cancel was
|
||||
# observed and must not include the trailing chunks.
|
||||
assert result.startswith("chunk0 ")
|
||||
assert "chunk19" not in result
|
||||
|
||||
def test_no_cancel_attr_means_stream_runs_to_completion(self) -> None:
|
||||
"""A console without ``cancel_requested`` must drain normally."""
|
||||
console, buf = _tty_console()
|
||||
result = stream_to_console(
|
||||
console,
|
||||
label="assistant",
|
||||
chunks=_yield_chunks(["one ", "two ", "three"]),
|
||||
)
|
||||
assert result == "one two three"
|
||||
|
||||
|
||||
class TestSuppressionPeek:
|
||||
"""``suppress_if_starts_with`` skips live rendering for content the caller will handle."""
|
||||
|
||||
def test_suppresses_and_drains_when_first_char_matches(self) -> None:
|
||||
console, buf = _tty_console()
|
||||
result = stream_to_console(
|
||||
console,
|
||||
label="assistant",
|
||||
chunks=_yield_chunks(['{"actions"', ":[]", "}"]),
|
||||
suppress_if_starts_with="{",
|
||||
)
|
||||
|
||||
assert result == '{"actions":[]}'
|
||||
# No bullet header, no markdown, no live-region artifacts.
|
||||
output = _strip_ansi(buf.getvalue())
|
||||
assert "●" not in output
|
||||
assert '{"actions"' not in output
|
||||
|
||||
def test_renders_normally_when_first_char_does_not_match(self) -> None:
|
||||
console, buf = _tty_console()
|
||||
result = stream_to_console(
|
||||
console,
|
||||
label="assistant",
|
||||
chunks=_yield_chunks(["Hello, ", "world"]),
|
||||
suppress_if_starts_with="{",
|
||||
)
|
||||
|
||||
assert result == "Hello, world"
|
||||
output = _strip_ansi(buf.getvalue())
|
||||
assert "●" in output
|
||||
assert "Hello, world" in output
|
||||
|
||||
def test_skips_leading_whitespace_before_deciding(self) -> None:
|
||||
"""Leading whitespace must not block the suppression peek."""
|
||||
console, buf = _tty_console()
|
||||
result = stream_to_console(
|
||||
console,
|
||||
label="assistant",
|
||||
chunks=_yield_chunks([" \n", '{"action"', ':"slash"}']),
|
||||
suppress_if_starts_with="{",
|
||||
)
|
||||
|
||||
assert result == ' \n{"action":"slash"}'
|
||||
output = _strip_ansi(buf.getvalue())
|
||||
assert "●" not in output
|
||||
@@ -0,0 +1,36 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from surfaces.interactive_shell.ui.components.time_format import (
|
||||
format_repl_duration,
|
||||
format_repl_timestamp,
|
||||
)
|
||||
|
||||
|
||||
def test_format_repl_duration() -> None:
|
||||
assert format_repl_duration(None) == "—"
|
||||
assert format_repl_duration(45) == "45s"
|
||||
assert format_repl_duration(125) == "2m 5s"
|
||||
assert format_repl_duration(3725) == "1h 2m"
|
||||
|
||||
|
||||
def test_format_repl_timestamp_iso_table_style() -> None:
|
||||
dt = datetime(2026, 5, 29, 10, 15, tzinfo=UTC)
|
||||
assert format_repl_timestamp(dt.isoformat(), style="table") == dt.astimezone().strftime(
|
||||
"%Y-%m-%d %H:%M"
|
||||
)
|
||||
|
||||
|
||||
def test_format_repl_timestamp_compact_style() -> None:
|
||||
dt = datetime(2026, 5, 29, 10, 15, tzinfo=UTC)
|
||||
assert format_repl_timestamp(dt, style="compact") == dt.astimezone().strftime("%m-%d %H:%M")
|
||||
|
||||
|
||||
def test_format_repl_timestamp_unix_utc_style() -> None:
|
||||
ts = datetime(2026, 5, 29, 10, 15, tzinfo=UTC).timestamp()
|
||||
assert format_repl_timestamp(ts, style="utc") == "2026-05-29 10:15:00 UTC"
|
||||
|
||||
|
||||
def test_format_repl_timestamp_invalid_iso_fallback() -> None:
|
||||
assert format_repl_timestamp("not-a-timestamp", style="table") == "not-a-timestamp"
|
||||
@@ -0,0 +1,324 @@
|
||||
"""Tests for the registered-tool catalog used by the ``/tools list`` slash command."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from rich.console import Console
|
||||
|
||||
from core.tool_framework.registered_tool import RegisteredTool
|
||||
from surfaces.interactive_shell.command_registry import dispatch_slash
|
||||
from surfaces.interactive_shell.command_registry.tools_cmds import _TOOLS_FIRST_ARGS, _cmd_tools
|
||||
from surfaces.interactive_shell.session import Session
|
||||
from surfaces.interactive_shell.ui.tables import tool_catalog
|
||||
from surfaces.interactive_shell.ui.tables.tool_catalog import (
|
||||
ToolCatalogEntry,
|
||||
_summarize_input_schema,
|
||||
build_tool_catalog,
|
||||
format_tool_catalog_text,
|
||||
)
|
||||
|
||||
|
||||
def _make_tool(
|
||||
name: str,
|
||||
*,
|
||||
description: str = "Tool description.",
|
||||
surfaces: tuple[str, ...] = ("investigation",),
|
||||
input_schema: dict[str, Any] | None = None,
|
||||
origin_module: str = "tools.registry",
|
||||
) -> RegisteredTool:
|
||||
"""Construct a minimal RegisteredTool stub for catalog rendering tests."""
|
||||
return RegisteredTool(
|
||||
name=name,
|
||||
description=description,
|
||||
input_schema=input_schema or {"type": "object", "properties": {}, "required": []},
|
||||
source="knowledge",
|
||||
run=lambda **_: None,
|
||||
surfaces=surfaces,
|
||||
origin_module=origin_module,
|
||||
origin_name=name,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_registry(monkeypatch: pytest.MonkeyPatch) -> Iterator[list[RegisteredTool]]:
|
||||
"""Replace the live registry with a curated set so tests are deterministic."""
|
||||
tools: list[RegisteredTool] = []
|
||||
|
||||
def _fake_get_registered_tools(surface: str | None = None) -> list[RegisteredTool]:
|
||||
if surface is None:
|
||||
return list(tools)
|
||||
return [t for t in tools if surface in t.surfaces]
|
||||
|
||||
monkeypatch.setattr(tool_catalog, "get_registered_tools", _fake_get_registered_tools)
|
||||
yield tools
|
||||
|
||||
|
||||
class TestSummarizeInputSchema:
|
||||
def test_empty_schema_renders_no_params(self) -> None:
|
||||
assert _summarize_input_schema({}) == "(no params)"
|
||||
assert (
|
||||
_summarize_input_schema({"type": "object", "properties": {}, "required": []})
|
||||
== "(no params)"
|
||||
)
|
||||
|
||||
def test_required_params_render_without_question_mark(self) -> None:
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {"query": {"type": "string"}, "limit": {"type": "integer"}},
|
||||
"required": ["query", "limit"],
|
||||
}
|
||||
assert _summarize_input_schema(schema) == "query: string, limit: integer"
|
||||
|
||||
def test_optional_params_get_question_mark_suffix(self) -> None:
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {"query": {"type": "string"}, "limit": {"type": "integer"}},
|
||||
"required": ["query"],
|
||||
}
|
||||
assert _summarize_input_schema(schema) == "query: string, limit?: integer"
|
||||
|
||||
def test_untyped_property_renders_as_any(self) -> None:
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {"cloudops_backend": {}},
|
||||
"required": ["cloudops_backend"],
|
||||
}
|
||||
assert _summarize_input_schema(schema) == "cloudops_backend: any"
|
||||
|
||||
def test_overlong_summary_is_truncated_with_ellipsis(self) -> None:
|
||||
# Build a schema large enough to exceed the 200-char cap.
|
||||
properties = {f"param_{i}": {"type": "string"} for i in range(40)}
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": properties,
|
||||
"required": list(properties.keys()),
|
||||
}
|
||||
rendered = _summarize_input_schema(schema)
|
||||
assert len(rendered) <= 200
|
||||
assert rendered.endswith("…")
|
||||
|
||||
|
||||
class TestBuildToolCatalog:
|
||||
def test_returns_empty_list_when_no_tools(self, fake_registry: list[RegisteredTool]) -> None:
|
||||
del fake_registry # unused — registry is empty by default
|
||||
assert build_tool_catalog() == []
|
||||
|
||||
def test_projects_each_tool_into_a_catalog_entry(
|
||||
self, fake_registry: list[RegisteredTool]
|
||||
) -> None:
|
||||
fake_registry.append(
|
||||
_make_tool(
|
||||
"search_github",
|
||||
description="Search GitHub code.",
|
||||
surfaces=("investigation", "chat"),
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"properties": {"query": {"type": "string"}},
|
||||
"required": ["query"],
|
||||
},
|
||||
)
|
||||
)
|
||||
entries = build_tool_catalog()
|
||||
assert len(entries) == 1
|
||||
entry = entries[0]
|
||||
assert entry.name == "search_github"
|
||||
assert entry.surfaces == ("investigation", "chat")
|
||||
assert entry.description == "Search GitHub code."
|
||||
assert entry.input_schema_summary == "query: string"
|
||||
# Source file for the registry module resolves to its repo-relative path.
|
||||
assert entry.source_file == "tools/registry.py"
|
||||
|
||||
def test_filters_by_surface_when_provided(self, fake_registry: list[RegisteredTool]) -> None:
|
||||
fake_registry.extend(
|
||||
[
|
||||
_make_tool("inv_only", surfaces=("investigation",)),
|
||||
_make_tool("chat_only", surfaces=("chat",)),
|
||||
_make_tool("both", surfaces=("investigation", "chat")),
|
||||
]
|
||||
)
|
||||
chat_entries = build_tool_catalog(surface="chat")
|
||||
names = {e.name for e in chat_entries}
|
||||
assert names == {"chat_only", "both"}
|
||||
|
||||
def test_unresolvable_origin_module_yields_empty_source_file(
|
||||
self, fake_registry: list[RegisteredTool]
|
||||
) -> None:
|
||||
# A tool that registered but whose origin module no longer imports
|
||||
# cleanly (e.g. partial uninstall) must still surface in the catalog.
|
||||
fake_registry.append(_make_tool("orphan", origin_module="not.a.real.module"))
|
||||
entries = build_tool_catalog()
|
||||
assert len(entries) == 1
|
||||
assert entries[0].source_file == ""
|
||||
|
||||
def test_origin_module_outside_repo_returns_absolute_source_path(
|
||||
self, fake_registry: list[RegisteredTool], monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
outside = Path("/virtual/site-packages/some_pkg/plugin.py")
|
||||
fake_mod = SimpleNamespace(__file__=str(outside))
|
||||
|
||||
monkeypatch.setattr(tool_catalog.importlib, "import_module", lambda _: fake_mod)
|
||||
fake_registry.append(_make_tool("ext_tool", origin_module="some_pkg.plugin"))
|
||||
entries = build_tool_catalog()
|
||||
assert len(entries) == 1
|
||||
assert entries[0].source_file == outside.as_posix()
|
||||
|
||||
|
||||
class TestFormatToolCatalogText:
|
||||
def test_returns_empty_string_for_empty_catalog(self) -> None:
|
||||
assert format_tool_catalog_text([]) == ""
|
||||
|
||||
def test_groups_by_surface_with_investigation_first(self) -> None:
|
||||
entries = [
|
||||
ToolCatalogEntry(
|
||||
name="alpha",
|
||||
surfaces=("investigation",),
|
||||
description="alpha desc",
|
||||
source_file="tools/alpha.py",
|
||||
input_schema_summary="(no params)",
|
||||
),
|
||||
ToolCatalogEntry(
|
||||
name="beta",
|
||||
surfaces=("chat",),
|
||||
description="beta desc",
|
||||
source_file="tools/beta.py",
|
||||
input_schema_summary="x: string",
|
||||
),
|
||||
]
|
||||
text = format_tool_catalog_text(entries)
|
||||
# Investigation header must precede chat header (canonical ordering).
|
||||
inv_pos = text.find("## investigation")
|
||||
chat_pos = text.find("## chat")
|
||||
assert inv_pos != -1 and chat_pos != -1
|
||||
assert inv_pos < chat_pos
|
||||
assert "alpha" in text and "beta" in text
|
||||
|
||||
def test_dual_surface_tool_appears_under_each_surface(self) -> None:
|
||||
entries = [
|
||||
ToolCatalogEntry(
|
||||
name="dual",
|
||||
surfaces=("investigation", "chat"),
|
||||
description="dual desc",
|
||||
source_file="tools/dual.py",
|
||||
input_schema_summary="(no params)",
|
||||
)
|
||||
]
|
||||
text = format_tool_catalog_text(entries)
|
||||
# Dual-surface tools surface in BOTH groups so the user can tell which
|
||||
# tools the chat agent vs the investigation pipeline can reach.
|
||||
assert text.count("**dual**") == 2
|
||||
assert "## investigation (1 tool)" in text
|
||||
assert "## chat (1 tool)" in text
|
||||
|
||||
def test_omits_source_line_when_source_file_unknown(self) -> None:
|
||||
entries = [
|
||||
ToolCatalogEntry(
|
||||
name="orphan",
|
||||
surfaces=("investigation",),
|
||||
description="orphan desc",
|
||||
source_file="",
|
||||
input_schema_summary="(no params)",
|
||||
)
|
||||
]
|
||||
text = format_tool_catalog_text(entries)
|
||||
assert "**orphan**" in text
|
||||
assert "source:" not in text
|
||||
|
||||
|
||||
class TestListToolsSlashCommand:
|
||||
"""``/tools list`` reaches the catalog and prints non-empty output."""
|
||||
|
||||
def _capture(self) -> tuple[Console, io.StringIO]:
|
||||
buf = io.StringIO()
|
||||
return Console(file=buf, force_terminal=False, highlight=False), buf
|
||||
|
||||
def test_list_tools_prints_grouped_catalog(self) -> None:
|
||||
console, buf = self._capture()
|
||||
session = Session()
|
||||
# Stub the catalog so the test stays decoupled from registry contents.
|
||||
fake = [
|
||||
ToolCatalogEntry(
|
||||
name="search_github",
|
||||
surfaces=("investigation", "chat"),
|
||||
description="Search GitHub code.",
|
||||
source_file="tools/search_github.py",
|
||||
input_schema_summary="query: string",
|
||||
)
|
||||
]
|
||||
with patch(
|
||||
"surfaces.interactive_shell.command_registry.tools_cmds.build_tool_catalog",
|
||||
return_value=fake,
|
||||
):
|
||||
assert _cmd_tools(session, console, ["list"]) is True
|
||||
out = buf.getvalue()
|
||||
assert "search_github" in out
|
||||
assert "investigation" in out
|
||||
assert "chat" in out
|
||||
assert "Search GitHub code." in out
|
||||
|
||||
def test_bare_tools_command_prints_full_registered_catalog(self) -> None:
|
||||
console, buf = self._capture()
|
||||
session = Session()
|
||||
fake = [
|
||||
ToolCatalogEntry(
|
||||
name="telegram_send_message",
|
||||
surfaces=("investigation", "chat"),
|
||||
description="Send a Telegram message.",
|
||||
source_file="tools/telegram_send_message_tool/tool.py",
|
||||
input_schema_summary="message: string",
|
||||
)
|
||||
]
|
||||
with patch(
|
||||
"surfaces.interactive_shell.command_registry.tools_cmds.build_tool_catalog",
|
||||
return_value=fake,
|
||||
) as catalog:
|
||||
assert dispatch_slash("/tools", session, console) is True
|
||||
catalog.assert_called_once_with()
|
||||
out = buf.getvalue()
|
||||
assert "telegram_send_message" in out
|
||||
assert "investigation" in out
|
||||
assert "chat" in out
|
||||
|
||||
def test_live_catalog_includes_telegram_send_message(self) -> None:
|
||||
names = {entry.name for entry in build_tool_catalog()}
|
||||
assert "telegram_send_message" in names
|
||||
|
||||
def test_list_tools_disables_markup_for_plain_catalog_text(self) -> None:
|
||||
console, buf = self._capture()
|
||||
session = Session()
|
||||
fake = [
|
||||
ToolCatalogEntry(
|
||||
name="risky_tool",
|
||||
surfaces=("investigation",),
|
||||
description="Payload [bold]injection[/bold] attempt",
|
||||
source_file="tools/risky.py",
|
||||
input_schema_summary="x: string",
|
||||
)
|
||||
]
|
||||
with patch(
|
||||
"surfaces.interactive_shell.command_registry.tools_cmds.build_tool_catalog",
|
||||
return_value=fake,
|
||||
):
|
||||
assert _cmd_tools(session, console, ["list"]) is True
|
||||
out = buf.getvalue()
|
||||
assert "[bold]injection[/bold]" in out
|
||||
|
||||
def test_list_tools_handles_empty_registry(self) -> None:
|
||||
console, buf = self._capture()
|
||||
session = Session()
|
||||
with patch(
|
||||
"surfaces.interactive_shell.command_registry.tools_cmds.build_tool_catalog",
|
||||
return_value=[],
|
||||
):
|
||||
assert _cmd_tools(session, console, ["list"]) is True
|
||||
assert "no tools registered" in buf.getvalue()
|
||||
|
||||
def test_tools_first_args_advertise_list_for_tab_completion(self) -> None:
|
||||
names = {arg for arg, _hint in _TOOLS_FIRST_ARGS}
|
||||
assert "list" in names
|
||||
Reference in New Issue
Block a user