mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-21 17:25:26 -04:00
feat(code): collapse completed tool calls into group summaries (#4373)
Collapses each assistant step's tool calls into a single collapsible line in the code TUI — live progress while running, past tense when the step finishes, click or Ctrl+O to expand. Errored, rejected, and skipped tools stay visible. The Thinking spinner now remains pinned as a stable turn-level indicator while tool rows mount above it, and tool-group updates are coalesced to avoid transcript flicker during streaming. ## Video https://github.com/user-attachments/assets/8e69e331-77d7-46ed-bdc9-a8b33db9ba75 --------- Co-authored-by: open-swe[bot] <open-swe@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:
committed by
GitHub
parent
a4abdd89df
commit
3735829a0c
@@ -83,10 +83,12 @@ from deepagents_code.widgets.message_store import (
|
||||
from deepagents_code.widgets.messages import (
|
||||
AppMessage,
|
||||
AssistantMessage,
|
||||
DiffMessage,
|
||||
ErrorMessage,
|
||||
QueuedUserMessage,
|
||||
SkillMessage,
|
||||
ToolCallMessage,
|
||||
ToolGroupSummary,
|
||||
UserMessage,
|
||||
)
|
||||
from deepagents_code.widgets.status import StatusBar
|
||||
@@ -2377,6 +2379,10 @@ class DeepAgentsApp(App):
|
||||
"""The `UserMessage` widget that started the in-flight turn, tracked so
|
||||
it can be dimmed if the turn is interrupted."""
|
||||
|
||||
self._active_tool_group: ToolGroupSummary | None = None
|
||||
"""Open tool-group summary for the current step. Tools are folded into
|
||||
it as they stream and it is closed at the next step boundary."""
|
||||
|
||||
self._shell_process: asyncio.subprocess.Process | None = None
|
||||
"""Shell command process tracking for interruption (! commands)."""
|
||||
|
||||
@@ -5614,13 +5620,19 @@ class DeepAgentsApp(App):
|
||||
added_height = hydrated_count * estimated_height_per_message
|
||||
chat.scroll_y = old_scroll_y + added_height
|
||||
|
||||
async def _mount_before_queued(self, container: Container, widget: Widget) -> None:
|
||||
"""Mount a widget in the messages container, before any queued widgets.
|
||||
# Collapse any completed tool runs brought in above the window so
|
||||
# hydrated history matches the live transcript.
|
||||
await self._regroup_completed_tools()
|
||||
|
||||
Queued-message widgets must stay at the bottom of the container so
|
||||
they remain visually anchored below the current agent response.
|
||||
This helper inserts `widget` just before the first queued widget,
|
||||
or appends at the end when the queue is empty.
|
||||
async def _mount_before_queued(self, container: Container, widget: Widget) -> None:
|
||||
"""Mount a widget in the messages container, kept above the bottom anchors.
|
||||
|
||||
The loading spinner and queued-message widgets must stay pinned at the
|
||||
bottom of the container. New content mounts just above them — before the
|
||||
spinner if it is present (so it never needs repositioning as tools
|
||||
stream, which flickered), otherwise before the first queued widget,
|
||||
otherwise appended at the end. The spinner itself anchors only on the
|
||||
queued widgets so it can mount at the bottom.
|
||||
|
||||
Args:
|
||||
container: The `#messages` container to mount into.
|
||||
@@ -5628,13 +5640,24 @@ class DeepAgentsApp(App):
|
||||
"""
|
||||
if not container.is_attached:
|
||||
return
|
||||
first_queued = self._queued_widgets[0] if self._queued_widgets else None
|
||||
if first_queued is not None and first_queued.parent is container:
|
||||
anchor: Widget | None = None
|
||||
spinner = self._loading_widget
|
||||
if (
|
||||
widget is not spinner
|
||||
and spinner is not None
|
||||
and spinner.parent is container
|
||||
):
|
||||
anchor = spinner
|
||||
else:
|
||||
first_queued = self._queued_widgets[0] if self._queued_widgets else None
|
||||
if first_queued is not None and first_queued.parent is container:
|
||||
anchor = first_queued
|
||||
if anchor is not None:
|
||||
try:
|
||||
await container.mount(widget, before=first_queued)
|
||||
await container.mount(widget, before=anchor)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Stale queued-widget reference; appending at end",
|
||||
"Stale mount anchor reference; appending at end",
|
||||
exc_info=True,
|
||||
)
|
||||
else:
|
||||
@@ -5758,8 +5781,7 @@ class DeepAgentsApp(App):
|
||||
)
|
||||
|
||||
if status is None:
|
||||
# Hide
|
||||
if self._loading_widget:
|
||||
if self._loading_widget is not None:
|
||||
await self._loading_widget.remove()
|
||||
self._loading_widget = None
|
||||
if self._terminal_progress_enabled:
|
||||
@@ -5784,8 +5806,10 @@ class DeepAgentsApp(App):
|
||||
# silently so the streaming loop doesn't crash.
|
||||
return
|
||||
|
||||
if self._loading_widget is None:
|
||||
# Create new
|
||||
if self._loading_widget is None or not self._loading_widget.is_attached:
|
||||
# Mount once per turn. `_mount_before_queued` keeps new messages
|
||||
# *above* the spinner, so it stays pinned at the bottom and never
|
||||
# needs repositioning (which flickered) as tools stream in.
|
||||
self._loading_widget = LoadingWidget(status)
|
||||
await self._mount_before_queued(messages, self._loading_widget)
|
||||
else:
|
||||
@@ -5794,10 +5818,9 @@ class DeepAgentsApp(App):
|
||||
# abandoned without completing the resume callback. `resume()` is a
|
||||
# no-op when the spinner is not paused.
|
||||
self._loading_widget.resume()
|
||||
# Update existing
|
||||
self._loading_widget.set_status(status)
|
||||
# Reposition via move_child so elapsed-time and animation state
|
||||
# carry through; remove + re-mount would reset both.
|
||||
# Safety fallback: messages now mount above the spinner so it should
|
||||
# already be in place, but reposition if something left it stranded.
|
||||
if not self._is_spinner_at_correct_position(messages):
|
||||
self._reposition_spinner(messages)
|
||||
# NOTE: Don't call anchor() here - it would re-anchor and drag user back
|
||||
@@ -10244,6 +10267,16 @@ class DeepAgentsApp(App):
|
||||
),
|
||||
turn_stats=turn_stats,
|
||||
)
|
||||
# Close the final step's group once the turn ends with no trailing
|
||||
# assistant text to trigger the boundary path. Grouping is cosmetic,
|
||||
# so a failure here must not abort the turn — but log it, since
|
||||
# `_mount_tool_group_summary` already handles its own mount errors and
|
||||
# anything reaching this point is unexpected.
|
||||
try:
|
||||
self._close_active_tool_group()
|
||||
await self._regroup_completed_tools()
|
||||
except Exception:
|
||||
logger.exception("Failed to close/regroup tool group at turn end")
|
||||
except Exception as e: # Resilient tool rendering
|
||||
logger.exception("Agent execution failed")
|
||||
try:
|
||||
@@ -10293,6 +10326,9 @@ class DeepAgentsApp(App):
|
||||
subagent_panel = self._get_subagent_panel()
|
||||
if subagent_panel is not None:
|
||||
subagent_panel.finalize_running()
|
||||
# Collapse the open tool group so an interrupted turn doesn't leave a
|
||||
# summary spinning "Running…" forever (synchronous, cancel-safe).
|
||||
self._close_active_tool_group()
|
||||
await self._cleanup_agent_task()
|
||||
|
||||
async def _process_next_from_queue(self) -> None:
|
||||
@@ -10952,20 +10988,59 @@ class DeepAgentsApp(App):
|
||||
pass
|
||||
return
|
||||
|
||||
# Eagerly fold tool calls into a single live summary so they are
|
||||
# collapsed from the moment they start, rather than rendering verbose
|
||||
# then snapping shut. A groupable tool joins (or opens) the current
|
||||
# step's group; a diff folds into it; anything else is a step boundary
|
||||
# that closes the group.
|
||||
is_groupable_tool = (
|
||||
isinstance(widget, ToolCallMessage) and widget.tool_name != "ask_user"
|
||||
)
|
||||
is_diff = isinstance(widget, DiffMessage)
|
||||
|
||||
# Store message data for virtualization
|
||||
message_data = MessageData.from_widget(widget)
|
||||
if not widget.id:
|
||||
# Keep the widget DOM id == store id so pruning can locate a
|
||||
# mounted widget (and its timestamp footer) from its MessageData.
|
||||
widget.id = message_data.id
|
||||
self._message_store.append(message_data)
|
||||
footer = self._build_message_timestamp_footer(
|
||||
message_data, visible=self._message_timestamps_visible
|
||||
)
|
||||
|
||||
await self._mount_before_queued(messages, widget)
|
||||
if footer is not None:
|
||||
await self._mount_before_queued(messages, footer)
|
||||
# Coalesce the whole mount-and-fold sequence into a single repaint.
|
||||
# Otherwise mounting a groupable tool paints it at full height, then
|
||||
# folding it into the group hides it on the next frame — bouncing the
|
||||
# bottom-anchored transcript on every tool call.
|
||||
with self.batch_update():
|
||||
if not (is_groupable_tool or is_diff):
|
||||
self._close_active_tool_group()
|
||||
# Re-derive groups for any tools mounted outside this path
|
||||
# (resumed history), which carry no live group.
|
||||
await self._regroup_completed_tools()
|
||||
elif is_groupable_tool and (
|
||||
self._active_tool_group is None
|
||||
or not self._active_tool_group.is_attached
|
||||
):
|
||||
self._active_tool_group = ToolGroupSummary(live=True)
|
||||
await self._mount_before_queued(messages, self._active_tool_group)
|
||||
|
||||
self._message_store.append(message_data)
|
||||
|
||||
await self._mount_before_queued(messages, widget)
|
||||
if footer is not None:
|
||||
await self._mount_before_queued(messages, footer)
|
||||
|
||||
# Fold the freshly-mounted tool/diff into the open group so it hides
|
||||
# immediately (must run after mount so display toggles take effect).
|
||||
if (
|
||||
self._active_tool_group is not None
|
||||
and self._active_tool_group.is_attached
|
||||
):
|
||||
if is_groupable_tool:
|
||||
self._active_tool_group.add_member(widget)
|
||||
elif is_diff:
|
||||
self._active_tool_group.add_collapsible(widget)
|
||||
|
||||
# Prune old widgets if window exceeded
|
||||
await self._prune_old_messages()
|
||||
@@ -11016,6 +11091,113 @@ class DeepAgentsApp(App):
|
||||
|
||||
if pruned_ids:
|
||||
self._message_store.mark_pruned(pruned_ids)
|
||||
# Drop any group summaries whose members were all pruned away so a
|
||||
# stray collapsed line never lingers above the window. Only reachable
|
||||
# when something was actually pruned this pass.
|
||||
for summary in list(self.query(ToolGroupSummary)):
|
||||
if not summary.has_attached_members:
|
||||
try:
|
||||
await summary.remove()
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"Failed to remove orphaned tool group summary",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
def _close_active_tool_group(self) -> None:
|
||||
"""Finalize the open tool group into its collapsed past-tense form."""
|
||||
group = self._active_tool_group
|
||||
self._active_tool_group = None
|
||||
if group is not None and group.is_attached:
|
||||
try:
|
||||
group.close()
|
||||
except Exception:
|
||||
# Also runs on the interrupt/cancel finally path, so never
|
||||
# re-raise. Log so a broken eviction (e.g. a failed tool left
|
||||
# folded and hidden) surfaces instead of being swallowed.
|
||||
logger.exception("Failed to close active tool group")
|
||||
|
||||
async def _regroup_completed_tools(self) -> None:
|
||||
"""Fold runs of completed tool calls into collapsible group summaries.
|
||||
|
||||
Scans the messages container for maximal runs of consecutive,
|
||||
successfully-completed tool calls (optionally interleaved with their
|
||||
diff previews) and inserts a `ToolGroupSummary` that collapses each run
|
||||
into a single dim line. Footers stay transparent to the scan so a
|
||||
timestamp row between two tools does not split a run.
|
||||
|
||||
Idempotent: tools already folded carry the `-grouped` class and are
|
||||
skipped, so it is safe to call on every stream boundary and on
|
||||
hydration. A run is only collapsed once every tool in it succeeded;
|
||||
a run containing an error/rejection/pending tool is left expanded.
|
||||
"""
|
||||
try:
|
||||
messages = self.query_one("#messages", Container)
|
||||
except NoMatches:
|
||||
return
|
||||
|
||||
run_tools: list[ToolCallMessage] = []
|
||||
run_collapsible: list[Widget] = []
|
||||
run_anchor: Widget | None = None
|
||||
|
||||
async def flush() -> None:
|
||||
nonlocal run_tools, run_collapsible, run_anchor
|
||||
if run_tools and run_anchor is not None:
|
||||
await self._mount_tool_group_summary(
|
||||
messages, run_tools, run_collapsible, run_anchor
|
||||
)
|
||||
run_tools = []
|
||||
run_collapsible = []
|
||||
run_anchor = None
|
||||
|
||||
# One repaint for the whole regroup — a single hydration or boundary
|
||||
# pass can fold several runs and hide many rows at once.
|
||||
with self.batch_update():
|
||||
for child in list(messages.children):
|
||||
if child.has_class(_MESSAGE_TIMESTAMP_FOOTER_CLASS):
|
||||
continue # footers are transparent to grouping
|
||||
if isinstance(child, ToolCallMessage):
|
||||
groupable = (
|
||||
child.tool_name != "ask_user"
|
||||
and child.is_success
|
||||
and not child.has_class("-grouped")
|
||||
)
|
||||
if not groupable:
|
||||
await flush()
|
||||
continue
|
||||
if run_anchor is None:
|
||||
run_anchor = child
|
||||
run_tools.append(child)
|
||||
run_collapsible.append(child)
|
||||
continue
|
||||
if isinstance(child, DiffMessage):
|
||||
# A diff belongs to the tool above it; never starts a run.
|
||||
if run_anchor is not None:
|
||||
run_collapsible.append(child)
|
||||
continue
|
||||
# Assistant text, notices, an existing summary, etc. end the run.
|
||||
await flush()
|
||||
await flush()
|
||||
|
||||
@staticmethod
|
||||
async def _mount_tool_group_summary(
|
||||
messages: Container,
|
||||
tools: list[ToolCallMessage],
|
||||
collapsible: list[Widget],
|
||||
anchor: Widget,
|
||||
) -> None:
|
||||
"""Insert a `ToolGroupSummary` before `anchor` and collapse the run."""
|
||||
if not anchor.is_attached:
|
||||
return
|
||||
summary = ToolGroupSummary(tools=list(tools), collapsible=list(collapsible))
|
||||
for widget in collapsible:
|
||||
widget.add_class("-grouped")
|
||||
try:
|
||||
await messages.mount(summary, before=anchor)
|
||||
except Exception:
|
||||
logger.warning("Failed to mount tool group summary", exc_info=True)
|
||||
for widget in collapsible:
|
||||
widget.remove_class("-grouped")
|
||||
|
||||
def _set_active_message(self, message_id: str | None) -> None:
|
||||
"""Set the active streaming message (won't be pruned).
|
||||
@@ -11048,6 +11230,11 @@ class DeepAgentsApp(App):
|
||||
self._pending_shell_messages.clear()
|
||||
# Clear the message store first
|
||||
self._message_store.clear()
|
||||
# Drop the open tool group; its widget is about to leave the DOM.
|
||||
self._active_tool_group = None
|
||||
# Drop the stale spinner ref, since remove_children() below detaches
|
||||
# the current spinner widget.
|
||||
self._loading_widget = None
|
||||
# Drop the tracked in-flight prompt: its widget is about to leave the
|
||||
# DOM, so the pointer must not outlive it. Keeps the "cleared screen ⇒
|
||||
# nothing to dim" invariant self-enforcing regardless of caller timing.
|
||||
@@ -11818,7 +12005,7 @@ class DeepAgentsApp(App):
|
||||
)
|
||||
|
||||
def action_toggle_tool_output(self) -> None:
|
||||
"""Toggle expand/collapse of the most recent tool output or skill body."""
|
||||
"""Toggle the most recent collapsible unit (group, skill, or tool)."""
|
||||
# Pending ask_user takes precedence so Ctrl+O toggles the question card.
|
||||
if self._pending_ask_user_widget is not None:
|
||||
try:
|
||||
@@ -11830,31 +12017,31 @@ class DeepAgentsApp(App):
|
||||
tool_msg.toggle_args()
|
||||
return
|
||||
|
||||
# Try skill messages first (most recent collapsible content)
|
||||
# Toggle whichever collapsible unit is most recent in DOM order — a tool
|
||||
# group, a skill body, or a standalone tool row — so content mounted
|
||||
# after a group stays reachable instead of always hitting the last group.
|
||||
# Grouped tool rows are folded into their summary, so skip them here.
|
||||
try:
|
||||
skill_messages = list(self.query(SkillMessage))
|
||||
messages = self.query_one("#messages", Container)
|
||||
except NoMatches:
|
||||
skill_messages = []
|
||||
for skill_msg in reversed(skill_messages):
|
||||
if skill_msg._stripped_body.strip():
|
||||
skill_msg.toggle_body()
|
||||
return
|
||||
for child in reversed(list(messages.children)):
|
||||
if isinstance(child, ToolGroupSummary):
|
||||
child.toggle()
|
||||
return
|
||||
|
||||
# Fall back to tool messages with output or expandable args
|
||||
try:
|
||||
tool_messages = list(self.query(ToolCallMessage))
|
||||
except NoMatches:
|
||||
tool_messages = []
|
||||
for tool_msg in reversed(tool_messages):
|
||||
if tool_msg.has_output and tool_msg.has_expandable_output:
|
||||
tool_msg.toggle_output()
|
||||
return
|
||||
if tool_msg.has_expandable_args:
|
||||
tool_msg.toggle_args()
|
||||
return
|
||||
if tool_msg.has_output:
|
||||
tool_msg.toggle_output()
|
||||
if isinstance(child, SkillMessage) and child._stripped_body.strip():
|
||||
child.toggle_body()
|
||||
return
|
||||
if isinstance(child, ToolCallMessage) and not child.has_class("-grouped"):
|
||||
if child.has_output and child.has_expandable_output:
|
||||
child.toggle_output()
|
||||
return
|
||||
if child.has_expandable_args:
|
||||
child.toggle_args()
|
||||
return
|
||||
if child.has_output:
|
||||
child.toggle_output()
|
||||
return
|
||||
|
||||
# Approval menu action handlers (delegated from App-level bindings)
|
||||
# NOTE: These only activate when approval widget is pending
|
||||
|
||||
@@ -78,9 +78,6 @@ _hitl_adapter_cache: TypeAdapter | None = None
|
||||
|
||||
_ASK_USER_UNSUPPORTED_ERROR = "ask_user not supported by this UI"
|
||||
|
||||
_TOOL_CALLS_KEEP_THINKING_SPINNER = frozenset({"edit_file"})
|
||||
"""Tool calls whose argument/approval phase can be long enough to need feedback."""
|
||||
|
||||
|
||||
def _get_hitl_request_adapter(hitl_request_type: type) -> TypeAdapter:
|
||||
"""Return a cached `TypeAdapter(HITLRequest)`.
|
||||
@@ -1170,17 +1167,15 @@ async def execute_task_textual(
|
||||
buffer_name, parsed_args, buffer_id
|
||||
)
|
||||
|
||||
keep_thinking_spinner = (
|
||||
buffer_name in _TOOL_CALLS_KEEP_THINKING_SPINNER
|
||||
)
|
||||
|
||||
# Hide spinner before showing most tool calls.
|
||||
# `edit_file` can spend noticeable time between
|
||||
# argument streaming, HITL interrupt delivery, and
|
||||
# approval handling, so re-anchor Thinking below
|
||||
# the row instead of leaving the UI visually idle.
|
||||
if adapter._set_spinner and not keep_thinking_spinner:
|
||||
await adapter._set_spinner(None)
|
||||
# Keep the global "Thinking" spinner visible
|
||||
# across tool calls rather than hiding it per
|
||||
# tool: it's a stable turn-level indicator, and
|
||||
# the tool's own progress now shows in its
|
||||
# collapsed group row. Re-assert it so it stays
|
||||
# pinned at the bottom as the new row mounts
|
||||
# above it.
|
||||
if adapter._set_spinner:
|
||||
await adapter._set_spinner("Thinking")
|
||||
|
||||
# Mount tool call message
|
||||
logger.debug(
|
||||
@@ -1191,20 +1186,11 @@ async def execute_task_textual(
|
||||
tool_msg = ToolCallMessage(buffer_name, parsed_args)
|
||||
await adapter._mount_message(tool_msg)
|
||||
adapter._current_tool_messages[buffer_id] = tool_msg
|
||||
if keep_thinking_spinner:
|
||||
# The argument/approval phase uses the global
|
||||
# "Thinking" spinner instead of a per-tool one.
|
||||
if adapter._set_spinner:
|
||||
await adapter._set_spinner("Thinking")
|
||||
else:
|
||||
# Show a per-tool running spinner immediately so
|
||||
# auto-executed tools such as grep, glob,
|
||||
# read_file, and ls display activity instead of
|
||||
# sitting idle until their result arrives. Every
|
||||
# tool outside the frozenset hits this branch;
|
||||
# those that go on to interrupt for approval are
|
||||
# paused again below.
|
||||
tool_msg.set_running()
|
||||
# Mark running so the group row reflects live
|
||||
# progress; the row itself is hidden inside the
|
||||
# group, so this drives state, not a visible
|
||||
# per-tool spinner.
|
||||
tool_msg.set_running()
|
||||
|
||||
tool_call_buffers.pop(buffer_key, None)
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@ if TYPE_CHECKING:
|
||||
from textual.app import ComposeResult
|
||||
from textual.events import MouseMove
|
||||
from textual.timer import Timer
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Markdown
|
||||
from textual.widgets._markdown import MarkdownStream
|
||||
|
||||
@@ -866,6 +867,15 @@ class AssistantMessage(Vertical):
|
||||
await self._markdown.update(content)
|
||||
|
||||
|
||||
_ToolStatus = Literal["pending", "running", "success", "error", "rejected", "skipped"]
|
||||
"""The full set of lifecycle states a tool call can hold.
|
||||
|
||||
Kept as a closed `Literal` so `ty` flags typos at the assignment sites and so
|
||||
the grouping predicates (`is_success`/`is_failed`/`is_pending`) partition a
|
||||
known universe.
|
||||
"""
|
||||
|
||||
|
||||
class ToolCallMessage(Vertical):
|
||||
"""Widget displaying a tool call with collapsible output.
|
||||
|
||||
@@ -1002,7 +1012,7 @@ class ToolCallMessage(Vertical):
|
||||
super().__init__(**kwargs)
|
||||
self._tool_name = tool_name
|
||||
self._args = args or {}
|
||||
self._status = "pending" # Waiting for approval or auto-approve
|
||||
self._status: _ToolStatus = "pending" # Waiting for approval or auto-approve
|
||||
self._output: str = ""
|
||||
self._expanded: bool = False
|
||||
self._args_expanded: bool = False
|
||||
@@ -1481,10 +1491,10 @@ class ToolCallMessage(Vertical):
|
||||
"""Return whether search output is a terminal no-result message.
|
||||
|
||||
These sentinels must match the empty-result strings the SDK emits
|
||||
(`format_grep_matches` and `_format_file_paths` in
|
||||
`deepagents.middleware.filesystem`). If those change, this silently
|
||||
stops matching and empty searches revert to collapsing behind an expand
|
||||
affordance rather than rendering inline.
|
||||
(`format_grep_matches` in `deepagents.backends.utils` and
|
||||
`_format_file_paths` in `deepagents.middleware.filesystem`). If those
|
||||
change, this silently stops matching and empty searches revert to
|
||||
collapsing behind an expand affordance rather than rendering inline.
|
||||
"""
|
||||
if self._tool_name == "grep":
|
||||
return output.strip() == "No matches found"
|
||||
@@ -2454,6 +2464,27 @@ class ToolCallMessage(Vertical):
|
||||
"""Public read-only accessor for the underlying tool name."""
|
||||
return self._tool_name
|
||||
|
||||
@property
|
||||
def is_success(self) -> bool:
|
||||
"""Whether the tool completed successfully."""
|
||||
return self._status == "success"
|
||||
|
||||
@property
|
||||
def is_failed(self) -> bool:
|
||||
"""Whether the tool did not succeed and should stay visible.
|
||||
|
||||
Covers errored, rejected, and skipped tools. `skipped` is included so a
|
||||
reject-cascade (one tool rejected, the rest skipped) keeps the skipped
|
||||
rows visible and out of the group's success count, matching how
|
||||
`_regroup_completed_tools` treats a hydrated transcript.
|
||||
"""
|
||||
return self._status in {"error", "rejected", "skipped"}
|
||||
|
||||
@property
|
||||
def is_pending(self) -> bool:
|
||||
"""Whether the tool has not finished (awaiting approval or running)."""
|
||||
return self._status in {"pending", "running"}
|
||||
|
||||
@property
|
||||
def has_expandable_args(self) -> bool:
|
||||
"""Whether the tool's args are large enough to deserve a collapsible block.
|
||||
@@ -2563,6 +2594,358 @@ class ToolCallMessage(Vertical):
|
||||
return filtered
|
||||
|
||||
|
||||
# Maps a tool name to the summary category it aggregates under. grep/glob share
|
||||
# "search" so a mixed run folds into a single "Searched for N patterns" segment.
|
||||
_TOOL_SUMMARY_CATEGORY: dict[str, str] = {
|
||||
"read_file": "read",
|
||||
"write_file": "write",
|
||||
"edit_file": "edit",
|
||||
"delete": "delete",
|
||||
"ls": "ls",
|
||||
"grep": "search",
|
||||
"glob": "search",
|
||||
"execute": "shell",
|
||||
"js_eval": "js",
|
||||
"web_search": "web_search",
|
||||
"fetch_url": "fetch",
|
||||
"task": "task",
|
||||
"write_todos": "todos",
|
||||
}
|
||||
|
||||
# category -> (present verb, past verb, singular noun, plural noun).
|
||||
_TOOL_SUMMARY_PHRASES: dict[str, tuple[str, str, str, str]] = {
|
||||
"read": ("Reading", "Read", "file", "files"),
|
||||
"write": ("Writing", "Wrote", "file", "files"),
|
||||
"edit": ("Editing", "Edited", "file", "files"),
|
||||
"delete": ("Deleting", "Deleted", "file", "files"),
|
||||
"ls": ("Listing", "Listed", "directory", "directories"),
|
||||
"search": ("Searching for", "Searched for", "pattern", "patterns"),
|
||||
"shell": ("Running", "Ran", "shell command", "shell commands"),
|
||||
"js": ("Running", "Ran", "JS evaluation", "JS evaluations"),
|
||||
"fetch": ("Fetching", "Fetched", "URL", "URLs"),
|
||||
"task": ("Running", "Ran", "agent", "agents"),
|
||||
}
|
||||
|
||||
_Tense = Literal["present", "past"]
|
||||
|
||||
|
||||
def _summary_segment(category: str, count: int, tool_name: str, tense: _Tense) -> str:
|
||||
"""Phrase a single count segment, e.g. "Read 2 files" / "Reading 2 files".
|
||||
|
||||
Args:
|
||||
category: The summary category the tools were bucketed into.
|
||||
count: How many tools fell into this category.
|
||||
tool_name: A representative raw tool name, used to phrase categories
|
||||
that have no dedicated entry in `_TOOL_SUMMARY_PHRASES`.
|
||||
tense: Whether to phrase the segment in the present or past tense.
|
||||
|
||||
Returns:
|
||||
The phrased segment for this category, count, and tense.
|
||||
"""
|
||||
if category == "web_search":
|
||||
base = "Searching the web" if tense == "present" else "Searched the web"
|
||||
return base if count == 1 else f"{base} {count} times"
|
||||
if category == "todos":
|
||||
return "Updating todos" if tense == "present" else "Updated todos"
|
||||
phrase = _TOOL_SUMMARY_PHRASES.get(category)
|
||||
if phrase is None:
|
||||
present, past = "Running", "Ran"
|
||||
singular, plural = f"{tool_name} call", f"{tool_name} calls"
|
||||
else:
|
||||
present, past, singular, plural = phrase
|
||||
verb = present if tense == "present" else past
|
||||
noun = singular if count == 1 else plural
|
||||
return f"{verb} {count} {noun}"
|
||||
|
||||
|
||||
def summarize_tool_group(tool_names: list[str], *, tense: _Tense = "past") -> str:
|
||||
"""Build a one-line summary of a run of tool calls.
|
||||
|
||||
Aggregates by category in first-appearance order and lowercases the lead
|
||||
word of every segment after the first, e.g.
|
||||
`["read_file", "read_file", "execute"]` -> "Read 2 files, ran 1 shell command".
|
||||
|
||||
Args:
|
||||
tool_names: Raw tool names for the run, in call order.
|
||||
tense: Whether to phrase the summary in the present or past tense.
|
||||
|
||||
Returns:
|
||||
The aggregated one-line summary string in the requested tense.
|
||||
"""
|
||||
counts: dict[str, int] = {}
|
||||
order: list[str] = []
|
||||
rep_name: dict[str, str] = {}
|
||||
for name in tool_names:
|
||||
category = _TOOL_SUMMARY_CATEGORY.get(name, name)
|
||||
if category not in counts:
|
||||
counts[category] = 0
|
||||
order.append(category)
|
||||
rep_name[category] = name
|
||||
counts[category] += 1
|
||||
|
||||
segments = [
|
||||
_summary_segment(cat, counts[cat], rep_name[cat], tense) for cat in order
|
||||
]
|
||||
if not segments:
|
||||
return "Running tools" if tense == "present" else "Ran tools"
|
||||
first, *rest = segments
|
||||
lowered = [f"{seg[0].lower()}{seg[1:]}" if seg else seg for seg in rest]
|
||||
return ", ".join([first, *lowered])
|
||||
|
||||
|
||||
class ToolGroupSummary(Static):
|
||||
"""Collapsed one-line stand-in for an assistant step's tool calls.
|
||||
|
||||
Tools are hidden from the moment they start; this single line shows live
|
||||
progress ("Running 1 shell command…") and flips to the past tense
|
||||
("Ran 1 shell command") once every tool finishes. Clicking the line or
|
||||
pressing Ctrl+O expands the underlying tool rows (and their diffs).
|
||||
|
||||
Two modes:
|
||||
|
||||
- **live** (streaming): created empty, members added via `add_member` as
|
||||
they mount, a spinner timer animates the line and re-renders present/past
|
||||
tense, and failed tools are ejected back into view so errors stay visible.
|
||||
- **finalized** (`live=False`, used for hydration/resume): a fixed set of
|
||||
completed tools rendered straight to the past tense with no timer.
|
||||
|
||||
Purely presentational — never tracked by the message store; it is re-derived
|
||||
from the mounted tool widgets on each stream boundary and on hydration.
|
||||
"""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
ToolGroupSummary {
|
||||
height: auto;
|
||||
padding: 0 1;
|
||||
margin: 0 0 1 0;
|
||||
color: $text-muted;
|
||||
pointer: pointer;
|
||||
}
|
||||
|
||||
ToolGroupSummary:hover {
|
||||
color: $text;
|
||||
}
|
||||
"""
|
||||
|
||||
_SPINNER_INTERVAL: ClassVar[float] = 0.1
|
||||
|
||||
_collapsed: var[bool] = var(True)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
tools: list[ToolCallMessage] | None = None,
|
||||
collapsible: list[Widget] | None = None,
|
||||
*,
|
||||
live: bool = False,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Initialize the summary.
|
||||
|
||||
Args:
|
||||
tools: Tool widgets the summary aggregates (drives its text). May be
|
||||
empty for a live group that grows via `add_member`.
|
||||
collapsible: Every widget hidden/shown with the group, including the
|
||||
tool widgets and any interleaved diff previews.
|
||||
live: When True, animate progress and accept new members until
|
||||
`close`. When False, render a finalized past-tense summary.
|
||||
**kwargs: Additional arguments passed to `Static`.
|
||||
"""
|
||||
super().__init__("", **kwargs)
|
||||
self._tools = list(tools or [])
|
||||
self._collapsible = list(collapsible or [])
|
||||
self._finalized = not live
|
||||
self._spinner_pos = 0
|
||||
self._timer: Timer | None = None
|
||||
# Cached summary phrasing, rebuilt only when membership changes (not on
|
||||
# every spinner tick). None means "recompute on next render".
|
||||
self._present_text: str | None = None
|
||||
self._past_text: str | None = None
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Apply initial visibility, render, and arm the spinner if live."""
|
||||
self._apply_visibility()
|
||||
self._render_line()
|
||||
self._sync_timer()
|
||||
|
||||
def add_member(self, tool: ToolCallMessage, *extra: Widget) -> None:
|
||||
"""Add a tool (and any associated widgets) to a live group."""
|
||||
tool.add_class("-grouped")
|
||||
self._tools.append(tool)
|
||||
self._collapsible.append(tool)
|
||||
for widget in extra:
|
||||
widget.add_class("-grouped")
|
||||
self._collapsible.append(widget)
|
||||
self._present_text = self._past_text = None
|
||||
self._apply_visibility()
|
||||
self._render_line()
|
||||
self._sync_timer()
|
||||
|
||||
def add_collapsible(self, widget: Widget) -> None:
|
||||
"""Attach a non-tool widget (e.g. a diff) to be folded with the group."""
|
||||
widget.add_class("-grouped")
|
||||
self._collapsible.append(widget)
|
||||
if widget.is_attached:
|
||||
widget.display = not self._collapsed
|
||||
|
||||
def close(self) -> None:
|
||||
"""Mark the group complete; no further members will join."""
|
||||
self._finalized = True
|
||||
self._evict_failed()
|
||||
self._stop_timer()
|
||||
if not self.is_attached:
|
||||
return
|
||||
if self._tools:
|
||||
self._render_line()
|
||||
else:
|
||||
# Every tool failed and was ejected — nothing left to summarize.
|
||||
self.remove()
|
||||
|
||||
@property
|
||||
def has_attached_members(self) -> bool:
|
||||
"""Whether any collapsed widget is still attached to the DOM."""
|
||||
return any(widget.is_attached for widget in self._collapsible)
|
||||
|
||||
def toggle(self) -> None:
|
||||
"""Toggle between collapsed and expanded."""
|
||||
self._collapsed = not self._collapsed
|
||||
|
||||
def watch__collapsed(self, _collapsed: bool) -> None:
|
||||
"""Re-render and re-apply member visibility when the state changes.
|
||||
|
||||
Coalesced into one repaint so expanding a multi-tool group reveals every
|
||||
row at once instead of bouncing the transcript per member.
|
||||
"""
|
||||
if not self.is_attached:
|
||||
self._apply_visibility()
|
||||
self._render_line()
|
||||
return
|
||||
with self.app.batch_update():
|
||||
self._apply_visibility()
|
||||
self._render_line()
|
||||
|
||||
def on_click(self, event: Click) -> None:
|
||||
"""Toggle the group on click."""
|
||||
event.stop()
|
||||
self.toggle()
|
||||
|
||||
def _in_progress(self) -> bool:
|
||||
"""Whether any member tool is still pending or running.
|
||||
|
||||
Returns:
|
||||
True if at least one member tool has not finished.
|
||||
"""
|
||||
return any(tool.is_pending for tool in self._tools)
|
||||
|
||||
def _evict_failed(self) -> None:
|
||||
"""Un-fold errored/rejected/skipped tools so non-successes stay visible."""
|
||||
failed = [t for t in self._tools if t.is_failed]
|
||||
if not failed:
|
||||
return
|
||||
for tool in failed:
|
||||
self._tools.remove(tool)
|
||||
if tool in self._collapsible:
|
||||
self._collapsible.remove(tool)
|
||||
tool.remove_class("-grouped")
|
||||
if tool.is_attached:
|
||||
tool.display = True
|
||||
self._present_text = self._past_text = None
|
||||
|
||||
def _sync_timer(self) -> None:
|
||||
"""Run the spinner timer only while live members are in progress."""
|
||||
if not self._finalized and self._in_progress():
|
||||
if self._timer is None:
|
||||
self._timer = self.set_interval(self._SPINNER_INTERVAL, self._tick)
|
||||
else:
|
||||
self._stop_timer()
|
||||
|
||||
def _stop_timer(self) -> None:
|
||||
if self._timer is not None:
|
||||
self._timer.stop()
|
||||
self._timer = None
|
||||
|
||||
def _tick(self) -> None:
|
||||
"""Advance the spinner, eject failures, and flip to past tense when done."""
|
||||
try:
|
||||
self._spinner_pos += 1
|
||||
before = len(self._tools)
|
||||
self._evict_failed()
|
||||
evicted = len(self._tools) != before
|
||||
if self._collapsed:
|
||||
# Re-assert hidden state in case a member was shown externally
|
||||
# (e.g. ToolCallMessage.clear_awaiting_approval after HITL).
|
||||
self._apply_visibility()
|
||||
if not self._tools:
|
||||
self._stop_timer()
|
||||
if self.is_attached:
|
||||
self.remove()
|
||||
return
|
||||
in_progress = self._in_progress()
|
||||
if not in_progress:
|
||||
self._stop_timer()
|
||||
# A bare spinner advance keeps the line height; only relayout when
|
||||
# membership changed (eviction) or the line flips to past tense.
|
||||
self._render_line(
|
||||
in_progress=in_progress, layout=evicted or not in_progress
|
||||
)
|
||||
except Exception:
|
||||
# Fires ~10x/second, so an unhandled raise would propagate out of the
|
||||
# interval callback and can crash the app repeatedly. The group is
|
||||
# purely presentational; stop animating and log rather than take the
|
||||
# transcript down.
|
||||
logger.exception("ToolGroupSummary spinner tick failed; stopping timer")
|
||||
self._stop_timer()
|
||||
|
||||
def _apply_visibility(self) -> None:
|
||||
"""Show or hide every folded widget per the collapsed state."""
|
||||
visible = not self._collapsed
|
||||
for widget in self._collapsible:
|
||||
if widget.is_attached and widget.display != visible:
|
||||
widget.display = visible
|
||||
|
||||
def _render_line(
|
||||
self, *, in_progress: bool | None = None, layout: bool = True
|
||||
) -> None:
|
||||
"""Refresh the summary line for the current tense and collapsed state.
|
||||
|
||||
Args:
|
||||
in_progress: Pre-computed progress state to avoid re-scanning members
|
||||
on the spinner hot path; recomputed when omitted.
|
||||
layout: Whether the update may change the line's height. The spinner
|
||||
hot path passes False so a bare glyph swap doesn't relayout the
|
||||
whole transcript 10x/second.
|
||||
"""
|
||||
if not self.is_attached:
|
||||
return
|
||||
if not self._tools:
|
||||
self.update(Content(""), layout=layout)
|
||||
return
|
||||
glyphs = get_glyphs()
|
||||
if in_progress is None:
|
||||
in_progress = self._in_progress()
|
||||
if not self._finalized and in_progress:
|
||||
if self._present_text is None:
|
||||
self._present_text = summarize_tool_group(
|
||||
[tool.tool_name for tool in self._tools], tense="present"
|
||||
)
|
||||
frames = glyphs.spinner_frames
|
||||
spinner = frames[self._spinner_pos % len(frames)]
|
||||
self.update(
|
||||
Content(f"{spinner} {self._present_text}{glyphs.ellipsis}"),
|
||||
layout=layout,
|
||||
)
|
||||
else:
|
||||
mark = (
|
||||
glyphs.disclosure_collapsed
|
||||
if self._collapsed
|
||||
else glyphs.disclosure_expanded
|
||||
)
|
||||
if self._past_text is None:
|
||||
self._past_text = summarize_tool_group(
|
||||
[tool.tool_name for tool in self._tools], tense="past"
|
||||
)
|
||||
self.update(Content(f"{mark} {self._past_text}"), layout=layout)
|
||||
|
||||
|
||||
class DiffMessage(Static):
|
||||
"""Widget displaying a diff with syntax highlighting."""
|
||||
|
||||
|
||||
@@ -21,10 +21,12 @@ if TYPE_CHECKING:
|
||||
from collections.abc import Awaitable, Callable, Iterator
|
||||
|
||||
from langchain_core.messages import HumanMessage
|
||||
from textual.pilot import Pilot
|
||||
|
||||
from deepagents_code.mcp_auth import McpServerSpec
|
||||
from deepagents_code.notifications import PendingNotification
|
||||
from deepagents_code.sessions import ThreadInfo
|
||||
from deepagents_code.widgets.messages import ToolCallMessage
|
||||
|
||||
import pytest
|
||||
from textual import events
|
||||
@@ -3597,25 +3599,18 @@ class TestAskUserLifecycle:
|
||||
|
||||
def test_ctrl_o_falls_back_to_tool_with_expandable_args(self) -> None:
|
||||
"""When no ask_user is pending, Ctrl+O still expands an ask_user-like row."""
|
||||
from deepagents_code.widgets.messages import ToolCallMessage
|
||||
|
||||
app = DeepAgentsApp(agent=MagicMock())
|
||||
app._pending_ask_user_widget = None
|
||||
tool = MagicMock()
|
||||
tool = MagicMock(spec=ToolCallMessage)
|
||||
tool.has_class.return_value = False
|
||||
tool.has_output = False
|
||||
tool.has_expandable_args = True
|
||||
container = MagicMock()
|
||||
container.children = [tool]
|
||||
|
||||
def fake_query(query_type: object) -> list[object]:
|
||||
from deepagents_code.widgets.messages import (
|
||||
SkillMessage,
|
||||
ToolCallMessage,
|
||||
)
|
||||
|
||||
if query_type is ToolCallMessage:
|
||||
return [tool]
|
||||
if query_type is SkillMessage:
|
||||
return []
|
||||
return []
|
||||
|
||||
with patch.object(app, "query", side_effect=fake_query):
|
||||
with patch.object(app, "query_one", return_value=container):
|
||||
app.action_toggle_tool_output()
|
||||
|
||||
tool.toggle_args.assert_called_once_with()
|
||||
@@ -3628,61 +3623,97 @@ class TestAskUserLifecycle:
|
||||
which used to swallow the toggle and leave the collapsible code block
|
||||
stuck. The action must fall through to args in that case.
|
||||
"""
|
||||
from deepagents_code.widgets.messages import ToolCallMessage
|
||||
|
||||
app = DeepAgentsApp(agent=MagicMock())
|
||||
app._pending_ask_user_widget = None
|
||||
tool = MagicMock()
|
||||
tool = MagicMock(spec=ToolCallMessage)
|
||||
tool.has_class.return_value = False
|
||||
tool.has_output = True
|
||||
tool.has_expandable_output = False # short result, nothing to expand
|
||||
tool.has_expandable_args = True # multi-line code block
|
||||
container = MagicMock()
|
||||
container.children = [tool]
|
||||
|
||||
def fake_query(query_type: object) -> list[object]:
|
||||
from deepagents_code.widgets.messages import (
|
||||
SkillMessage,
|
||||
ToolCallMessage,
|
||||
)
|
||||
|
||||
if query_type is ToolCallMessage:
|
||||
return [tool]
|
||||
if query_type is SkillMessage:
|
||||
return []
|
||||
return []
|
||||
|
||||
with patch.object(app, "query", side_effect=fake_query):
|
||||
with patch.object(app, "query_one", return_value=container):
|
||||
app.action_toggle_tool_output()
|
||||
|
||||
tool.toggle_args.assert_called_once_with()
|
||||
tool.toggle_output.assert_not_called()
|
||||
|
||||
def test_ctrl_o_prefers_tool_with_output_over_expandable_args(self) -> None:
|
||||
"""Tool with real output wins over a later one with only expandable args."""
|
||||
def test_ctrl_o_prefers_more_recent_tool_in_dom_order(self) -> None:
|
||||
"""The newest tool row in DOM order wins over an older one."""
|
||||
from deepagents_code.widgets.messages import ToolCallMessage
|
||||
|
||||
app = DeepAgentsApp(agent=MagicMock())
|
||||
app._pending_ask_user_widget = None
|
||||
older = MagicMock()
|
||||
older = MagicMock(spec=ToolCallMessage)
|
||||
older.has_class.return_value = False
|
||||
older.has_output = True
|
||||
older.has_expandable_args = False
|
||||
newer = MagicMock()
|
||||
newer = MagicMock(spec=ToolCallMessage)
|
||||
newer.has_class.return_value = False
|
||||
newer.has_output = False
|
||||
newer.has_expandable_args = True
|
||||
container = MagicMock()
|
||||
container.children = [older, newer]
|
||||
|
||||
def fake_query(query_type: object) -> list[object]:
|
||||
from deepagents_code.widgets.messages import (
|
||||
SkillMessage,
|
||||
ToolCallMessage,
|
||||
)
|
||||
|
||||
if query_type is ToolCallMessage:
|
||||
return [older, newer]
|
||||
if query_type is SkillMessage:
|
||||
return []
|
||||
return []
|
||||
|
||||
with patch.object(app, "query", side_effect=fake_query):
|
||||
with patch.object(app, "query_one", return_value=container):
|
||||
app.action_toggle_tool_output()
|
||||
|
||||
# Iterates in reverse, so newer (expandable args) is hit first.
|
||||
# Walks children in reverse, so the newer row is hit first.
|
||||
newer.toggle_args.assert_called_once_with()
|
||||
older.toggle_output.assert_not_called()
|
||||
|
||||
def test_ctrl_o_targets_content_mounted_after_a_group(self) -> None:
|
||||
"""Content mounted after a tool group stays reachable from Ctrl+O.
|
||||
|
||||
Regression: the handler used to toggle the last tool group whenever any
|
||||
existed, leaving newer collapsible content (here a skill body)
|
||||
unreachable.
|
||||
"""
|
||||
from deepagents_code.widgets.messages import SkillMessage, ToolGroupSummary
|
||||
|
||||
app = DeepAgentsApp(agent=MagicMock())
|
||||
app._pending_ask_user_widget = None
|
||||
group = MagicMock(spec=ToolGroupSummary)
|
||||
skill = MagicMock(spec=SkillMessage)
|
||||
skill._stripped_body = "body"
|
||||
container = MagicMock()
|
||||
container.children = [group, skill] # skill mounted after the group
|
||||
|
||||
with patch.object(app, "query_one", return_value=container):
|
||||
app.action_toggle_tool_output()
|
||||
|
||||
skill.toggle_body.assert_called_once_with()
|
||||
group.toggle.assert_not_called()
|
||||
|
||||
def test_ctrl_o_toggles_group_and_skips_its_folded_rows(self) -> None:
|
||||
"""Toggling a group skips its folded rows and leaves older content alone."""
|
||||
from deepagents_code.widgets.messages import (
|
||||
SkillMessage,
|
||||
ToolCallMessage,
|
||||
ToolGroupSummary,
|
||||
)
|
||||
|
||||
app = DeepAgentsApp(agent=MagicMock())
|
||||
app._pending_ask_user_widget = None
|
||||
skill = MagicMock(spec=SkillMessage)
|
||||
skill._stripped_body = "body"
|
||||
group = MagicMock(spec=ToolGroupSummary)
|
||||
folded = MagicMock(spec=ToolCallMessage)
|
||||
folded.has_class.return_value = True # folded into the group
|
||||
# DOM: older skill, then the group summary followed by its folded row.
|
||||
container = MagicMock()
|
||||
container.children = [skill, group, folded]
|
||||
|
||||
with patch.object(app, "query_one", return_value=container):
|
||||
app.action_toggle_tool_output()
|
||||
|
||||
group.toggle.assert_called_once_with()
|
||||
folded.toggle_output.assert_not_called()
|
||||
skill.toggle_body.assert_not_called()
|
||||
|
||||
async def test_request_ask_user_timeout_cleans_old_widget(self) -> None:
|
||||
"""Timeout cleanup should cancel then remove the previous widget."""
|
||||
app = DeepAgentsApp()
|
||||
@@ -3856,6 +3887,34 @@ class TestLoadingSpinnerLifecycle:
|
||||
children = list(messages.children)
|
||||
assert children[-1] is widget
|
||||
|
||||
async def test_spinner_stays_pinned_and_singular_as_messages_mount(self) -> None:
|
||||
"""The spinner is reused and stays last as new messages stream in.
|
||||
|
||||
New content mounts above the spinner (via `_mount_before_queued`), so it
|
||||
never needs repositioning and exactly one spinner exists — the stability
|
||||
that replaced the per-tool hide/show flicker.
|
||||
"""
|
||||
from deepagents_code.widgets.loading import LoadingWidget
|
||||
|
||||
app = DeepAgentsApp()
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
await app._set_spinner("Thinking")
|
||||
await pilot.pause()
|
||||
widget = app._loading_widget
|
||||
assert widget is not None
|
||||
|
||||
messages = app.query_one("#messages", Container)
|
||||
for i in range(3):
|
||||
await app._mount_message(AppMessage(f"line {i}"))
|
||||
await app._set_spinner("Thinking") # status update each "step"
|
||||
await pilot.pause()
|
||||
|
||||
# Same instance the whole time; still exactly one; still last.
|
||||
assert app._loading_widget is widget
|
||||
assert len(list(app.query(LoadingWidget))) == 1
|
||||
assert list(messages.children)[-1] is widget
|
||||
|
||||
|
||||
class TestTraceCommand:
|
||||
"""Test /trace slash command."""
|
||||
@@ -17114,6 +17173,7 @@ class TestSetSpinnerTerminalProgress:
|
||||
await app._set_spinner("Thinking")
|
||||
await app._set_spinner(None)
|
||||
await app._set_spinner("Thinking")
|
||||
await app._set_spinner(None)
|
||||
await pilot.pause()
|
||||
|
||||
assert calls.count("set") >= 3
|
||||
@@ -21078,3 +21138,286 @@ class TestCopyFocusedInputText:
|
||||
assert app._copy_focused_input_text() is False
|
||||
|
||||
copy_mock.assert_not_called()
|
||||
|
||||
|
||||
class TestToolGroupCollapse:
|
||||
"""Integration tests for auto-collapsing completed tool runs."""
|
||||
|
||||
@staticmethod
|
||||
async def _mount_tools(
|
||||
pilot: Pilot[DeepAgentsApp],
|
||||
container: Container,
|
||||
specs: list[tuple[str, str, dict[str, Any], str]],
|
||||
) -> list[ToolCallMessage]:
|
||||
"""Mount tool widgets and apply their terminal status.
|
||||
|
||||
Each spec is `(id, tool_name, args, status)` where status is
|
||||
`"success"` or `"error"`.
|
||||
"""
|
||||
from deepagents_code.widgets.messages import ToolCallMessage
|
||||
|
||||
tools: list[ToolCallMessage] = []
|
||||
for tid, name, args, _status in specs:
|
||||
tool = ToolCallMessage(name, args)
|
||||
tool.id = tid
|
||||
await container.mount(tool)
|
||||
tools.append(tool)
|
||||
await pilot.pause()
|
||||
for tool, (_tid, _name, _args, status) in zip(tools, specs, strict=True):
|
||||
if status == "success":
|
||||
tool.set_success("output")
|
||||
else:
|
||||
tool.set_error("boom")
|
||||
await pilot.pause()
|
||||
return tools
|
||||
|
||||
async def test_regroup_collapses_success_run(self) -> None:
|
||||
"""A run of successful tools folds into one collapsed summary."""
|
||||
from deepagents_code.widgets.messages import ToolGroupSummary
|
||||
|
||||
app = DeepAgentsApp(agent=MagicMock(), thread_id="t-group")
|
||||
app._load_thread_history = AsyncMock() # ty: ignore
|
||||
async with app.run_test() as pilot:
|
||||
messages = app.query_one("#messages", Container)
|
||||
await messages.remove_children()
|
||||
tools = await self._mount_tools(
|
||||
pilot,
|
||||
messages,
|
||||
[
|
||||
("g1", "read_file", {"file_path": "a.py"}, "success"),
|
||||
("g2", "execute", {"command": "ls"}, "success"),
|
||||
],
|
||||
)
|
||||
|
||||
await app._regroup_completed_tools()
|
||||
await pilot.pause()
|
||||
|
||||
summaries = list(app.query(ToolGroupSummary))
|
||||
assert len(summaries) == 1
|
||||
assert all(tool.display is False for tool in tools)
|
||||
rendered = summaries[0].render()
|
||||
assert isinstance(rendered, Content)
|
||||
assert "Read 1 file, ran 1 shell command" in rendered.plain
|
||||
|
||||
async def test_regroup_treats_timestamp_footer_as_transparent(self) -> None:
|
||||
"""A timestamp footer between two tools does not split the run.
|
||||
|
||||
Production mounts a footer after every message, so a completed run
|
||||
reaches regroup as (tool, footer, tool). The footer must be transparent
|
||||
to grouping or every timestamped run would fragment into single-tool
|
||||
summaries. `_mount_tools` mounts tools with no footers, so this shape is
|
||||
otherwise never exercised.
|
||||
"""
|
||||
from deepagents_code.widgets.message_store import MessageData, MessageType
|
||||
from deepagents_code.widgets.messages import (
|
||||
ToolCallMessage,
|
||||
ToolGroupSummary,
|
||||
)
|
||||
|
||||
app = DeepAgentsApp(agent=MagicMock(), thread_id="t-footer")
|
||||
app._load_thread_history = AsyncMock() # ty: ignore
|
||||
async with app.run_test() as pilot:
|
||||
messages = app.query_one("#messages", Container)
|
||||
await messages.remove_children()
|
||||
|
||||
t1 = ToolCallMessage("read_file", {"file_path": "a.py"})
|
||||
t1.id = "f1"
|
||||
t2 = ToolCallMessage("read_file", {"file_path": "b.py"})
|
||||
t2.id = "f2"
|
||||
# A USER footer is the simplest to build; only its footer CSS class
|
||||
# matters to the transparency branch under test.
|
||||
footer = app._build_message_timestamp_footer(
|
||||
MessageData(
|
||||
type=MessageType.USER,
|
||||
content="",
|
||||
id="f1",
|
||||
timestamp=1_704_110_405.0,
|
||||
),
|
||||
visible=True,
|
||||
)
|
||||
assert footer is not None
|
||||
await messages.mount(t1)
|
||||
await messages.mount(footer)
|
||||
await messages.mount(t2)
|
||||
await pilot.pause()
|
||||
t1.set_success("ok")
|
||||
t2.set_success("ok")
|
||||
await pilot.pause()
|
||||
|
||||
await app._regroup_completed_tools()
|
||||
await pilot.pause()
|
||||
|
||||
# Both tools fold into one summary despite the intervening footer.
|
||||
summaries = list(app.query(ToolGroupSummary))
|
||||
assert len(summaries) == 1
|
||||
assert t1.display is False
|
||||
assert t2.display is False
|
||||
rendered = summaries[0].render()
|
||||
assert isinstance(rendered, Content)
|
||||
assert "Read 2 files" in rendered.plain
|
||||
|
||||
async def test_regroup_is_idempotent(self) -> None:
|
||||
"""Re-running regroup does not create duplicate summaries."""
|
||||
from deepagents_code.widgets.messages import ToolGroupSummary
|
||||
|
||||
app = DeepAgentsApp(agent=MagicMock(), thread_id="t-idem")
|
||||
app._load_thread_history = AsyncMock() # ty: ignore
|
||||
async with app.run_test() as pilot:
|
||||
messages = app.query_one("#messages", Container)
|
||||
await messages.remove_children()
|
||||
await self._mount_tools(
|
||||
pilot,
|
||||
messages,
|
||||
[("g1", "grep", {"pattern": "x"}, "success")],
|
||||
)
|
||||
|
||||
await app._regroup_completed_tools()
|
||||
await app._regroup_completed_tools()
|
||||
await pilot.pause()
|
||||
|
||||
assert len(list(app.query(ToolGroupSummary))) == 1
|
||||
|
||||
async def test_errored_tool_stays_visible(self) -> None:
|
||||
"""An errored tool stays visible; only the successful prefix collapses."""
|
||||
from deepagents_code.widgets.messages import ToolGroupSummary
|
||||
|
||||
app = DeepAgentsApp(agent=MagicMock(), thread_id="t-err")
|
||||
app._load_thread_history = AsyncMock() # ty: ignore
|
||||
async with app.run_test() as pilot:
|
||||
messages = app.query_one("#messages", Container)
|
||||
await messages.remove_children()
|
||||
ok_tool, err_tool = await self._mount_tools(
|
||||
pilot,
|
||||
messages,
|
||||
[
|
||||
("g1", "read_file", {"file_path": "a.py"}, "success"),
|
||||
("g2", "execute", {"command": "ls"}, "error"),
|
||||
],
|
||||
)
|
||||
|
||||
await app._regroup_completed_tools()
|
||||
await pilot.pause()
|
||||
|
||||
# The success prefix folds; the errored tool is never grouped.
|
||||
assert len(list(app.query(ToolGroupSummary))) == 1
|
||||
assert ok_tool.display is False
|
||||
assert err_tool.display is True
|
||||
assert not err_tool.has_class("-grouped")
|
||||
|
||||
async def test_assistant_message_boundary_triggers_collapse(self) -> None:
|
||||
"""Mounting a non-tool message folds the preceding tool run."""
|
||||
from deepagents_code.widgets.messages import ToolGroupSummary
|
||||
|
||||
app = DeepAgentsApp(agent=MagicMock(), thread_id="t-boundary")
|
||||
app._load_thread_history = AsyncMock() # ty: ignore
|
||||
async with app.run_test() as pilot:
|
||||
messages = app.query_one("#messages", Container)
|
||||
await messages.remove_children()
|
||||
tools = await self._mount_tools(
|
||||
pilot,
|
||||
messages,
|
||||
[("g1", "read_file", {"file_path": "a.py"}, "success")],
|
||||
)
|
||||
|
||||
await app._mount_message(AssistantMessage("next step"))
|
||||
await pilot.pause()
|
||||
|
||||
assert len(list(app.query(ToolGroupSummary))) == 1
|
||||
assert tools[0].display is False
|
||||
|
||||
async def test_separate_steps_get_separate_summaries(self) -> None:
|
||||
"""Tools split by an assistant message form two independent groups."""
|
||||
from deepagents_code.widgets.messages import ToolGroupSummary
|
||||
|
||||
app = DeepAgentsApp(agent=MagicMock(), thread_id="t-steps")
|
||||
app._load_thread_history = AsyncMock() # ty: ignore
|
||||
async with app.run_test() as pilot:
|
||||
messages = app.query_one("#messages", Container)
|
||||
await messages.remove_children()
|
||||
await self._mount_tools(
|
||||
pilot, messages, [("a1", "read_file", {"file_path": "a"}, "success")]
|
||||
)
|
||||
await messages.mount(AssistantMessage("step two"))
|
||||
await self._mount_tools(
|
||||
pilot, messages, [("b1", "execute", {"command": "ls"}, "success")]
|
||||
)
|
||||
|
||||
await app._regroup_completed_tools()
|
||||
await pilot.pause()
|
||||
|
||||
assert len(list(app.query(ToolGroupSummary))) == 2
|
||||
|
||||
async def test_mount_tool_creates_collapsed_live_group(self) -> None:
|
||||
"""Mounting a tool via _mount_message folds it immediately, no flash."""
|
||||
from deepagents_code.widgets.messages import (
|
||||
ToolCallMessage,
|
||||
ToolGroupSummary,
|
||||
)
|
||||
|
||||
app = DeepAgentsApp(agent=MagicMock(), thread_id="t-live")
|
||||
app._load_thread_history = AsyncMock() # ty: ignore
|
||||
async with app.run_test() as pilot:
|
||||
messages = app.query_one("#messages", Container)
|
||||
await messages.remove_children()
|
||||
|
||||
tool = ToolCallMessage("read_file", {"file_path": "a.py"})
|
||||
# _mount_message folds it synchronously; don't pilot.pause() while
|
||||
# the live spinner timer runs (it blocks the idle wait).
|
||||
await app._mount_message(tool)
|
||||
|
||||
# A live group was opened and the tool hidden from the start.
|
||||
summaries = list(app.query(ToolGroupSummary))
|
||||
assert len(summaries) == 1
|
||||
assert tool.display is False
|
||||
assert app._active_tool_group is summaries[0]
|
||||
|
||||
# A boundary closes the group and flips it to past tense.
|
||||
tool.set_success("done")
|
||||
await app._mount_message(AssistantMessage("done"))
|
||||
|
||||
assert app._active_tool_group is None
|
||||
rendered = summaries[0].render()
|
||||
assert isinstance(rendered, Content)
|
||||
assert "Read 1 file" in rendered.plain
|
||||
assert tool.display is False
|
||||
await pilot.pause()
|
||||
|
||||
async def test_group_survives_idle_after_completion(self) -> None:
|
||||
"""A folded group stays mounted across completion, idle, and a boundary.
|
||||
|
||||
Regression guard: the summary's finalized flag must not collide with
|
||||
Textual's MessagePump internals, or the widget is silently pruned on the
|
||||
next idle tick (looked like the group "disappearing" when tools finish).
|
||||
"""
|
||||
from deepagents_code.widgets.messages import (
|
||||
ToolCallMessage,
|
||||
ToolGroupSummary,
|
||||
)
|
||||
|
||||
app = DeepAgentsApp(agent=MagicMock(), thread_id="t-idle")
|
||||
app._load_thread_history = AsyncMock() # ty: ignore
|
||||
async with app.run_test() as pilot:
|
||||
messages = app.query_one("#messages", Container)
|
||||
await messages.remove_children()
|
||||
|
||||
t1 = ToolCallMessage("execute", {"command": "ls"})
|
||||
t2 = ToolCallMessage("read_file", {"file_path": "a.py"})
|
||||
await app._mount_message(t1)
|
||||
await app._mount_message(t2)
|
||||
group = app._active_tool_group
|
||||
assert group is not None
|
||||
|
||||
t1.set_success("ok")
|
||||
t2.set_success("ok")
|
||||
group._tick() # flips to past tense, stops the spinner timer
|
||||
await pilot.pause()
|
||||
assert group.is_attached # survives the idle tick after completion
|
||||
|
||||
await app._mount_message(AssistantMessage("next step"))
|
||||
await pilot.pause()
|
||||
summaries = list(app.query(ToolGroupSummary))
|
||||
assert len(summaries) == 1
|
||||
assert summaries[0].is_attached
|
||||
rendered = summaries[0].render()
|
||||
assert isinstance(rendered, Content)
|
||||
assert "Ran 1 shell command, read 1 file" in rendered.plain
|
||||
|
||||
@@ -3351,3 +3351,277 @@ class TestUserMessageCancelled:
|
||||
msg.set_cancelled()
|
||||
await pilot.pause()
|
||||
assert msg.has_class("-cancelled")
|
||||
|
||||
|
||||
class TestSummarizeToolGroup:
|
||||
"""Tests for the tool-group summary phrasing."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("names", "expected"),
|
||||
[
|
||||
(["execute"], "Ran 1 shell command"),
|
||||
(
|
||||
["read_file", "read_file", "execute", "execute", "execute"],
|
||||
"Read 2 files, ran 3 shell commands",
|
||||
),
|
||||
(["grep"], "Searched for 1 pattern"),
|
||||
(["grep", "glob", "glob"], "Searched for 3 patterns"),
|
||||
(["read_file"], "Read 1 file"),
|
||||
(["web_search", "web_search"], "Searched the web 2 times"),
|
||||
(["web_search"], "Searched the web"),
|
||||
(["write_todos"], "Updated todos"),
|
||||
(["task", "task"], "Ran 2 agents"),
|
||||
(
|
||||
["edit_file", "write_file", "read_file"],
|
||||
"Edited 1 file, wrote 1 file, read 1 file",
|
||||
),
|
||||
(["mystery", "mystery"], "Ran 2 mystery calls"),
|
||||
],
|
||||
)
|
||||
def test_summary_phrasing(self, names: list[str], expected: str) -> None:
|
||||
"""The summary aggregates by category and lowercases trailing verbs."""
|
||||
from deepagents_code.widgets.messages import summarize_tool_group
|
||||
|
||||
assert summarize_tool_group(names) == expected
|
||||
|
||||
def test_empty_group_has_fallback(self) -> None:
|
||||
"""An empty tool list yields a generic fallback rather than crashing."""
|
||||
from deepagents_code.widgets.messages import summarize_tool_group
|
||||
|
||||
assert summarize_tool_group([]) == "Ran tools"
|
||||
|
||||
|
||||
class _ToolGroupApp(App[None]):
|
||||
"""Minimal app mounting two completed tools plus a group summary."""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
from deepagents_code.widgets.messages import ToolGroupSummary
|
||||
|
||||
t1 = ToolCallMessage("read_file", {"file_path": "a.py"})
|
||||
t1.id = "t1"
|
||||
t2 = ToolCallMessage("execute", {"command": "ls"})
|
||||
t2.id = "t2"
|
||||
summary = ToolGroupSummary(tools=[t1, t2], collapsible=[t1, t2])
|
||||
summary.id = "summary"
|
||||
yield summary
|
||||
yield t1
|
||||
yield t2
|
||||
|
||||
|
||||
class TestToolGroupSummary:
|
||||
"""Runtime collapse/expand behavior for the group summary widget."""
|
||||
|
||||
async def test_collapsed_hides_members_and_renders_summary(self) -> None:
|
||||
"""On mount the summary collapses its members and shows the count line."""
|
||||
from deepagents_code.widgets.messages import ToolGroupSummary
|
||||
|
||||
async with _ToolGroupApp().run_test() as pilot:
|
||||
summary = pilot.app.query_one("#summary", ToolGroupSummary)
|
||||
t1 = pilot.app.query_one("#t1", ToolCallMessage)
|
||||
t2 = pilot.app.query_one("#t2", ToolCallMessage)
|
||||
|
||||
assert summary._collapsed is True
|
||||
assert t1.display is False
|
||||
assert t2.display is False
|
||||
rendered = summary.render()
|
||||
assert isinstance(rendered, Content)
|
||||
assert "Read 1 file, ran 1 shell command" in rendered.plain
|
||||
|
||||
async def test_toggle_expands_and_recollapses_members(self) -> None:
|
||||
"""Toggling flips member visibility and the disclosure glyph."""
|
||||
from deepagents_code.widgets.messages import ToolGroupSummary
|
||||
|
||||
async with _ToolGroupApp().run_test() as pilot:
|
||||
summary = pilot.app.query_one("#summary", ToolGroupSummary)
|
||||
t1 = pilot.app.query_one("#t1", ToolCallMessage)
|
||||
t2 = pilot.app.query_one("#t2", ToolCallMessage)
|
||||
|
||||
summary.toggle()
|
||||
await pilot.pause()
|
||||
assert summary._collapsed is False
|
||||
assert t1.display is True
|
||||
assert t2.display is True
|
||||
|
||||
summary.toggle()
|
||||
await pilot.pause()
|
||||
assert summary._collapsed is True
|
||||
assert t1.display is False
|
||||
assert t2.display is False
|
||||
|
||||
async def test_has_attached_members_tracks_removal(self) -> None:
|
||||
"""`has_attached_members` flips to False once members are removed."""
|
||||
from deepagents_code.widgets.messages import ToolGroupSummary
|
||||
|
||||
async with _ToolGroupApp().run_test() as pilot:
|
||||
summary = pilot.app.query_one("#summary", ToolGroupSummary)
|
||||
assert summary.has_attached_members is True
|
||||
|
||||
await pilot.app.query_one("#t1", ToolCallMessage).remove()
|
||||
await pilot.app.query_one("#t2", ToolCallMessage).remove()
|
||||
await pilot.pause()
|
||||
assert summary.has_attached_members is False
|
||||
|
||||
|
||||
class TestSummarizeToolGroupPresentTense:
|
||||
"""Present-tense phrasing used while a step's tools are still running."""
|
||||
|
||||
def test_present_tense(self) -> None:
|
||||
from deepagents_code.widgets.messages import summarize_tool_group
|
||||
|
||||
assert (
|
||||
summarize_tool_group(["execute"], tense="present")
|
||||
== "Running 1 shell command"
|
||||
)
|
||||
assert (
|
||||
summarize_tool_group(["read_file", "read_file", "grep"], tense="present")
|
||||
== "Reading 2 files, searching for 1 pattern"
|
||||
)
|
||||
|
||||
|
||||
class _LiveToolGroupApp(App[None]):
|
||||
"""Minimal app with an empty live group and two tools to add to it."""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
from deepagents_code.widgets.messages import ToolGroupSummary
|
||||
|
||||
summary = ToolGroupSummary(live=True)
|
||||
summary.id = "summary"
|
||||
t1 = ToolCallMessage("execute", {"command": "ls"})
|
||||
t1.id = "t1"
|
||||
t2 = ToolCallMessage("read_file", {"file_path": "a.py"})
|
||||
t2.id = "t2"
|
||||
yield summary
|
||||
yield t1
|
||||
yield t2
|
||||
|
||||
|
||||
class TestLiveToolGroupSummary:
|
||||
"""Eager/live group: collapsed from the start, running -> ran transition."""
|
||||
|
||||
async def test_present_tense_while_running_then_past_on_close(self) -> None:
|
||||
from deepagents_code.widgets.messages import ToolGroupSummary
|
||||
|
||||
async with _LiveToolGroupApp().run_test() as pilot:
|
||||
summary = pilot.app.query_one("#summary", ToolGroupSummary)
|
||||
t1 = pilot.app.query_one("#t1", ToolCallMessage)
|
||||
|
||||
# add_member renders synchronously; avoid pilot.pause() while the
|
||||
# live spinner timer is running (it keeps the app from going idle).
|
||||
summary.add_member(t1)
|
||||
rendered = summary.render()
|
||||
assert isinstance(rendered, Content)
|
||||
assert "Running 1 shell command" in rendered.plain
|
||||
assert t1.display is False # collapsed from the start
|
||||
|
||||
t1.set_success("done")
|
||||
summary.close() # stops the spinner timer, flips to past tense
|
||||
|
||||
rendered = summary.render()
|
||||
assert isinstance(rendered, Content)
|
||||
assert "Ran 1 shell command" in rendered.plain
|
||||
assert t1.display is False
|
||||
# Survives the idle tick after close — guards against the summary's
|
||||
# state attributes colliding with Textual's MessagePump internals
|
||||
# (e.g. `_closed`), which would silently prune the widget.
|
||||
await pilot.pause()
|
||||
assert summary.is_attached
|
||||
assert bool(pilot.app.query(ToolGroupSummary))
|
||||
|
||||
async def test_failed_member_is_evicted_on_close(self) -> None:
|
||||
from deepagents_code.widgets.messages import ToolGroupSummary
|
||||
|
||||
async with _LiveToolGroupApp().run_test() as pilot:
|
||||
summary = pilot.app.query_one("#summary", ToolGroupSummary)
|
||||
t1 = pilot.app.query_one("#t1", ToolCallMessage)
|
||||
t2 = pilot.app.query_one("#t2", ToolCallMessage)
|
||||
|
||||
summary.add_member(t1)
|
||||
summary.add_member(t2)
|
||||
t1.set_error("boom")
|
||||
t2.set_success("ok")
|
||||
summary.close()
|
||||
await pilot.pause()
|
||||
|
||||
# The errored tool is un-folded; the successful one stays collapsed.
|
||||
assert t1.display is True
|
||||
assert not t1.has_class("-grouped")
|
||||
assert t2.display is False
|
||||
rendered = summary.render()
|
||||
assert isinstance(rendered, Content)
|
||||
assert "Read 1 file" in rendered.plain
|
||||
|
||||
async def test_rejected_member_is_evicted_on_close(self) -> None:
|
||||
"""A rejected tool stays visible, mirroring the errored-tool path."""
|
||||
from deepagents_code.widgets.messages import ToolGroupSummary
|
||||
|
||||
async with _LiveToolGroupApp().run_test() as pilot:
|
||||
summary = pilot.app.query_one("#summary", ToolGroupSummary)
|
||||
t1 = pilot.app.query_one("#t1", ToolCallMessage)
|
||||
t2 = pilot.app.query_one("#t2", ToolCallMessage)
|
||||
|
||||
summary.add_member(t1)
|
||||
summary.add_member(t2)
|
||||
t1.set_rejected(reason="not now")
|
||||
t2.set_success("ok")
|
||||
summary.close()
|
||||
await pilot.pause()
|
||||
|
||||
assert t1.display is True
|
||||
assert not t1.has_class("-grouped")
|
||||
assert t2.display is False
|
||||
rendered = summary.render()
|
||||
assert isinstance(rendered, Content)
|
||||
assert "Read 1 file" in rendered.plain
|
||||
|
||||
async def test_skipped_member_is_evicted_and_uncounted_on_close(self) -> None:
|
||||
"""A skipped tool stays visible and is left out of the summary count.
|
||||
|
||||
Regression: `skipped` once fell through `is_success`/`is_failed`/
|
||||
`is_pending`, so a skipped tool stayed folded and inflated the count
|
||||
(e.g. "Ran 1 shell command" for a command that never executed).
|
||||
"""
|
||||
from deepagents_code.widgets.messages import ToolGroupSummary
|
||||
|
||||
async with _LiveToolGroupApp().run_test() as pilot:
|
||||
summary = pilot.app.query_one("#summary", ToolGroupSummary)
|
||||
t1 = pilot.app.query_one("#t1", ToolCallMessage) # execute
|
||||
t2 = pilot.app.query_one("#t2", ToolCallMessage) # read_file
|
||||
|
||||
summary.add_member(t1)
|
||||
summary.add_member(t2)
|
||||
t1.set_skipped()
|
||||
t2.set_success("ok")
|
||||
summary.close()
|
||||
await pilot.pause()
|
||||
|
||||
# The skipped tool is un-folded and no longer part of the group.
|
||||
assert t1.display is True
|
||||
assert not t1.has_class("-grouped")
|
||||
assert t2.display is False
|
||||
rendered = summary.render()
|
||||
assert isinstance(rendered, Content)
|
||||
assert "Read 1 file" in rendered.plain
|
||||
# The skipped execute must not be summarized as if it had run.
|
||||
assert "shell command" not in rendered.plain
|
||||
|
||||
async def test_all_failed_members_remove_summary_on_close(self) -> None:
|
||||
"""When every member fails, the empty summary removes itself."""
|
||||
from deepagents_code.widgets.messages import ToolGroupSummary
|
||||
|
||||
async with _LiveToolGroupApp().run_test() as pilot:
|
||||
summary = pilot.app.query_one("#summary", ToolGroupSummary)
|
||||
t1 = pilot.app.query_one("#t1", ToolCallMessage)
|
||||
t2 = pilot.app.query_one("#t2", ToolCallMessage)
|
||||
|
||||
summary.add_member(t1)
|
||||
summary.add_member(t2)
|
||||
t1.set_error("boom")
|
||||
t2.set_rejected(reason="no")
|
||||
summary.close()
|
||||
await pilot.pause()
|
||||
|
||||
# Nothing left to summarize: the summary detaches, both tools show.
|
||||
assert not summary.is_attached
|
||||
assert not pilot.app.query(ToolGroupSummary)
|
||||
assert t1.display is True
|
||||
assert t2.display is True
|
||||
|
||||
@@ -1979,8 +1979,8 @@ def _text_message(text: str) -> SimpleNamespace:
|
||||
class TestExecuteTaskTextualParallelToolSpinner:
|
||||
"""Regression tests for #1796: premature spinner with parallel tools."""
|
||||
|
||||
async def test_spinner_not_shown_until_all_parallel_tools_complete(self) -> None:
|
||||
"""With two parallel tools, Thinking appears only at start and after last."""
|
||||
async def test_spinner_stays_up_across_parallel_tools(self) -> None:
|
||||
"""With two parallel tools, the spinner stays "Thinking" and is never hidden."""
|
||||
statuses: list[str | None] = []
|
||||
|
||||
async def record_spinner(status: str | None) -> None:
|
||||
@@ -2041,11 +2041,9 @@ class TestExecuteTaskTextualParallelToolSpinner:
|
||||
)
|
||||
|
||||
assert statuses[0] == "Thinking"
|
||||
thinking_count = sum(1 for s in statuses if s == "Thinking")
|
||||
assert thinking_count == 2, (
|
||||
"Expected exactly 2 Thinking calls (start + after last tool); "
|
||||
f"got {thinking_count}: {statuses}"
|
||||
)
|
||||
assert statuses[-1] == "Thinking"
|
||||
# Stable turn-level indicator: never hidden while parallel tools run.
|
||||
assert None not in statuses
|
||||
|
||||
async def test_on_tool_complete_fires_per_tool_message(self) -> None:
|
||||
"""`on_tool_complete` should fire once per `ToolMessage`, even in parallel."""
|
||||
@@ -2228,12 +2226,12 @@ class TestExecuteTaskTextualParallelToolSpinner:
|
||||
tool_msg = adapter._current_tool_messages["tool-1"]
|
||||
assert tool_msg._status == "running"
|
||||
|
||||
async def test_edit_file_does_not_get_per_tool_spinner_at_mount(self) -> None:
|
||||
"""`edit_file` relies on the global Thinking spinner, not a per-tool one.
|
||||
async def test_edit_file_marks_running_at_mount(self) -> None:
|
||||
"""All tool rows are marked running at mount, including `edit_file`.
|
||||
|
||||
Negative counterpart to the auto-executed case: tools in
|
||||
`_TOOL_CALLS_KEEP_THINKING_SPINNER` must NOT be flipped to "running" at
|
||||
mount, or they would show a duplicate spinner alongside "Thinking".
|
||||
The row is hidden inside its collapsed group, so "running" drives the
|
||||
group's live progress state rather than showing a duplicate spinner
|
||||
alongside the global "Thinking" indicator.
|
||||
"""
|
||||
chunks = [
|
||||
(
|
||||
@@ -2269,10 +2267,10 @@ class TestExecuteTaskTextualParallelToolSpinner:
|
||||
)
|
||||
|
||||
tool_msg = adapter._current_tool_messages["tool-1"]
|
||||
assert tool_msg._status != "running"
|
||||
assert tool_msg._status == "running"
|
||||
|
||||
async def test_spinner_with_three_parallel_tools_out_of_order(self) -> None:
|
||||
"""Three parallel tools completed out of order; Thinking after all."""
|
||||
"""Three parallel tools complete out of order; spinner stays up throughout."""
|
||||
statuses: list[str | None] = []
|
||||
|
||||
async def record_spinner(status: str | None) -> None:
|
||||
@@ -2335,11 +2333,9 @@ class TestExecuteTaskTextualParallelToolSpinner:
|
||||
adapter=adapter,
|
||||
)
|
||||
|
||||
thinking_count = sum(1 for s in statuses if s == "Thinking")
|
||||
assert thinking_count == 2, (
|
||||
"Expected exactly 2 Thinking calls (start + after last tool); "
|
||||
f"got {thinking_count}: {statuses}"
|
||||
)
|
||||
assert statuses[-1] == "Thinking"
|
||||
# Stable turn-level indicator: never hidden while parallel tools run.
|
||||
assert None not in statuses
|
||||
|
||||
async def test_spinner_recovers_with_untracked_tool_id(self) -> None:
|
||||
"""Spinner still shows Thinking with an untracked tool_call_id."""
|
||||
@@ -2444,24 +2440,14 @@ class TestExecuteTaskTextualTextThenToolSpinner:
|
||||
adapter=adapter,
|
||||
)
|
||||
|
||||
# Expected sequence:
|
||||
# 1. "Thinking" before astream
|
||||
# 2. "Thinking" after mounting the streaming AssistantMessage
|
||||
# (re-anchor the spinner below the message so the user still
|
||||
# sees activity if the model pauses before the tool call)
|
||||
# 3. None when the tool call mounts
|
||||
# 4. "Thinking" after the tool result
|
||||
# The spinner is a stable turn-level indicator: it shows "Thinking"
|
||||
# before the stream, stays up while text streams and while the tool
|
||||
# runs (the tool's own progress shows in its collapsed group row), and
|
||||
# is never hidden mid-turn — so it no longer flickers off for each tool.
|
||||
assert statuses[0] == "Thinking"
|
||||
assert statuses[1] == "Thinking"
|
||||
assert None in statuses
|
||||
assert statuses[-1] == "Thinking"
|
||||
|
||||
# The spinner must never be hidden before the tool call arrives.
|
||||
first_none = statuses.index(None)
|
||||
text_thinking_seen = statuses[:first_none].count("Thinking") >= 2
|
||||
assert text_thinking_seen, (
|
||||
f"Spinner was hidden during text streaming before tool call: {statuses}"
|
||||
)
|
||||
assert None not in statuses, f"Spinner was hidden mid-turn: {statuses}"
|
||||
assert all(s == "Thinking" for s in statuses)
|
||||
|
||||
async def test_spinner_reanchors_for_text_after_tool_cycle(self) -> None:
|
||||
"""Text -> tool_call -> tool_result -> text must re-anchor the spinner.
|
||||
@@ -2516,14 +2502,13 @@ class TestExecuteTaskTextualTextThenToolSpinner:
|
||||
f"each text mount; got {thinking_count}: {statuses}"
|
||||
)
|
||||
|
||||
async def test_spinner_reanchor_skipped_while_tools_pending(self) -> None:
|
||||
"""The re-anchor must be gated on `not _current_tool_messages`.
|
||||
async def test_spinner_stays_up_when_text_arrives_mid_tool(self) -> None:
|
||||
"""A text chunk arriving while a tool is in flight keeps the spinner up.
|
||||
|
||||
Contrived sequence: a tool call mounts (populating
|
||||
`_current_tool_messages`), then a text chunk arrives before the tool
|
||||
result. The new re-anchor logic must NOT call `_set_spinner("Thinking")`
|
||||
in that window — the tool-call widget is the dominant progress
|
||||
indicator.
|
||||
result. The spinner stays "Thinking" throughout and is never hidden —
|
||||
the text re-anchor stays gated on `not _current_tool_messages`.
|
||||
"""
|
||||
statuses: list[str | None] = []
|
||||
|
||||
@@ -2557,15 +2542,11 @@ class TestExecuteTaskTextualTextThenToolSpinner:
|
||||
adapter=adapter,
|
||||
)
|
||||
|
||||
# Thinking calls should be:
|
||||
# 1. Before astream
|
||||
# 2. After tool result (guard is back to empty)
|
||||
# The re-anchor must NOT fire while the tool is in flight.
|
||||
thinking_count = sum(1 for s in statuses if s == "Thinking")
|
||||
assert thinking_count == 2, (
|
||||
f"Expected 2 Thinking calls (start + after tool); got "
|
||||
f"{thinking_count}: {statuses}"
|
||||
)
|
||||
# The spinner stays "Thinking" and is never hidden while the tool is in
|
||||
# flight; the text re-anchor stays gated on no pending tools.
|
||||
assert statuses[0] == "Thinking"
|
||||
assert statuses[-1] == "Thinking"
|
||||
assert None not in statuses
|
||||
|
||||
|
||||
class TestExecuteTaskTextualRubricRevisionStreaming:
|
||||
|
||||
Reference in New Issue
Block a user