1740 lines
59 KiB
Python
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")
|