mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-22 09:45:24 -04:00
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:
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
Reference in New Issue
Block a user