Files
agent-desktop/tests/unit/cli/tui/test_app.py
T

1740 lines
59 KiB
Python

# -*- coding: utf-8 -*-
"""Pilot-driven smoke tests for the Textual app with a fake transport."""
from __future__ import annotations
# Tests poke at the app's internals and use terse fixtures.
# pylint: disable=protected-access,not-an-iterable,disallowed-name
# pylint: disable=consider-using-with,unnecessary-lambda
# pylint: disable=use-implicit-booleaness-not-comparison
import asyncio
import time
import pytest
from textual.widgets import ListView
from pineagents.cli.tui.app import PawApp
from pineagents.cli.tui.events import (
AvailableCommands,
BackendWarmed,
Connected,
FileLink,
PermissionOption,
PermissionExpired,
PermissionRequest,
SessionSummary,
SessionTitle,
SlashCommand,
TextDelta,
ThoughtDelta,
TokenUsage,
ToolCall,
TurnEnded,
UserTurn,
)
from pineagents.cli.tui.widgets import (
ActivityLine,
AgentLabel,
AssistantMessage,
CommandMenu,
ErrorMessage,
FileLinkBox,
InfoMessage,
PermissionOverlay,
QueuedMessage,
SessionPicker,
StatusBar,
ThoughtMessage,
ToolPanel,
UserMessage,
WelcomeMessage,
ThemePicker,
)
pytestmark = [pytest.mark.unit, pytest.mark.p1]
class FakeTransport:
"""In-process transport that scripts a canned turn for the UI."""
def __init__(self, *, resume_session_id: str | None = None) -> None:
self._queue: asyncio.Queue = asyncio.Queue()
self.sent: list[str] = []
self.interrupted = False
self.resolved: list[tuple[str, str | None]] = []
self.closed = False
self._permission_mode = "none"
# Resume support: past sessions to offer, and a record of loads.
self.sessions: list[SessionSummary] = []
self.loaded: list[str] = []
self._resume_session_id = resume_session_id
async def start(self) -> Connected:
if self._resume_session_id is not None:
await self.load_session(self._resume_session_id)
return Connected(
session_id=self._resume_session_id,
agent="default",
qwenpaw_version="9.8.7",
)
return Connected(
session_id="sess-abc",
agent="default",
model="qwen-max",
qwenpaw_version="9.8.7",
)
async def send(self, text: str) -> None:
self.sent.append(text)
if "permission" in text:
await self._queue.put(
PermissionRequest(
request_id="r1",
title="dangerous_tool",
tool_kind="execute",
params="command: rm -rf /tmp/nope",
options=[
PermissionOption("allow", "Allow", "allow_once"),
PermissionOption("deny", "Deny", "reject_once"),
],
),
)
return
for chunk in ("Hello ", "there"):
await self._queue.put(TextDelta(chunk))
await self._queue.put(
ToolCall(
"t1",
"read_file",
kind="read",
status="completed",
output="data",
),
)
await self._queue.put(TurnEnded(stop_reason="end_turn"))
async def interrupt(self) -> None:
self.interrupted = True
await self._queue.put(TurnEnded(stop_reason="cancelled"))
async def list_sessions(self) -> list[SessionSummary]:
return list(self.sessions)
async def load_session(self, session_id: str) -> None:
self.loaded.append(session_id)
# Replay a tiny saved transcript, like the real backend does.
await self._queue.put(UserTurn("How do I write a loop in Rust?"))
await self._queue.put(TextDelta("Use a `for` loop."))
await self._queue.put(UserTurn("Thanks!"))
def events(self):
async def _gen():
while True:
item = await self._queue.get()
if item is None:
return
yield item
return _gen()
async def resolve_permission(self, request_id, option_id):
self.resolved.append((request_id, option_id))
await self._queue.put(TextDelta(f"[{option_id}]"))
await self._queue.put(TurnEnded())
async def close(self):
self.closed = True
await self._queue.put(None)
class SlowStartTransport(FakeTransport):
def __init__(self) -> None:
super().__init__()
self.release = asyncio.Event()
async def start(self) -> Connected:
await self.release.wait()
return await super().start()
class WarmingTransport(FakeTransport):
async def start(self) -> Connected:
connected = await super().start()
return Connected(
session_id=connected.session_id,
agent=connected.agent,
model=connected.model,
qwenpaw_version=connected.qwenpaw_version,
warming=True,
)
class QuietTransport(FakeTransport):
async def send(self, text: str) -> None:
self.sent.append(text)
class WarmingQuietTransport(WarmingTransport):
async def send(self, text: str) -> None:
self.sent.append(text)
@pytest.mark.asyncio
async def test_basic_turn_renders():
transport = FakeTransport()
app = PawApp(transport)
async with app.run_test() as pilot:
await pilot.pause()
# status bar picked up the Connected event
assert "qwen-max" in app.query_one("StatusBar").summary
prompt = app.query_one("#prompt")
prompt.value = "hi"
await pilot.press("enter")
# let the scripted events drain
for _ in range(10):
await pilot.pause()
if not app._busy:
break
assert transport.sent == ["hi"]
assert any(isinstance(w, UserMessage) for w in app.query(UserMessage))
assistant = app.query(AssistantMessage).first()
assert assistant.text == "Hello there"
tools = list(app.query(ToolPanel))
assert tools and tools[0]._status == "completed"
class EmptyTurnTransport(FakeTransport):
"""A turn that ends with no content — e.g. an unusable model."""
async def send(self, text: str) -> None:
self.sent.append(text)
await self._queue.put(TurnEnded(stop_reason="end_turn"))
@pytest.mark.asyncio
async def test_empty_turn_surfaces_error():
"""A turn with no visible output reports an error, not a silent return."""
transport = EmptyTurnTransport()
app = PawApp(transport)
async with app.run_test() as pilot:
await pilot.pause()
app._model = "dashscope:qwen3-max"
app.query_one("#prompt").value = "hi"
await pilot.press("enter")
for _ in range(10):
await pilot.pause()
if not app._busy:
break
assert not app._busy # not stuck busy
errors = list(app.query(ErrorMessage))
assert errors, "expected an error message for an empty turn"
assert "No response" in errors[-1].content.plain
assert "dashscope:qwen3-max" in errors[-1].content.plain
@pytest.mark.asyncio
async def test_cancelled_empty_turn_is_not_an_error():
"""Interrupting a turn before any output must not look like a failure."""
transport = FakeTransport()
app = PawApp(transport)
async with app.run_test() as pilot:
await pilot.pause()
await app._dispatch(TurnEnded(stop_reason="cancelled"))
await pilot.pause()
assert not list(app.query(ErrorMessage))
@pytest.mark.asyncio
async def test_message_bubbles_are_transparent():
"""Message bubbles blend into the background.
The background is a static colour and the bubble fills are transparent, so
text and bubble are consistent with the overall background; only a rounded
border outlines each one.
"""
transport = FakeTransport()
app = PawApp(transport)
async with app.run_test() as pilot:
await pilot.pause()
app.query_one("#prompt").value = "hi"
await pilot.press("enter")
for _ in range(10):
await pilot.pause()
if not app._busy:
break
bubbles = [
app.query(UserMessage).first(),
app.query(AssistantMessage).first(),
app.query(AssistantMessage).first().query_one("Markdown"),
app.query(ToolPanel).first(),
]
for bubble in bubbles:
assert (
bubble.styles.background.a == 0
), f"{type(bubble).__name__} background is not transparent"
@pytest.mark.asyncio
async def test_assistant_bubble_has_no_trailing_blank_row():
"""A single-line answer is border + one text row + border = 3 rows.
A trailing markdown paragraph margin would make it 4 (uneven bottom
padding); the last-child margin reset keeps top and bottom even.
"""
transport = FakeTransport()
app = PawApp(transport)
async with app.run_test() as pilot:
await pilot.pause()
app.query_one("#prompt").value = "hi"
await pilot.press("enter")
for _ in range(10):
await pilot.pause()
if not app._busy:
break
assistant = app.query(AssistantMessage).first()
assert assistant.region.height == 3
@pytest.mark.asyncio
async def test_running_tool_expanded_then_collapsed_when_done():
"""A tool stays open while running, then auto-collapses on completion."""
transport = FakeTransport()
app = PawApp(transport)
async with app.run_test() as pilot:
await pilot.pause()
await app._dispatch(
ToolCall(
"t1",
"execute_shell_command",
kind="execute",
status="in_progress",
params="command: ls -la",
),
)
await pilot.pause()
panel = app.query(ToolPanel).first()
assert panel.collapsed is False # running → expanded
await app._dispatch(
ToolCall(
"t1",
"execute_shell_command",
kind="execute",
status="completed",
output="total 0",
),
)
await pilot.pause()
assert panel.collapsed is True # done → collapsed, re-openable
@pytest.mark.asyncio
async def test_tool_name_persists_after_completion_update():
"""The agent only sends the name on the start event; the completion
update (title="") must not overwrite it back to a placeholder."""
transport = FakeTransport()
app = PawApp(transport)
async with app.run_test() as pilot:
await pilot.pause()
# start: real name; completion: no title, just status + output
await app._dispatch(
ToolCall("t1", "execute_shell_command", status="in_progress"),
)
await app._dispatch(
ToolCall("t1", "", status="completed", output="done"),
)
await pilot.pause()
panel = app.query(ToolPanel).first()
assert "execute_shell_command" in panel.title.plain
@pytest.mark.asyncio
async def test_finished_tool_header_is_informative():
"""A completed tool with a generic title still shows kind + params."""
transport = FakeTransport()
app = PawApp(transport)
async with app.run_test() as pilot:
await pilot.pause()
await app._dispatch(
ToolCall(
"t1",
"tool",
kind="execute",
status="completed",
params="command: ls -la /tmp\nshell: zsh",
output="x",
),
)
await pilot.pause()
panel = app.query(ToolPanel).first()
title = panel.title.plain
assert "execute" in title # falls back to kind, not bare "tool"
assert "ls -la /tmp" in title # primary param surfaced
assert "completed" not in title # redundant status word dropped
@pytest.mark.asyncio
async def test_toggle_hides_finished_tools_only():
transport = FakeTransport()
app = PawApp(transport)
async with app.run_test() as pilot:
await pilot.pause()
await app._dispatch(
ToolCall("done", "read_file", kind="read", status="completed"),
)
await app._dispatch(
ToolCall("live", "grep", kind="search", status="in_progress"),
)
await pilot.pause()
done = app._tools["done"]
live = app._tools["live"]
assert done.has_class("hidden")
assert live.has_class("hidden")
await pilot.press("ctrl+i")
assert not done.has_class("hidden")
assert not live.has_class("hidden")
await pilot.press("ctrl+t")
assert done.has_class("hidden") # finished → hidden
assert not live.has_class("hidden") # running → still visible
await pilot.press("ctrl+t")
assert not done.has_class("hidden") # toggled back for inspection
async def _overflow_transcript(app, pilot, count: int = 15) -> None:
"""Mount enough messages that the transcript actually scrolls."""
for i in range(count):
await app._dispatch(UserTurn(f"replayed message {i}"))
await pilot.pause()
assert app._transcript().max_scroll_y > 0
@pytest.mark.asyncio
async def test_transcript_follows_new_content_at_bottom():
"""While the user sits at the end, streaming content keeps the
transcript pinned to the bottom (the anchor follows growth)."""
transport = FakeTransport()
app = PawApp(transport)
async with app.run_test() as pilot:
await pilot.pause()
await _overflow_transcript(app, pilot)
t = app._transcript()
assert t.is_vertical_scroll_end
await app._dispatch(TextDelta("streamed reply text\n" * 4))
await pilot.pause()
assert t.is_vertical_scroll_end
@pytest.mark.asyncio
async def test_user_scroll_up_holds_position_while_streaming():
"""Scrolling up releases the anchor: new content must not move the
viewport until the user returns to the bottom."""
transport = FakeTransport()
app = PawApp(transport)
async with app.run_test() as pilot:
await pilot.pause()
await _overflow_transcript(app, pilot)
t = app._transcript()
# Wheel up (the mouse-wheel handler's exact code path).
t._scroll_up_for_pointer(animate=False)
await pilot.pause()
held = t.scroll_y
assert not t.is_vertical_scroll_end
# A reply streams and new widgets mount — the viewport stays put.
await app._dispatch(TextDelta("more streamed text\n" * 5))
await app._dispatch(UserTurn("another replayed message"))
await pilot.pause()
assert t.scroll_y == held
assert not t.is_vertical_scroll_end
# Wheeling back down to the very end re-engages following.
for _ in range(80):
t._scroll_down_for_pointer(animate=False)
await pilot.pause()
assert t.is_vertical_scroll_end
await app._dispatch(UserTurn("yet another message"))
await pilot.pause()
assert t.is_vertical_scroll_end
@pytest.mark.asyncio
async def test_scroll_down_at_bottom_keeps_following():
"""A clamped downward scroll at the end (down-arrow / page-down with the
transcript focused) must not silently stop the auto-follow: Textual
releases the anchor on the action but never re-arms it because scroll_y
doesn't change."""
transport = FakeTransport()
app = PawApp(transport)
async with app.run_test() as pilot:
await pilot.pause()
await _overflow_transcript(app, pilot)
t = app._transcript()
assert t.is_vertical_scroll_end
t.scroll_down(animate=False) # the down-arrow binding's code path
await pilot.pause()
await app._dispatch(UserTurn("a fresh message"))
await pilot.pause()
assert t.is_vertical_scroll_end
@pytest.mark.asyncio
async def test_submit_jumps_back_to_transcript_end():
"""Sending a message is an explicit user action: the transcript jumps
to the end and resumes following, even if they had scrolled up."""
transport = FakeTransport()
app = PawApp(transport)
async with app.run_test() as pilot:
await pilot.pause()
await _overflow_transcript(app, pilot)
t = app._transcript()
t._scroll_up_for_pointer(animate=False)
await pilot.pause()
assert not t.is_vertical_scroll_end
app.query_one("#prompt").value = "hi"
await pilot.press("enter")
for _ in range(10):
await pilot.pause()
if not app._busy:
break
assert t.is_vertical_scroll_end
@pytest.mark.asyncio
async def test_streaming_thought_follows_in_inspection_mode():
"""An expanded thought growing during inspection keeps the transcript
at the end — growth without a new mount is followed too."""
transport = FakeTransport()
app = PawApp(transport)
async with app.run_test() as pilot:
await pilot.pause()
await pilot.press("ctrl+i")
for i in range(40):
await app._dispatch(ThoughtDelta(f"thinking step {i}\n"))
t = app._transcript()
for _ in range(5):
await pilot.pause()
if t.is_vertical_scroll_end:
break
assert t.max_scroll_y > 0
assert t.is_vertical_scroll_end
@pytest.mark.asyncio
async def test_inspection_mode_expands_finished_tool_panels():
"""Ctrl+I opens every panel so params + output are readable without a
click per tool; toggling back restores the collapsed summaries."""
transport = FakeTransport()
app = PawApp(transport)
async with app.run_test() as pilot:
await pilot.pause()
await app._dispatch(
ToolCall(
"t1",
"read_file",
kind="read",
status="completed",
params="path: README.md",
output="# QwenPaw",
),
)
await pilot.pause()
panel = app.query(ToolPanel).first()
assert panel.collapsed is True # friendly default
await pilot.press("ctrl+i")
assert panel.collapsed is False
assert not panel.has_class("hidden")
await pilot.press("ctrl+i")
assert panel.collapsed is True
@pytest.mark.asyncio
async def test_tool_finishing_in_inspection_mode_stays_expanded():
"""In inspection mode a completing tool must not auto-collapse — the
whole point of the mode is to watch params and output land."""
transport = FakeTransport()
app = PawApp(transport)
async with app.run_test() as pilot:
await pilot.pause()
await pilot.press("ctrl+i")
await app._dispatch(
ToolCall(
"t1",
"grep",
kind="search",
status="in_progress",
params="pattern: TODO",
),
)
await app._dispatch(
ToolCall("t1", "", status="completed", output="3 hits"),
)
await pilot.pause()
panel = app.query(ToolPanel).first()
assert panel.collapsed is False
assert not panel.has_class("hidden")
@pytest.mark.asyncio
async def test_tool_panel_body_shows_labelled_full_params_and_output():
"""The body labels its input/output sections and no longer truncates at
600 chars; content scrolls inside the panel instead."""
from textual.containers import VerticalScroll
transport = FakeTransport()
app = PawApp(transport)
async with app.run_test() as pilot:
await pilot.pause()
long_cmd = "echo " + "x" * 900 # beyond the old 600-char cap
await app._dispatch(
ToolCall(
"t1",
"execute_shell_command",
kind="execute",
status="completed",
params=f"command: {long_cmd}",
output="done",
),
)
await pilot.pause()
panel = app.query(ToolPanel).first()
body = panel._render_body().plain
assert body.startswith("input\n")
assert long_cmd in body
assert "output\ndone" in body
# The body sits in a height-capped scroll container.
assert panel.query(VerticalScroll)
@pytest.mark.asyncio
async def test_tool_panel_clips_huge_sections():
"""A megabyte-scale payload is capped with an explicit truncation note
so it cannot stall the renderer."""
transport = FakeTransport()
app = PawApp(transport)
async with app.run_test() as pilot:
await pilot.pause()
await app._dispatch(
ToolCall(
"t1",
"take_screenshot",
status="completed",
output="y" * 50_000,
),
)
await pilot.pause()
panel = app.query(ToolPanel).first()
body = panel._render_body().plain
assert "… (+40,000 more chars)" in body
assert len(body) < 11_000
@pytest.mark.asyncio
async def test_thinking_collapsed_by_default():
transport = FakeTransport()
app = PawApp(transport)
async with app.run_test() as pilot:
await pilot.pause()
await app._dispatch(ThoughtDelta("pondering the question"))
await pilot.pause()
thought = app.query(ThoughtMessage).first()
assert thought.collapsed is True
assert thought.has_class("hidden")
activity = app.query(ActivityLine).first()
assert "thinking" in activity.content.plain
assert "ctrl+i to inspect" in activity.content.plain
@pytest.mark.asyncio
async def test_new_thoughts_expand_when_inspection_mode_is_active():
transport = FakeTransport()
app = PawApp(transport)
async with app.run_test() as pilot:
await pilot.pause()
await pilot.press("ctrl+i")
await app._dispatch(ThoughtDelta("show the details"))
await pilot.pause()
thought = app.query(ThoughtMessage).first()
assert thought.collapsed is False
assert not thought.has_class("hidden")
assert app.query(ActivityLine).first().has_class("hidden")
@pytest.mark.asyncio
async def test_friendly_mode_collapses_tool_chain_to_one_activity_line():
transport = FakeTransport()
app = PawApp(transport)
async with app.run_test() as pilot:
await pilot.pause()
await app._dispatch(ThoughtDelta("planning"))
await app._dispatch(
ToolCall(
"t1",
"read_file",
kind="read",
status="completed",
params="path: README.md",
output="...",
),
)
await app._dispatch(
ToolCall(
"t2",
"execute_shell_command",
kind="execute",
status="in_progress",
params="command: pytest -q",
),
)
await pilot.pause()
activities = list(app.query(ActivityLine))
assert len(activities) == 1
assert "execute_shell_command" in activities[0].content.plain
assert "pytest -q" in activities[0].content.plain
assert "ctrl+i to inspect" in activities[0].content.plain
assert all(panel.has_class("hidden") for panel in app.query(ToolPanel))
assert app.query(ThoughtMessage).first().has_class("hidden")
await pilot.press("ctrl+i")
assert activities[0].has_class("hidden")
assert all(
not panel.has_class("hidden") for panel in app.query(ToolPanel)
)
assert not app.query(ThoughtMessage).first().has_class("hidden")
@pytest.mark.asyncio
async def test_activity_line_stops_thinking_when_answer_streams():
transport = FakeTransport()
app = PawApp(transport)
async with app.run_test() as pilot:
await pilot.pause()
await app._dispatch(ThoughtDelta("pondering"))
await pilot.pause()
activity = app.query(ActivityLine).first()
assert "thinking" in activity.content.plain
# Once the visible answer begins, the activity row must stop animating
# "thinking" — the thought chain is complete.
await app._dispatch(TextDelta("Here is the answer."))
await pilot.pause()
assert "thinking" not in activity.content.plain
assert "thought complete" in activity.content.plain
assert "ctrl+i to inspect" in activity.content.plain
# The reference is dropped so resumed reasoning starts a fresh line.
assert app._activity is None
@pytest.mark.asyncio
async def test_permission_overlay_resolves_with_keyboard_selection():
transport = FakeTransport()
app = PawApp(transport)
async with app.run_test() as pilot:
await pilot.pause()
prompt = app.query_one("#prompt")
prompt.value = "do permission thing"
await pilot.press("enter")
# The overlay should appear above the input with the tool parameters.
overlay = app.query_one(PermissionOverlay)
for _ in range(10):
await pilot.pause()
if overlay.display:
break
assert overlay.display
assert app.focused is overlay
option_text = "\n".join(
getattr(overlay.get_option_at_index(index).prompt, "plain", "")
for index in range(len(overlay.options))
)
assert "Action: execute" in option_text
assert "Review target" in option_text
assert "Command: rm -rf /tmp/nope" in option_text
assert "Choose a session-scoped action" in option_text
assert "kind:" not in option_text
assert "parameters" not in option_text
handled_by_app: list[str] = []
async def _record_app_level_permission_key(key: str) -> None:
handled_by_app.append(key)
app._handle_permission_key = _record_app_level_permission_key
# Down selects Deny, then Enter resolves the highlighted option.
await pilot.press("down")
await pilot.pause()
assert overlay.selected == "deny"
await pilot.press("enter")
for _ in range(10):
await pilot.pause()
if transport.resolved:
break
assert transport.resolved == [("r1", "deny")]
assert handled_by_app == []
@pytest.mark.asyncio
async def test_permission_overlay_resolves_after_prompt_loses_focus():
transport = FakeTransport()
app = PawApp(transport)
async with app.run_test() as pilot:
await pilot.pause()
prompt = app.query_one("#prompt")
prompt.value = "do permission thing"
await pilot.press("enter")
overlay = app.query_one(PermissionOverlay)
for _ in range(10):
await pilot.pause()
if overlay.display:
break
assert overlay.display
overlay.cursor_down()
assert overlay.selected == "deny"
app.set_focus(None)
assert app.focused is None
await pilot.press("enter")
for _ in range(10):
await pilot.pause()
if transport.resolved:
break
assert transport.resolved == [("r1", "deny")]
@pytest.mark.asyncio
async def test_permission_overlay_expires_with_message():
transport = FakeTransport()
app = PawApp(transport)
async with app.run_test() as pilot:
await pilot.pause()
await app._dispatch(
PermissionRequest(
request_id="r-expired",
title="dangerous_tool",
options=[
PermissionOption("allow", "Allow", "allow_once"),
PermissionOption("deny", "Deny", "reject_once"),
],
),
)
await pilot.pause()
overlay = app.query_one(PermissionOverlay)
assert overlay.display
await app._dispatch(
PermissionExpired(
request_id="r-expired",
message=(
"Approval request timed out. The tool call was blocked; "
"start a new request to try again."
),
),
)
await pilot.pause()
assert not overlay.display
infos = [i.content.plain for i in app.query(InfoMessage)]
assert any("Approval request timed out" in i for i in infos)
@pytest.mark.asyncio
async def test_permission_overlay_formats_approval_targets():
transport = FakeTransport()
app = PawApp(transport)
async with app.run_test() as pilot:
await pilot.pause()
await app._dispatch(
PermissionRequest(
request_id="r-targets",
title="execute_shell_command requires approval (MEDIUM)",
tool_kind="execute",
params=(
"command: git status\n"
"approve_exact_target: git status\n"
"approve_pattern_target: git *"
),
options=[
PermissionOption(
"allow_once",
"Allow Exact This Session",
"allow_once",
),
PermissionOption(
"allow_always",
"Allow Pattern This Session",
"allow_always",
),
PermissionOption("deny", "Deny", "reject_once"),
],
),
)
await pilot.pause()
overlay = app.query_one(PermissionOverlay)
option_text = "\n".join(
getattr(overlay.get_option_at_index(index).prompt, "plain", "")
for index in range(len(overlay.options))
)
assert "Command: git status" in option_text
assert "Exact Target: git status" in option_text
assert "Pattern Target: git *" in option_text
assert "approve_exact_target" not in option_text
assert "approve_pattern_target" not in option_text
@pytest.mark.asyncio
async def test_permission_overlay_shows_expiry_countdown():
transport = FakeTransport()
app = PawApp(transport)
async with app.run_test() as pilot:
await pilot.pause()
await app._dispatch(
PermissionRequest(
request_id="r-countdown",
title="dangerous_tool",
expires_at=time.time() + 65,
options=[
PermissionOption("allow", "Allow", "allow_once"),
PermissionOption("deny", "Deny", "reject_once"),
],
),
)
await pilot.pause()
overlay = app.query_one(PermissionOverlay)
title = overlay.get_option_at_index(0).prompt
assert "Approval required" in title.plain
assert "expires in" in title.plain
assert "dangerous_tool" in title.plain
@pytest.mark.asyncio
async def test_slash_command_suggestions():
transport = FakeTransport()
app = PawApp(transport)
async with app.run_test() as pilot:
await pilot.pause()
await app._dispatch(
AvailableCommands(
commands=[
SlashCommand("model", "switch model"),
SlashCommand("agent", "change agent"),
SlashCommand("clear", "clear session"),
],
),
)
menu = app.query_one(CommandMenu)
prompt = app.query_one("#prompt")
# Plain text → no dropdown.
prompt.value = "hi"
await pilot.pause()
assert not menu.display
# "/" opens the dropdown with local commands plus agent commands.
prompt.value = "/"
await pilot.pause()
assert menu.display
assert menu.option_count >= 3
command_names = [command.name for command in app._suggester._commands]
assert {"model", "agent", "clear", "theme"}.issubset(command_names)
# Inline ghost completion offers the top match.
assert await app._suggester.get_suggestion("/mod") == "/model"
# Typing narrows the list.
prompt.value = "/a"
await pilot.pause()
assert menu.display
assert menu.selected == "agent"
# Tab accepts the highlighted command (note trailing space) and the
# menu steps aside instead of submitting.
await pilot.press("tab")
assert prompt.value == "/agent "
assert not menu.display
assert transport.sent == []
@pytest.mark.asyncio
async def test_slash_command_suggests_theme_arguments():
transport = FakeTransport()
app = PawApp(transport)
async with app.run_test() as pilot:
await pilot.pause()
menu = app.query_one(CommandMenu)
prompt = app.query_one("#prompt")
prompt.value = "/theme c"
await pilot.pause()
assert menu.display
assert menu.selected == "theme cyberpunk"
await pilot.press("tab")
assert prompt.value == "/theme cyberpunk "
assert transport.sent == []
@pytest.mark.asyncio
async def test_welcome_message_mounts_with_qwenpaw_greeting():
transport = FakeTransport()
app = PawApp(transport)
async with app.run_test() as pilot:
await pilot.pause()
welcome = app.query(WelcomeMessage).first()
plain = welcome.content.plain
assert "" in plain
assert "" not in plain
assert plain.count("") > 150
assert "QwenPaw 9.8.7" not in plain
assert "works for you" not in plain
assert "/theme" not in plain
assert len(plain.splitlines()) >= 6
status = app.query_one(StatusBar).summary
assert "QwenPaw 9.8.7" in status
# The TUI version is no longer shown — only QwenPaw's.
assert "TUI" not in status
@pytest.mark.asyncio
async def test_welcome_message_stays_top_aligned_on_launch():
"""The welcome logo should not be bottom-anchored on first render."""
transport = FakeTransport()
app = PawApp(transport)
async with app.run_test(size=(80, 12)) as pilot:
await pilot.pause()
transcript = app._transcript()
assert transcript.max_scroll_y > 0
assert transcript.scroll_y == 0
assert not transcript.is_anchored
@pytest.mark.asyncio
async def test_welcome_logo_animates_then_restores_on_live_terminal(
monkeypatch,
):
# ``run_test`` is headless, which skips the hop; pretend it's a live
# terminal so ``on_mount`` starts the animation, then drive the frame
# loop deterministically (each ``_tick`` advances a fixed step, so we
# don't have to wait wall-clock seconds).
monkeypatch.setattr(
PawApp,
"is_headless",
property(lambda self: False),
)
transport = FakeTransport()
app = PawApp(transport)
async with app.run_test() as pilot:
await pilot.pause()
welcome = app.query(WelcomeMessage).first()
assert welcome._animating is True
assert welcome._anim_timer is not None
for _ in range(500):
if not welcome._animating:
break
welcome._tick()
assert welcome._animating is False
assert welcome._anim_timer is None
# The settled logo is the same as the static one.
plain = welcome.content.plain
assert plain.count("") == welcome._render_body().plain.count("")
@pytest.mark.asyncio
async def test_status_stays_starting_until_backend_connects():
transport = SlowStartTransport()
app = PawApp(transport)
async with app.run_test() as pilot:
await pilot.pause()
assert "starting" in app.query_one(StatusBar).summary
assert "ready" not in app.query_one(StatusBar).summary
transport.release.set()
for _ in range(10):
await pilot.pause()
if "ready" in app.query_one(StatusBar).summary:
break
assert "ready" in app.query_one(StatusBar).summary
@pytest.mark.asyncio
async def test_status_stays_warming_until_backend_warmup_finishes():
transport = WarmingTransport()
app = PawApp(transport)
async with app.run_test() as pilot:
await pilot.pause()
summary = app.query_one(StatusBar).summary
assert "warming" in summary
assert "ready" not in summary
await app._dispatch(BackendWarmed())
summary = app.query_one(StatusBar).summary
assert "ready" in summary
assert "warming" not in summary
@pytest.mark.asyncio
async def test_first_turn_shows_warming_until_backend_updates():
transport = WarmingQuietTransport()
app = PawApp(transport)
async with app.run_test() as pilot:
await pilot.pause()
await app._submit("hello")
assert transport.sent == ["hello"]
summary = app.query_one(StatusBar).summary
assert "warming" in summary
assert "thinking" not in summary
await app._dispatch(TextDelta("hi"))
summary = app.query_one(StatusBar).summary
assert "thinking" in summary
@pytest.mark.asyncio
async def test_theme_command_opens_gallery_without_chat_turn():
transport = FakeTransport()
app = PawApp(transport)
async with app.run_test() as pilot:
await pilot.pause()
prompt = app.query_one("#prompt")
prompt.value = "/theme gallery"
await pilot.press("enter")
await pilot.pause()
assert isinstance(app.screen, ThemePicker)
assert transport.sent == []
app.screen.dismiss(None)
@pytest.mark.asyncio
async def test_help_command_shows_current_slash_command_usage():
transport = FakeTransport()
app = PawApp(transport)
async with app.run_test() as pilot:
await pilot.pause()
app.query_one("#prompt").value = "/help "
await pilot.press("enter")
await pilot.pause()
help_text = "\n".join(i.content.plain for i in app.query(InfoMessage))
assert "/resume <id-prefix>" in help_text
assert "/theme <theme-id|prompt>" in help_text
assert "/inspect" in help_text
assert "/model <provider>:<model>" in help_text
assert "/clear" in help_text
assert "/compact" in help_text
assert "/skills" in help_text
assert transport.sent == []
@pytest.mark.asyncio
async def test_resume_with_no_sessions_shows_info():
transport = FakeTransport() # no past sessions
app = PawApp(transport)
async with app.run_test() as pilot:
await pilot.pause()
app.query_one("#prompt").value = "/resume "
await pilot.press("enter")
await pilot.pause()
assert not isinstance(app.screen, SessionPicker)
infos = [i.content.plain for i in app.query(InfoMessage)]
assert any("No previous sessions yet" in text for text in infos)
assert transport.sent == [] # never forwarded to the agent
@pytest.mark.asyncio
async def test_resume_opens_picker_and_replays_selected_session():
transport = FakeTransport()
transport.sessions = [
SessionSummary(
session_id="old-1",
title="Earlier chat about Rust",
updated_at="2026-01-01T00:00:00+00:00",
),
]
app = PawApp(transport)
async with app.run_test() as pilot:
await pilot.pause()
app.query_one("#prompt").value = "/resume list"
await pilot.press("enter")
for _ in range(10):
await pilot.pause()
if isinstance(app.screen, SessionPicker):
break
assert isinstance(app.screen, SessionPicker)
# Pick the only session.
app.screen.dismiss("old-1")
# ``FakeTransport.load_session`` sets ``transport.loaded`` BEFORE
# pushing replay events, so polling that flag races the consume
# worker on event loops with different scheduling
# (ProactorEventLoop on Windows vs. SelectorEventLoop elsewhere).
# Poll the final observable state instead: the last replayed
# user turn has been mounted as a UserMessage.
for _ in range(20):
await pilot.pause()
rendered = " ".join(
u.content.plain for u in app.query(UserMessage)
)
if "Thanks!" in rendered:
break
assert transport.loaded == ["old-1"]
# Give the UI time to render all replayed events.
for _ in range(20):
await pilot.pause()
if len(app.query(UserMessage)) >= 2:
break
# The resumed transcript renders below the welcome banner.
assert list(app.query(WelcomeMessage))
user_msgs = [u.content.plain for u in app.query(UserMessage)]
assert "How do I write a loop in Rust?" in " ".join(user_msgs)
assert "Thanks!" in " ".join(user_msgs)
assistant = app.query(AssistantMessage).first()
assert "Use a `for` loop." in assistant.text
assert transport.sent == []
assert "old-1" in app.query_one(StatusBar).summary
@pytest.mark.asyncio
async def test_session_picker_arrow_keys_move_selection():
transport = FakeTransport()
transport.sessions = [
SessionSummary(session_id="s1", title="First chat"),
SessionSummary(session_id="s2", title="Second chat"),
SessionSummary(session_id="s3", title="Third chat"),
]
app = PawApp(transport)
async with app.run_test() as pilot:
await pilot.pause()
app.query_one("#prompt").value = "/resume list"
await pilot.press("enter")
for _ in range(10):
await pilot.pause()
if isinstance(app.screen, SessionPicker):
break
assert isinstance(app.screen, SessionPicker)
# Search input has focus, yet ↓/↑ move the list selection.
assert app.screen.query_one("#session-list", ListView).index == 0
await pilot.press("down")
await pilot.pause()
assert app.screen.query_one("#session-list", ListView).index == 1
await pilot.press("down")
await pilot.pause()
assert app.screen.query_one("#session-list", ListView).index == 2
await pilot.press("up")
await pilot.pause()
assert app.screen.query_one("#session-list", ListView).index == 1
# Enter resumes whatever is highlighted (the 2nd session).
await pilot.press("enter")
for _ in range(10):
await pilot.pause()
if transport.loaded:
break
assert transport.loaded == ["s2"]
@pytest.mark.asyncio
async def test_theme_picker_arrow_keys_move_selection():
transport = FakeTransport()
app = PawApp(transport)
async with app.run_test() as pilot:
await pilot.pause()
app.query_one("#prompt").value = "/theme gallery"
await pilot.press("enter")
for _ in range(10):
await pilot.pause()
if isinstance(app.screen, ThemePicker):
break
assert isinstance(app.screen, ThemePicker)
assert app.screen.query_one("#theme-list", ListView).index == 0
await pilot.press("down")
await pilot.pause()
assert app.screen.query_one("#theme-list", ListView).index == 1
await pilot.press("up")
await pilot.pause()
assert app.screen.query_one("#theme-list", ListView).index == 0
app.screen.dismiss(None)
@pytest.mark.asyncio
async def test_resume_autosuggest_lists_recent_ten():
transport = FakeTransport()
# 12 sessions; only the most recent 10 should become suggestions.
transport.sessions = [
SessionSummary(
session_id=f"{i:02d}cdef00" + "0" * 24,
title=f"Chat {i}",
)
for i in range(12)
]
app = PawApp(transport)
async with app.run_test() as pilot:
for _ in range(10):
await pilot.pause()
if app._session_commands:
break
names = [c.name for c in app._menu._commands]
resume_entries = [n for n in names if n.startswith("resume ")]
assert len(resume_entries) == 10 # capped at the 10 most recent
# Short id + the session title as description, like /theme <id>.
assert "resume 00cdef00" in names
cmd = next(
c for c in app._menu._commands if c.name == "resume 00cdef00"
)
assert cmd.description == "Chat 0"
@pytest.mark.asyncio
async def test_resume_with_id_arg_resumes_directly_without_picker():
transport = FakeTransport()
full_id = "00cdef00" + "0" * 24
transport.sessions = [SessionSummary(session_id=full_id, title="Chat 0")]
app = PawApp(transport)
async with app.run_test() as pilot:
await pilot.pause()
# Trailing space closes the suggest menu so Enter submits the line.
app.query_one("#prompt").value = "/resume 00cdef00 "
await pilot.press("enter")
for _ in range(10):
await pilot.pause()
if transport.loaded:
break
# Resolved the short id to the full session and resumed it directly.
assert transport.loaded == [full_id]
assert not isinstance(app.screen, SessionPicker)
assert transport.sent == []
@pytest.mark.asyncio
async def test_resume_flag_shows_welcome_and_replays_at_start():
transport = FakeTransport(resume_session_id="old-1")
app = PawApp(transport, resume_session_id="old-1")
async with app.run_test() as pilot:
# Wait until the final replayed user turn is rendered. Polling
# ``transport.loaded`` races the consume worker on event loops
# with different scheduling (see twin test above).
for _ in range(20):
await pilot.pause()
rendered = " ".join(
u.content.plain for u in app.query(UserMessage)
)
if "Thanks!" in rendered:
break
# The welcome banner remains above the resumed transcript.
assert list(app.query(WelcomeMessage))
assert transport.loaded == ["old-1"]
user_msgs = " ".join(u.content.plain for u in app.query(UserMessage))
assert "How do I write a loop in Rust?" in user_msgs
assert "Thanks!" in user_msgs
assert "old-1" in app.query_one(StatusBar).summary
@pytest.mark.asyncio
async def test_named_theme_command_applies_gallery_theme(
tmp_path,
monkeypatch,
):
monkeypatch.setenv("PAW_STATE_DIR", str(tmp_path / "state"))
transport = FakeTransport()
app = PawApp(transport)
async with app.run_test() as pilot:
await pilot.pause()
prompt = app.query_one("#prompt")
prompt.value = "/theme cyberpunk"
await pilot.press("enter")
await pilot.pause()
saved = (tmp_path / "state" / "theme.json").read_text(encoding="utf-8")
assert "cyberpunk tiger-paw alley" in saved
assert transport.sent == []
@pytest.mark.asyncio
async def test_terminal_title_uses_session_then_title():
transport = FakeTransport()
app = PawApp(transport)
titles: list[str] = []
async with app.run_test() as pilot:
await pilot.pause()
# Capture what we write to the terminal.
app._set_terminal_title = lambda t: titles.append(t) # type: ignore
app._on_connected(Connected(session_id="abcd1234ef", agent="a"))
assert titles[-1] == "QwenPaw abcd1234"
await app._dispatch(SessionTitle("Fix the parser"))
await pilot.pause()
assert titles[-1] == "QwenPaw Fix the parser"
@pytest.mark.asyncio
async def test_single_agent_label_per_turn_above_thinking():
transport = FakeTransport()
app = PawApp(transport)
async with app.run_test() as pilot:
await pilot.pause()
# thinking → tool → answer: exactly one "qwenpaw" label, and it sits
# above the first activity (the thinking lane).
await app._dispatch(ThoughtDelta("hmm"))
await app._dispatch(
ToolCall("t1", "grep", kind="search", status="in_progress"),
)
await app._dispatch(TextDelta("Done."))
await pilot.pause()
labels = list(app.query(AgentLabel))
assert len(labels) == 1
assert labels[0].content.plain == "qwenpaw"
transcript = app.query_one("#transcript")
types = [
type(w).__name__
for w in transcript.children
if isinstance(
w,
(
AgentLabel,
ActivityLine,
ThoughtMessage,
ToolPanel,
AssistantMessage,
),
)
]
assert types[0] == "AgentLabel"
assert types[1] == "ActivityLine"
@pytest.mark.asyncio
async def test_text_after_tool_mounts_below_it():
transport = FakeTransport()
app = PawApp(transport)
async with app.run_test() as pilot:
await pilot.pause()
# text → tool → more text: the transcript should read top-down in
# that order (the post-tool text must not fold into the first bubble).
await app._dispatch(TextDelta("Let me check."))
await app._dispatch(
ToolCall("t1", "grep", kind="search", status="in_progress"),
)
await app._dispatch(TextDelta("Found it."))
await pilot.pause()
transcript = app.query_one("#transcript")
kinds = [
type(w).__name__
for w in transcript.children
if isinstance(w, (AssistantMessage, ToolPanel))
]
assert kinds == ["AssistantMessage", "ToolPanel", "AssistantMessage"]
bubbles = list(app.query(AssistantMessage))
assert bubbles[0].text == "Let me check."
assert bubbles[1].text == "Found it."
@pytest.mark.asyncio
async def test_thinking_finalizes_to_thought_for():
transport = FakeTransport()
app = PawApp(transport)
async with app.run_test() as pilot:
await pilot.pause()
await app._dispatch(ThoughtDelta("pondering..."))
await pilot.pause()
thought = app.query(ThoughtMessage).first()
assert "thinking" in str(thought.title)
assert not thought._finished
# The visible answer beginning finalizes the thinking lane.
await app._dispatch(TextDelta("Here is the answer."))
await pilot.pause()
assert thought._finished
assert "thought for" in str(thought.title)
@pytest.mark.asyncio
async def test_live_token_estimate_then_exact():
transport = FakeTransport()
app = PawApp(transport)
async with app.run_test() as pilot:
await pilot.pause()
bar = app.query_one("StatusBar")
# Stream 40 chars of assistant text → ~10 token estimate, marked "~".
await app._dispatch(TextDelta("x" * 40))
await pilot.pause()
assert "↓~10" in bar.summary
# Exact usage for the call replaces the estimate (no tilde).
await app._dispatch(
TokenUsage(input_tokens=1200, output_tokens=7, model="m"),
)
await pilot.pause()
assert "↓7" in bar.summary
assert "↓~" not in bar.summary
assert "↑1.2k" in bar.summary
assert app._tok_out == 7 and app._stream_chars == 0
@pytest.mark.asyncio
async def test_interrupt_action():
transport = FakeTransport()
app = PawApp(transport)
async with app.run_test() as pilot:
await pilot.pause()
prompt = app.query_one("#prompt")
prompt.value = (
"permission please" # leaves it busy awaiting permission
)
await pilot.press("enter")
await pilot.pause()
app.query_one(PermissionOverlay).clear_request()
app._busy = True
await app.action_interrupt()
assert transport.interrupted
# The status bar reflects the request immediately.
assert "interrupting" in app.query_one("StatusBar").summary
@pytest.mark.asyncio
async def test_escape_keypress_interrupts_when_busy():
transport = FakeTransport()
app = PawApp(transport)
async with app.run_test() as pilot:
await pilot.pause()
app._busy = True
app.query_one("#prompt").focus()
await pilot.pause()
await pilot.press("escape")
await pilot.pause()
assert transport.interrupted
@pytest.mark.asyncio
async def test_escape_clears_input_when_idle():
transport = FakeTransport()
app = PawApp(transport)
async with app.run_test() as pilot:
await pilot.pause()
prompt = app.query_one("#prompt")
prompt.value = "half-typed message"
prompt.focus()
await pilot.pause()
await pilot.press("escape")
await pilot.pause()
assert prompt.value == ""
assert not transport.interrupted # idle esc never interrupts
@pytest.mark.asyncio
async def test_file_link_mounts_clickable_box_once():
transport = FakeTransport()
app = PawApp(transport)
async with app.run_test() as pilot:
await pilot.pause()
link = FileLink(uri="file:///tmp/report.pdf", name="report.pdf")
event = ToolCall(
"t1",
"send_file_to_user",
kind="other",
status="completed",
output="File sent successfully.",
links=(link,),
)
await app._dispatch(event)
await pilot.pause()
boxes = list(app.query(FileLinkBox))
assert len(boxes) == 1
assert boxes[0]._uri == "file:///tmp/report.pdf"
# A repeated update for the same tool call must not duplicate the link.
await app._dispatch(event)
await pilot.pause()
assert len(list(app.query(FileLinkBox))) == 1
@pytest.mark.asyncio
async def test_messages_queue_while_busy_and_drain_on_turn_end():
transport = FakeTransport()
app = PawApp(transport)
async with app.run_test() as pilot:
await pilot.pause()
# Pretend the agent is mid-turn.
app._busy = True
prompt = app.query_one("#prompt")
prompt.value = "first queued"
await pilot.press("enter")
prompt.value = "second queued"
await pilot.press("enter")
await pilot.pause()
# Both wait in the queue (dimmed), nothing sent yet.
assert [t for t, _ in app._queued] == ["first queued", "second queued"]
assert len(list(app.query(QueuedMessage))) == 2
assert transport.sent == []
# The turn ends → the first queued message is delivered.
await app._dispatch(TurnEnded())
for _ in range(10):
await pilot.pause()
if not app._busy:
break
# FakeTransport auto-ends each turn, so the second drains too.
for _ in range(10):
await pilot.pause()
if transport.sent == ["first queued", "second queued"]:
break
assert transport.sent == ["first queued", "second queued"]
assert app._queued == []
assert len(list(app.query(QueuedMessage))) == 0
@pytest.mark.asyncio
async def test_up_recalls_queued_message_to_edit():
transport = FakeTransport()
app = PawApp(transport)
async with app.run_test() as pilot:
await pilot.pause()
app._busy = True
prompt = app.query_one("#prompt")
prompt.value = "draft to fix"
await pilot.press("enter")
await pilot.pause()
assert len(list(app.query(QueuedMessage))) == 1
# ↑ pulls the last queued message back into the input for editing.
await pilot.press("up")
await pilot.pause()
assert prompt.value == "draft to fix"
assert app._queued == []
assert len(list(app.query(QueuedMessage))) == 0
assert transport.sent == []
@pytest.mark.asyncio
async def test_multiline_prompt_sends_full_text():
transport = FakeTransport()
app = PawApp(transport)
async with app.run_test() as pilot:
await pilot.pause()
prompt = app.query_one("#prompt")
prompt.value = "first line\nsecond line"
await pilot.press("enter")
for _ in range(10):
await pilot.pause()
if transport.sent:
break
assert transport.sent == ["first line\nsecond line"]
@pytest.mark.asyncio
async def test_model_command_is_forwarded_to_agent():
"""paw no longer manages models — /model is QwenPaw's, so forwarded."""
transport = QuietTransport()
app = PawApp(transport)
async with app.run_test() as pilot:
await pilot.pause()
app.query_one("#prompt").value = "/model dashscope:qwen-max"
await pilot.press("enter")
await pilot.pause()
assert transport.sent == ["/model dashscope:qwen-max"]
@pytest.mark.asyncio
async def test_long_paste_is_stored_as_file(tmp_path, monkeypatch):
monkeypatch.setenv("PAW_STATE_DIR", str(tmp_path / "state"))
transport = FakeTransport()
app = PawApp(transport)
async with app.run_test() as pilot:
await pilot.pause()
replacement = await app._handle_prompt_paste("x" * 2200)
assert replacement is not None
path = replacement.removeprefix("[pasted text: ").removesuffix("]")
assert "pasted-text" in path
assert open(path, encoding="utf-8").read() == "x" * 2200
@pytest.mark.asyncio
async def test_file_path_paste_is_copied_to_attachment(tmp_path, monkeypatch):
monkeypatch.setenv("PAW_STATE_DIR", str(tmp_path / "state"))
source = tmp_path / "note.txt"
source.write_text("hello", encoding="utf-8")
transport = FakeTransport()
app = PawApp(transport)
async with app.run_test() as pilot:
await pilot.pause()
replacement = await app._handle_prompt_paste(str(source))
assert replacement is not None
path = replacement.removeprefix("[attached file: ").removesuffix("]")
assert path.endswith(".txt")
assert open(path, encoding="utf-8").read() == "hello"
@pytest.mark.asyncio
async def test_embedded_escaped_file_path_paste_is_copied(
tmp_path,
monkeypatch,
):
import sys
monkeypatch.setenv("PAW_STATE_DIR", str(tmp_path / "state"))
source = tmp_path / "Screenshot 2026-06-06 at 9.31.17 PM.png"
source.write_bytes(b"png")
# On Unix: escape spaces with backslash (shell convention).
# On Windows: quote the path (shell convention).
if sys.platform == "win32":
escaped = f'"{source}"'
else:
escaped = str(source).replace(" ", "\\ ")
transport = FakeTransport()
app = PawApp(transport)
async with app.run_test() as pilot:
await pilot.pause()
replacement = await app._handle_prompt_paste(
f"Can you describe this image? {escaped}",
)
assert replacement is not None
assert replacement.startswith("Can you describe this image? ")
assert str(source) not in replacement
assert escaped not in replacement
path = replacement.rsplit("[attached file: ", 1)[1].removesuffix("]")
assert path.endswith(".png")
assert open(path, "rb").read() == b"png"
@pytest.mark.asyncio
async def test_theme_prompt_persists(tmp_path, monkeypatch):
monkeypatch.setenv("PAW_STATE_DIR", str(tmp_path / "state"))
transport = FakeTransport()
app = PawApp(transport)
async with app.run_test() as pilot:
await pilot.pause()
app._apply_theme_prompt("rainbow workspace")
await pilot.pause()
assert "rainbow workspace" in (
tmp_path / "state" / "theme.json"
).read_text(encoding="utf-8")