feat(cli): free-text reject reason on HITL approval prompt (#3344)

Closes #2087

---

Adds a Claude-Code-style _reject with reason_ flow to the CLI's HITL
approval prompt. With the Reject option highlighted, pressing Tab swaps
the option for an inline single-line `Input`. Pressing Enter submits the
decision with the typed text attached as `RejectDecision.message`;
pressing Esc returns to the menu without deciding. Leaving the input
blank submits a plain reject.

The reason is surfaced in the chat transcript as a dim `Reason: …` line
attached to the rejected `ToolCallMessage`, so the user can see exactly
what they told the model. The reason round-trips through `MessageData`
so it survives message rehydration.

Existing `Reject (n)` muscle memory is preserved — `n`, `3`, and Esc
still produce an immediate plain reject. `Tab` is also intercepted while
the input is open so Ctrl+C / Esc cancel the input first instead of
submitting an empty reject.

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
Co-authored-by: Mason Daugherty <61371264+mdrxy@users.noreply.github.com>
Co-authored-by: Mason Daugherty <mason@langchain.dev>
Co-authored-by: Mason Daugherty <github@mdrxy.com>
This commit is contained in:
open-swe[bot]
2026-05-11 14:22:08 -07:00
committed by GitHub
parent b5c12280e4
commit dcc48f48d2
9 changed files with 660 additions and 20 deletions
+7
View File
@@ -224,6 +224,13 @@ ToolCallMessage.-ascii:hover {
margin-top: 0;
}
/* Free-text reject reason input (Tab on Reject) */
.approval-menu .approval-reason-input {
margin: 1 0 0 0;
border: solid $warning;
background: $surface;
}
/* Ask user widget */
.ask-user-menu {
height: auto;
+24 -6
View File
@@ -1228,17 +1228,35 @@ async def execute_task_textual(
)
elif decision_type == "reject":
decisions = [
RejectDecision(type="reject")
for _ in action_requests
]
reject_message = decision.get("message")
reject_message = (
reject_message
if isinstance(reject_message, str)
and reject_message.strip()
else None
)
reject_decision: RejectDecision = (
RejectDecision(
type="reject", message=reject_message
)
if reject_message
else RejectDecision(type="reject")
)
decisions = [reject_decision for _ in action_requests]
tool_msgs = list(
adapter._current_tool_messages.values()
)
for tool_msg in tool_msgs:
tool_msg.set_rejected()
tool_msg.set_rejected(reason=reject_message)
adapter._current_tool_messages.clear()
any_rejected = True
# Bare reject aborts the turn and shows the
# canned "Command rejected" banner so the user
# can redirect. When a reason is supplied, the
# reason itself serves as feedback for the
# agent: keep `any_rejected=False` so the
# stream resumes and the banner is suppressed.
if reject_message is None:
any_rejected = True
else:
logger.warning(
"Unexpected HITL decision type: %s",
+133 -12
View File
@@ -2,13 +2,14 @@
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any, ClassVar
from textual.binding import Binding, BindingType
from textual.containers import Container, Vertical, VerticalScroll
from textual.content import Content
from textual.message import Message
from textual.widgets import Static
from textual.widgets import Input, Static
if TYPE_CHECKING:
import asyncio
@@ -33,12 +34,16 @@ from deepagents_cli.unicode_security import (
)
from deepagents_cli.widgets.tool_renderers import get_renderer
logger = logging.getLogger(__name__)
# Max length for truncated shell command display
_SHELL_COMMAND_TRUNCATE_LENGTH: int = 120
# Max number of lines for truncated shell command display
_SHELL_COMMAND_TRUNCATE_LINES: int = 5
_WARNING_PREVIEW_LIMIT: int = 3
_WARNING_TEXT_TRUNCATE_LENGTH: int = 220
# Must match the "reject" entry in `_handle_selection`'s decision map.
_REJECT_OPTION_INDEX: int = 2
def _is_command_too_long(command: str) -> bool:
@@ -111,6 +116,7 @@ class ApprovalMenu(Container):
Binding("3", "select_reject", "Reject", show=False),
Binding("n", "select_reject", "Reject", show=False),
Binding("e", "toggle_expand", "Expand command", show=False),
Binding("tab", "reject_with_reason", "Reject with reason", show=False),
]
class Decided(Message):
@@ -167,6 +173,10 @@ class ApprovalMenu(Container):
self._command_widget: Static | None = None
self._has_expandable_command = self._check_expandable_command()
self._security_warnings = self._collect_security_warnings()
# Free-text reject mode state (Tab on Reject opens an inline Input).
self._reason_input: Input | None = None
self._reason_input_active = False
self._help_widget: Static | None = None
def set_future(self, future: asyncio.Future[dict[str, str]]) -> None:
"""Set the future to resolve when user decides."""
@@ -302,15 +312,44 @@ class ApprovalMenu(Container):
self._option_widgets.append(widget)
yield widget
# Help text at the very bottom
glyphs = get_glyphs()
help_text = (
f"{glyphs.arrow_up}/{glyphs.arrow_down} navigate {glyphs.bullet} "
f"Enter select {glyphs.bullet} y/a/n quick keys {glyphs.bullet} Esc reject"
# Free-text reject reason input (hidden until activated via Tab)
self._reason_input = Input(
placeholder="Reason (Enter to submit, Esc to cancel)",
classes="approval-reason-input",
id="approval-reason-input",
)
self._reason_input.display = False
yield self._reason_input
# Help text at the very bottom
self._help_widget = Static(self._compose_help_text(), classes="approval-help")
yield self._help_widget
def _compose_help_text(self) -> str:
"""Build the help-line content for the current mode.
Returns:
Help text for either the normal menu or the reject-reason input.
"""
glyphs = get_glyphs()
if self._reason_input_active:
return (
f"Enter submit {glyphs.bullet} Esc cancel {glyphs.bullet} "
"leave blank to reject without a reason"
)
help_parts = [
(
f"{glyphs.arrow_up}/{glyphs.arrow_down} navigate "
f"{glyphs.bullet} Enter select {glyphs.bullet} y/a/n quick keys"
),
]
if self._selected == _REJECT_OPTION_INDEX:
help_parts.append("Tab amend")
help_parts.append("Esc reject")
help_text = f" {glyphs.bullet} ".join(help_parts)
if self._has_expandable_command:
help_text += f" {glyphs.bullet} e expand"
yield Static(help_text, classes="approval-help")
return help_text
async def on_mount(self) -> None:
"""Focus self on mount and update tool info."""
@@ -388,14 +427,20 @@ class ApprovalMenu(Container):
widget.remove_class("approval-option-selected")
if i == self._selected:
widget.add_class("approval-option-selected")
if self._help_widget is not None:
self._help_widget.update(self._compose_help_text())
def action_move_up(self) -> None:
"""Move selection up."""
if self._reason_input_active:
return
self._selected = (self._selected - 1) % 3
self._update_options()
def action_move_down(self) -> None:
"""Move selection down."""
if self._reason_input_active:
return
self._selected = (self._selected + 1) % 3
self._update_options()
@@ -412,7 +457,15 @@ class ApprovalMenu(Container):
self._handle_selection(1)
def action_select_reject(self) -> None:
"""Submit reject option."""
"""Submit reject option.
When the free-text reject input is open, the first press cancels the
input instead of rejecting, so the user can back out without losing
their unsubmitted reason.
"""
if self._reason_input_active:
self._exit_reason_input_mode()
return
self._handle_selection(2)
def action_toggle_expand(self) -> None:
@@ -424,14 +477,24 @@ class ApprovalMenu(Container):
self._get_command_display(expanded=self._command_expanded)
)
def _handle_selection(self, option: int) -> None:
"""Handle the selected option."""
def _handle_selection(
self, option: int, *, reject_message: str | None = None
) -> None:
"""Handle the selected option.
Args:
option: Index of the chosen option (0 approve, 1 auto-approve, 2 reject).
reject_message: Optional free-text reason. Only attached when the
user rejects with a non-empty message via `action_reject_with_reason`.
"""
decision_map = {
0: "approve",
1: "auto_approve_all",
2: "reject",
}
decision = {"type": decision_map[option]}
decision: dict[str, str] = {"type": decision_map[option]}
if option == _REJECT_OPTION_INDEX and reject_message:
decision["message"] = reject_message
self.display = False
@@ -442,6 +505,58 @@ class ApprovalMenu(Container):
# Post message
self.post_message(self.Decided(decision))
def action_reject_with_reason(self) -> None:
"""Enter free-text reject mode if Reject is currently selected.
No-op unless the cursor is on the Reject option. Mounts an inline
`Input` whose value is sent as `RejectDecision.message` on submit.
"""
if self._reason_input_active:
return
if self._selected != _REJECT_OPTION_INDEX:
return
if self._reason_input is None:
# Lifecycle bug: Tab fired before `compose()` populated the Input ref.
# Logging makes the silent no-op debuggable instead of invisible.
logger.warning(
"action_reject_with_reason: _reason_input is None; menu may not "
"be mounted yet"
)
return
self._reason_input_active = True
self._reason_input.value = ""
self._reason_input.display = True
if self._help_widget is not None:
self._help_widget.update(self._compose_help_text())
self._reason_input.focus()
def _exit_reason_input_mode(self) -> None:
"""Close the reason input and return focus to the menu without deciding."""
if not self._reason_input_active or self._reason_input is None:
return
self._reason_input_active = False
self._reason_input.display = False
if self._help_widget is not None:
self._help_widget.update(self._compose_help_text())
self.focus()
def on_input_submitted(self, event: Input.Submitted) -> None:
"""Submit the reject decision with the typed reason (if any)."""
# Stop before the guard so a stray submit (e.g. queued after Esc closed
# the input) cannot bubble to a parent and be re-interpreted, and so a
# foreign Input's submission is never misrouted through this handler.
if event.input is not self._reason_input:
return
event.stop()
if not self._reason_input_active:
logger.debug(
"on_input_submitted fired with inactive reason input; dropping"
)
return
reason = event.value.strip()
self._reason_input_active = False
self._handle_selection(2, reject_message=reason or None)
def _collect_security_warnings(self) -> list[str]:
"""Collect warning strings for suspicious Unicode and URL values.
@@ -474,5 +589,11 @@ class ApprovalMenu(Container):
return warnings
def on_blur(self, event: events.Blur) -> None: # noqa: ARG002 # Textual event handler signature
"""Re-focus on blur to keep focus trapped until decision is made."""
"""Re-focus on blur to keep focus trapped until decision is made.
Skipped while the free-text reject input is active so the `Input`
widget can keep keyboard focus.
"""
if self._reason_input_active:
return
self.call_after_refresh(self.focus)
@@ -133,6 +133,9 @@ class MessageData:
tool_expanded: bool = False
"""Whether the tool output section is expanded in the UI."""
tool_reject_reason: str | None = None
"""User-supplied reason attached to a HITL reject decision (if any)."""
# ---
diff_file_path: str | None = None
@@ -233,6 +236,7 @@ class MessageData:
widget._deferred_status = self.tool_status
widget._deferred_output = self.tool_output
widget._deferred_expanded = self.tool_expanded
widget._deferred_reject_reason = self.tool_reject_reason
return widget
case MessageType.SKILL:
@@ -345,6 +349,7 @@ class MessageData:
tool_status=tool_status,
tool_output=widget._output,
tool_expanded=widget._expanded,
tool_reject_reason=widget._reject_reason,
)
if isinstance(widget, ErrorMessage):
+44 -2
View File
@@ -759,6 +759,13 @@ class ToolCallMessage(Vertical):
color: $warning;
}
ToolCallMessage .tool-reject-reason {
margin-left: 3;
margin-top: 0;
height: auto;
color: $text-muted;
}
ToolCallMessage .tool-output {
margin-left: 0;
margin-top: 0;
@@ -806,6 +813,8 @@ class ToolCallMessage(Vertical):
self._output: str = ""
self._expanded: bool = False
self._args_expanded: bool = False
# User-provided reason attached to a HITL reject decision (if any).
self._reject_reason: str | None = None
# Widget references (set in on_mount)
self._status_widget: Static | None = None
self._args_widget: Static | None = None
@@ -813,6 +822,7 @@ class ToolCallMessage(Vertical):
self._preview_widget: Static | None = None
self._hint_widget: Static | None = None
self._full_widget: Static | None = None
self._reject_reason_widget: Static | None = None
# Animation state
self._spinner_position = 0
self._start_time: float | None = None
@@ -821,6 +831,7 @@ class ToolCallMessage(Vertical):
self._deferred_status: str | None = None
self._deferred_output: str | None = None
self._deferred_expanded: bool = False
self._deferred_reject_reason: str | None = None
# Whether the widget is currently hidden because an approval prompt
# is rendering the same content (see `set_awaiting_approval`).
self._awaiting_approval: bool = False
@@ -863,6 +874,8 @@ class ToolCallMessage(Vertical):
yield Static("", classes="tool-output-hint", id="args-hint")
# Status - shows running animation while pending, then final status
yield Static("", classes="tool-status", id="status")
# Optional HITL reject reason (only shown when user rejected with a message)
yield Static("", classes="tool-reject-reason", id="reject-reason")
# Output area - hidden initially, shown when output is set
yield Static("", classes="tool-output-preview", id="output-preview")
yield Static("", classes="tool-output", id="output-full")
@@ -879,6 +892,7 @@ class ToolCallMessage(Vertical):
self._preview_widget = self.query_one("#output-preview", Static)
self._hint_widget = self.query_one("#output-hint", Static)
self._full_widget = self.query_one("#output-full", Static)
self._reject_reason_widget = self.query_one("#reject-reason", Static)
# Hide everything initially - status only shown when running or on error/reject
self._status_widget.display = False
self._args_widget.display = False
@@ -886,6 +900,7 @@ class ToolCallMessage(Vertical):
self._preview_widget.display = False
self._hint_widget.display = False
self._full_widget.display = False
self._reject_reason_widget.display = False
self._update_args_display()
# Restore deferred state if this widget was hydrated from data
@@ -899,11 +914,14 @@ class ToolCallMessage(Vertical):
status = self._deferred_status
output = self._deferred_output or ""
self._expanded = self._deferred_expanded
if self._deferred_reject_reason:
self._reject_reason = self._deferred_reject_reason
# Clear deferred values
self._deferred_status = None
self._deferred_output = None
self._deferred_expanded = False
self._deferred_reject_reason = None
# Restore based on status (don't restart animations for running tools)
colors = theme.get_theme_colors(self)
@@ -932,6 +950,7 @@ class ToolCallMessage(Vertical):
Content.styled(f"{error_icon} Rejected", colors.warning)
)
self._status_widget.display = True
self._update_reject_reason_display()
case "skipped":
self._status = "skipped"
if self._status_widget:
@@ -1037,10 +1056,17 @@ class ToolCallMessage(Vertical):
self._expanded = True
self._update_output_display()
def set_rejected(self) -> None:
"""Mark the tool call as rejected by user."""
def set_rejected(self, *, reason: str | None = None) -> None:
"""Mark the tool call as rejected by user.
Args:
reason: Optional free-text reason supplied via the HITL reject
widget; rendered as a dim line beneath the status.
"""
self._stop_animation()
self._status = "rejected"
if reason and reason.strip():
self._reject_reason = reason.strip()
if self._status_widget:
self._status_widget.remove_class("pending")
self._status_widget.add_class("rejected")
@@ -1049,6 +1075,22 @@ class ToolCallMessage(Vertical):
colors = theme.get_theme_colors(self)
self._status_widget.update(Content.styled(text, colors.warning))
self._status_widget.display = True
self._update_reject_reason_display()
def _update_reject_reason_display(self) -> None:
"""Render the rejection reason line if a reason is set."""
if self._reject_reason_widget is None:
return
if self._reject_reason:
self._reject_reason_widget.update(
Content.from_markup(
"[dim italic]Reason: $reason[/dim italic]",
reason=self._reject_reason,
)
)
self._reject_reason_widget.display = True
else:
self._reject_reason_widget.display = False
def set_skipped(self) -> None:
"""Mark the tool call as skipped (due to another rejection)."""
+267
View File
@@ -372,3 +372,270 @@ class TestOptionOrdering:
await pilot.pause()
assert decision_received == {"type": expected_type}
class TestRejectWithReason:
"""Tests for the free-text reject mode (`action_reject_with_reason`)."""
def test_help_hides_tab_amend_until_reject_selected(self) -> None:
"""The Tab amendment hint should only appear on the Reject option."""
menu = ApprovalMenu({"name": "execute", "args": {"command": "echo hello"}})
menu._selected = 0
assert "Tab amend" not in menu._compose_help_text()
menu._selected = 2
help_text = menu._compose_help_text()
assert "Tab amend" in help_text
assert "reject with reason" not in help_text
def test_update_options_refreshes_help_for_selected_option(self) -> None:
"""Moving between options should refresh the footer hint state."""
menu = ApprovalMenu({"name": "execute", "args": {"command": "echo hello"}})
menu._option_widgets = [MagicMock(), MagicMock(), MagicMock()]
menu._help_widget = MagicMock()
menu._selected = 2
menu._update_options()
menu._help_widget.update.assert_called_once()
assert "Tab amend" in menu._help_widget.update.call_args.args[0]
def test_move_actions_no_op_while_input_mode_active(self) -> None:
"""Arrow-key bindings should not move the menu while amending a reject."""
menu = ApprovalMenu({"name": "execute", "args": {"command": "echo hello"}})
menu._selected = 2
menu._reason_input_active = True
menu._update_options = MagicMock() # type: ignore[method-assign]
menu.action_move_up()
menu.action_move_down()
assert menu._selected == 2
menu._update_options.assert_not_called() # type: ignore[attr-defined]
def test_no_op_when_reject_not_selected(self) -> None:
"""Tab is a no-op unless cursor is on the Reject option."""
menu = ApprovalMenu({"name": "execute", "args": {"command": "echo hello"}})
menu._reason_input = MagicMock(value="", display=False)
menu._selected = 0
menu.action_reject_with_reason()
assert menu._reason_input_active is False
def test_activates_input_mode_when_reject_selected(self) -> None:
"""Tab on Reject flips the menu into reason-input mode."""
menu = ApprovalMenu({"name": "execute", "args": {"command": "echo hello"}})
reason_input = MagicMock(value="existing", display=False)
menu._reason_input = reason_input
menu._help_widget = MagicMock()
menu._selected = 2
menu.action_reject_with_reason()
assert menu._reason_input_active is True
assert reason_input.value == "" # cleared
assert reason_input.display is True
reason_input.focus.assert_called_once()
def test_handle_selection_attaches_reject_message(self) -> None:
"""A non-empty reason is attached to the reject decision."""
import asyncio
loop = asyncio.new_event_loop()
future: asyncio.Future[dict[str, str]] = loop.create_future()
menu = ApprovalMenu({"name": "write_file", "args": {"path": "f.py"}})
menu.set_future(future)
menu._handle_selection(2, reject_message="please dry-run first")
assert future.result() == {
"type": "reject",
"message": "please dry-run first",
}
loop.close()
def test_handle_selection_omits_empty_reject_message(self) -> None:
"""An empty / `None` reason produces a bare reject decision."""
import asyncio
loop = asyncio.new_event_loop()
future: asyncio.Future[dict[str, str]] = loop.create_future()
menu = ApprovalMenu({"name": "write_file", "args": {"path": "f.py"}})
menu.set_future(future)
menu._handle_selection(2, reject_message=None)
assert future.result() == {"type": "reject"}
loop.close()
def test_action_select_reject_cancels_input_mode_first(self) -> None:
"""Reject shortcut closes the input the first time instead of rejecting."""
menu = ApprovalMenu({"name": "execute", "args": {"command": "echo hello"}})
reason_input = MagicMock(value="abc", display=True)
menu._reason_input = reason_input
menu._help_widget = MagicMock()
menu._selected = 2
menu._reason_input_active = True
menu._handle_selection = MagicMock() # type: ignore[method-assign]
menu.action_select_reject()
# First call: cancels the input, no decision posted.
menu._handle_selection.assert_not_called() # type: ignore[attr-defined]
assert menu._reason_input_active is False
assert reason_input.display is False
# Second call: now it actually rejects.
menu.action_select_reject()
menu._handle_selection.assert_called_once_with(2)
async def test_tab_then_type_then_enter_submits_reason(self) -> None:
"""End-to-end Tab → type → Enter sends a reject decision with the message."""
from textual.app import App, ComposeResult
decision_received: dict[str, str] | None = None
class ApprovalTestApp(App[None]):
def compose(self) -> ComposeResult:
yield ApprovalMenu(
{"name": "execute", "args": {"command": "echo hello"}}
)
def on_approval_menu_decided(self, event: ApprovalMenu.Decided) -> None:
nonlocal decision_received
decision_received = event.decision
async with ApprovalTestApp().run_test() as pilot:
await pilot.pause()
# Move to Reject (option 3 of 3) — start at 0, so two downs.
await pilot.press("down", "down")
await pilot.press("tab")
await pilot.pause()
for ch in "dry run first":
await pilot.press(ch if ch != " " else "space")
await pilot.press("enter")
await pilot.pause()
assert decision_received == {
"type": "reject",
"message": "dry run first",
}
async def test_blank_reason_submits_plain_reject(self) -> None:
"""Pressing Enter without typing yields a bare reject decision."""
from textual.app import App, ComposeResult
decision_received: dict[str, str] | None = None
class ApprovalTestApp(App[None]):
def compose(self) -> ComposeResult:
yield ApprovalMenu(
{"name": "execute", "args": {"command": "echo hello"}}
)
def on_approval_menu_decided(self, event: ApprovalMenu.Decided) -> None:
nonlocal decision_received
decision_received = event.decision
async with ApprovalTestApp().run_test() as pilot:
await pilot.pause()
await pilot.press("down", "down")
await pilot.press("tab")
await pilot.pause()
await pilot.press("enter")
await pilot.pause()
assert decision_received == {"type": "reject"}
async def test_escape_during_reason_cancels_without_deciding(self) -> None:
"""Esc from the reason input must close it without posting a decision.
Verifies the cancel-first behavior end-to-end: typed reason is dropped,
no `Decided` posts, and a subsequent `n` still produces a plain reject.
"""
from textual.app import App, ComposeResult
decisions: list[dict[str, str]] = []
class ApprovalTestApp(App[None]):
def compose(self) -> ComposeResult:
yield ApprovalMenu(
{"name": "execute", "args": {"command": "echo hello"}}
)
def on_approval_menu_decided(self, event: ApprovalMenu.Decided) -> None:
decisions.append(event.decision)
async with ApprovalTestApp().run_test() as pilot:
await pilot.pause()
await pilot.press("down", "down")
await pilot.press("tab")
await pilot.pause()
for ch in "wip":
await pilot.press(ch)
menu = pilot.app.query_one(ApprovalMenu)
reason_input = menu._reason_input
assert reason_input is not None
# Esc from the reason Input — verify the cancel state directly so
# the test does not depend on which widget surfaces the key event.
menu.action_select_reject()
await pilot.pause()
assert decisions == []
assert menu._reason_input_active is False
assert reason_input.display is False
# Plain reject still works afterwards.
await pilot.press("n")
await pilot.pause()
assert decisions == [{"type": "reject"}]
async def test_on_blur_keeps_focus_on_input_while_active(self) -> None:
"""Focus-trap must skip re-focus while the reason `Input` is active.
Without this, the menu would steal focus mid-typing and the typed
reason would be lost.
"""
from textual.app import App, ComposeResult
class ApprovalTestApp(App[None]):
def compose(self) -> ComposeResult:
yield ApprovalMenu(
{"name": "execute", "args": {"command": "echo hello"}}
)
async with ApprovalTestApp().run_test() as pilot:
await pilot.pause()
await pilot.press("down", "down")
await pilot.press("tab")
await pilot.pause()
menu = pilot.app.query_one(ApprovalMenu)
assert menu._reason_input_active is True
assert menu._reason_input is not None
# Type something so we can confirm the value survives a blur.
for ch in "ok":
await pilot.press(ch)
await pilot.pause()
# Force-emit a blur event; menu must not steal focus back.
from textual.events import Blur
menu.on_blur(Blur())
await pilot.pause()
assert pilot.app.focused is menu._reason_input
assert menu._reason_input.value == "ok"
def test_on_input_submitted_drops_inactive_event_without_decision(self) -> None:
"""A stray submit after the input was cancelled must not decide.
Guards against the Esc-then-Enter race: Esc flips `_reason_input_active`
off; a queued Submitted should be silently dropped (with debug log)
rather than posting a phantom reject.
"""
import asyncio
from types import SimpleNamespace
loop = asyncio.new_event_loop()
future: asyncio.Future[dict[str, str]] = loop.create_future()
menu = ApprovalMenu({"name": "execute", "args": {"command": "echo hello"}})
menu.set_future(future)
reason_input = MagicMock(value="late", display=False)
menu._reason_input = reason_input
menu._reason_input_active = False
menu._handle_selection = MagicMock() # type: ignore[method-assign]
event = SimpleNamespace(input=reason_input, value="late", stop=MagicMock())
menu.on_input_submitted(event) # type: ignore[arg-type]
event.stop.assert_called_once()
menu._handle_selection.assert_not_called() # type: ignore[attr-defined]
assert not future.done()
loop.close()
@@ -616,6 +616,23 @@ class TestVirtualizationFlow:
assert restored._deferred_output == "file1.txt\nfile2.txt\nfile3.txt"
assert restored._deferred_expanded is True
def test_tool_message_reject_reason_round_trips(self):
"""The HITL reject reason should survive serialization."""
original = ToolCallMessage(
tool_name="execute",
args={"command": "rm -rf /"},
id="tool-2",
)
original._status = "rejected"
original._reject_reason = "let's avoid recursive deletes"
data = MessageData.from_widget(original)
assert data.tool_reject_reason == "let's avoid recursive deletes"
restored = data.to_widget()
assert isinstance(restored, ToolCallMessage)
assert restored._deferred_reject_reason == "let's avoid recursive deletes"
def test_streaming_message_protection(self):
"""Test that streaming (active) messages are never pruned.
+103
View File
@@ -763,6 +763,109 @@ class TestToolCallMessageAwaitingApproval:
assert msg.display is True
class TestToolCallMessageRejectReason:
"""Tests for surfacing a user-supplied HITL reject reason."""
async def test_set_rejected_with_reason_renders_line(self) -> None:
"""`set_rejected(reason=...)` should display the reason beneath the status."""
from textual.app import App, ComposeResult
class _Harness(App[None]):
def __init__(self) -> None:
super().__init__()
self.msg = ToolCallMessage("execute", {"command": "echo hi"})
def compose(self) -> ComposeResult:
yield self.msg
async with _Harness().run_test() as pilot:
await pilot.pause()
app = pilot.app
assert isinstance(app, _Harness)
msg = app.msg
msg.set_rejected(reason="please dry-run first")
await pilot.pause()
assert msg._reject_reason == "please dry-run first"
assert msg._reject_reason_widget is not None
assert msg._reject_reason_widget.display is True
async def test_set_rejected_without_reason_hides_line(self) -> None:
"""`set_rejected()` with no reason keeps the reason line hidden."""
from textual.app import App, ComposeResult
class _Harness(App[None]):
def __init__(self) -> None:
super().__init__()
self.msg = ToolCallMessage("execute", {"command": "echo hi"})
def compose(self) -> ComposeResult:
yield self.msg
async with _Harness().run_test() as pilot:
await pilot.pause()
app = pilot.app
assert isinstance(app, _Harness)
msg = app.msg
msg.set_rejected()
await pilot.pause()
assert msg._reject_reason is None
assert msg._reject_reason_widget is not None
assert msg._reject_reason_widget.display is False
async def test_blank_reason_does_not_set_attribute(self) -> None:
"""Whitespace-only reasons are treated as no reason."""
from textual.app import App, ComposeResult
class _Harness(App[None]):
def __init__(self) -> None:
super().__init__()
self.msg = ToolCallMessage("execute", {"command": "echo hi"})
def compose(self) -> ComposeResult:
yield self.msg
async with _Harness().run_test() as pilot:
await pilot.pause()
app = pilot.app
assert isinstance(app, _Harness)
msg = app.msg
msg.set_rejected(reason=" ")
await pilot.pause()
assert msg._reject_reason is None
async def test_reason_with_markup_brackets_renders_safely(self) -> None:
"""User-controlled reasons must round-trip through Rich markup unscathed.
`from_markup` with `$reason` substitution should escape any literal
bracket sequences so the reason line never throws a MarkupError.
"""
from textual.app import App, ComposeResult
hostile = "[bold red]boom[/bold red] [/dim] $x"
class _Harness(App[None]):
def __init__(self) -> None:
super().__init__()
self.msg = ToolCallMessage("execute", {"command": "echo hi"})
def compose(self) -> ComposeResult:
yield self.msg
async with _Harness().run_test() as pilot:
await pilot.pause()
app = pilot.app
assert isinstance(app, _Harness)
msg = app.msg
msg.set_rejected(reason=hostile)
await pilot.pause()
assert msg._reject_reason == hostile
assert msg._reject_reason_widget is not None
assert msg._reject_reason_widget.display is True
rendered = str(msg._reject_reason_widget.render())
assert "boom" in rendered
assert "$x" in rendered
class TestUserMessageHighlighting:
"""Test UserMessage highlighting of `@mentions` and `/commands`."""
@@ -1620,6 +1620,66 @@ class TestExecuteTaskTextualAskUser:
assert "Command rejected" in str(app_messages[0]._content)
assert token_events == ["pending", "show:False"]
async def test_hitl_rejection_with_reason_resumes_agent(self) -> None:
"""Rejected approval with a reason should resume so the agent can react."""
mounted: list[object] = []
future: asyncio.Future[object] = asyncio.Future()
future.set_result({"type": "reject", "message": "use a safer command"})
async def mount_message(widget: object) -> None:
await asyncio.sleep(0)
mounted.append(widget)
async def request_approval(
_action_requests: list[dict[str, Any]],
_assistant_id: str | None,
) -> asyncio.Future[object]:
await asyncio.sleep(0)
return future
agent = _SequencedAgent(
streams_by_call=[
[
_hitl_interrupt_chunk(
{
"action_requests": [
{"name": "execute", "args": {"command": "rm file"}}
],
"review_configs": [
{
"action_name": "execute",
"allowed_decisions": ["approve", "reject"],
}
],
}
)
],
[],
]
)
adapter = TextualUIAdapter(
mount_message=mount_message,
update_status=_noop_status,
request_approval=request_approval,
)
await execute_task_textual(
user_input="hello",
agent=agent,
assistant_id="assistant",
session_state=SimpleNamespace(thread_id="thread-1", auto_approve=False),
adapter=adapter,
)
assert len(agent.stream_inputs) == 2
resume_cmd = agent.stream_inputs[1]
assert isinstance(resume_cmd, Command)
resume_payload = cast("dict[str, dict[str, Any]]", resume_cmd.resume)
decisions = resume_payload["interrupt-1"]["decisions"]
assert decisions == [{"type": "reject", "message": "use a safer command"}]
app_messages = [widget for widget in mounted if isinstance(widget, AppMessage)]
assert not any("Command rejected" in str(msg._content) for msg in app_messages)
async def test_ask_user_invalid_answers_payload_marks_row_error(self) -> None:
"""Non-list answers should mark row as error and pop it."""
mounted: list[ToolCallMessage] = []