mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-22 09:45:24 -04:00
fix(code): show "Took <duration>" when task tool completes (#4638)
The completed `task` subagent tool widget now reports its elapsed run time (`Took <duration>`), matching `execute`. --- The completed `task` (subagent) tool widget never displayed a `Took <duration>` line the way `execute` does. On success, `task` fell through to `_show_success_status`, which hides the status row entirely when the tool produced output — so the elapsed run time was lost. This generalizes the timing branch in `set_success` to a `_TIMED_SUCCESS_TOOLS` set covering both `execute` and `task`, so long-running subagent dispatches now report how long they ran once the spinner stops. Made by [Open SWE](https://openswe.vercel.app/agents/e4c0f49c-071d-bd35-7a89-d4b1fdb67244) --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
@@ -12449,6 +12449,7 @@ class DeepAgentsApp(App):
|
||||
widget.id,
|
||||
tool_status=data.tool_status,
|
||||
tool_output=data.tool_output,
|
||||
tool_duration=data.tool_duration,
|
||||
tool_expanded=data.tool_expanded,
|
||||
tool_reject_reason=data.tool_reject_reason,
|
||||
)
|
||||
|
||||
@@ -41,6 +41,7 @@ _UPDATABLE_FIELDS: frozenset[str] = frozenset(
|
||||
"content",
|
||||
"tool_status",
|
||||
"tool_output",
|
||||
"tool_duration",
|
||||
"tool_expanded",
|
||||
"tool_reject_reason",
|
||||
"skill_expanded",
|
||||
@@ -148,6 +149,9 @@ class MessageData:
|
||||
tool_output: str | None = None
|
||||
"""Output returned by the tool after execution."""
|
||||
|
||||
tool_duration: float | None = None
|
||||
"""Elapsed run time in seconds for a completed timed tool call."""
|
||||
|
||||
tool_expanded: bool = False
|
||||
"""Whether the tool output section is expanded in the UI."""
|
||||
|
||||
@@ -254,6 +258,7 @@ class MessageData:
|
||||
# via _restore_deferred_state
|
||||
widget._deferred_status = self.tool_status
|
||||
widget._deferred_output = self.tool_output
|
||||
widget._deferred_duration = self.tool_duration
|
||||
widget._deferred_expanded = self.tool_expanded
|
||||
widget._deferred_reject_reason = self.tool_reject_reason
|
||||
return widget
|
||||
@@ -368,6 +373,7 @@ class MessageData:
|
||||
tool_args=widget._args,
|
||||
tool_status=tool_status,
|
||||
tool_output=widget._output,
|
||||
tool_duration=widget._duration,
|
||||
tool_expanded=widget._expanded,
|
||||
tool_reject_reason=widget._reject_reason,
|
||||
)
|
||||
|
||||
@@ -154,6 +154,16 @@ _COLLAPSE_OUTPUT_BY_DEFAULT: set[str] = {
|
||||
}
|
||||
|
||||
|
||||
# Long-running tools whose completed status row reports how long they ran
|
||||
# ("Took <duration>") when a run was timed, instead of being hidden. `execute`
|
||||
# shells and `task` subagent dispatches can both run for a while, so the elapsed
|
||||
# time is useful.
|
||||
_TIMED_SUCCESS_TOOLS: set[str] = {
|
||||
"execute",
|
||||
"task",
|
||||
}
|
||||
|
||||
|
||||
_SUCCESS_EXIT_RE = re.compile(r"\n?\[Command succeeded with exit code 0\]\s*$")
|
||||
"""Strip the SDK's `[Command succeeded with exit code 0]` trailer from tool output."""
|
||||
|
||||
@@ -1160,10 +1170,12 @@ class ToolCallMessage(Vertical):
|
||||
# Animation state
|
||||
self._spinner_position = 0
|
||||
self._start_time: float | None = None
|
||||
self._duration: float | None = None
|
||||
self._animation_timer: Timer | None = None
|
||||
# Deferred state for hydration (set by MessageData.to_widget)
|
||||
self._deferred_status: str | None = None
|
||||
self._deferred_output: str | None = None
|
||||
self._deferred_duration: float | None = None
|
||||
self._deferred_expanded: bool = False
|
||||
self._deferred_reject_reason: str | None = None
|
||||
# Whether the widget is currently hidden because an approval prompt
|
||||
@@ -1263,6 +1275,7 @@ class ToolCallMessage(Vertical):
|
||||
|
||||
status = self._deferred_status
|
||||
output = self._deferred_output or ""
|
||||
duration = self._deferred_duration
|
||||
self._expanded = self._deferred_expanded
|
||||
if self._deferred_reject_reason:
|
||||
self._reject_reason = self._deferred_reject_reason
|
||||
@@ -1270,6 +1283,7 @@ class ToolCallMessage(Vertical):
|
||||
# Clear deferred values
|
||||
self._deferred_status = None
|
||||
self._deferred_output = None
|
||||
self._deferred_duration = None
|
||||
self._deferred_expanded = False
|
||||
self._deferred_reject_reason = None
|
||||
|
||||
@@ -1279,7 +1293,11 @@ class ToolCallMessage(Vertical):
|
||||
case "success":
|
||||
self._status = "success"
|
||||
self._output = output
|
||||
self._show_success_status()
|
||||
self._duration = duration
|
||||
if self._tool_name in _TIMED_SUCCESS_TOOLS and duration is not None:
|
||||
self._show_timed_success_status(duration)
|
||||
else:
|
||||
self._show_success_status()
|
||||
self._update_output_display()
|
||||
case "error":
|
||||
self._status = "error"
|
||||
@@ -1332,6 +1350,7 @@ class ToolCallMessage(Vertical):
|
||||
return # Already running
|
||||
|
||||
self._status = "running"
|
||||
self._duration = None
|
||||
self._start_time = time()
|
||||
if self._status_widget:
|
||||
self._status_widget.add_class("pending")
|
||||
@@ -1385,9 +1404,10 @@ class ToolCallMessage(Vertical):
|
||||
def set_success(self, result: str = "") -> None:
|
||||
"""Mark the tool call as successful.
|
||||
|
||||
For `execute` calls that actually ran (a start time was recorded via
|
||||
`set_running`), the elapsed run time is shown in place of the usual
|
||||
success marker; every other tool routes through `_show_success_status`.
|
||||
For long-running tools (`execute`, `task`) that actually ran (a start
|
||||
time was recorded via `set_running`), the elapsed run time is shown via
|
||||
`_show_timed_success_status`; every other case routes through
|
||||
`_show_success_status`.
|
||||
|
||||
Args:
|
||||
result: Tool output/result to display
|
||||
@@ -1402,20 +1422,33 @@ class ToolCallMessage(Vertical):
|
||||
elapsed = time() - self._start_time if self._start_time is not None else None
|
||||
self._stop_animation()
|
||||
self._status = "success"
|
||||
self._duration = (
|
||||
elapsed
|
||||
if self._tool_name in _TIMED_SUCCESS_TOOLS and elapsed is not None
|
||||
else None
|
||||
)
|
||||
# Strip redundant success trailer — the UI already conveys success
|
||||
self._output = _strip_success_exit_line(result)
|
||||
if self._tool_name == "execute" and elapsed is not None and self._status_widget:
|
||||
self._status_widget.remove_class("pending")
|
||||
# `execute` calls can run for a while, so keep the row and report
|
||||
# how long the command took once the spinner stops.
|
||||
self._status_widget.update(
|
||||
Content.styled(f"Took {format_duration(elapsed)}", "dim")
|
||||
)
|
||||
self._status_widget.display = True
|
||||
if self._duration is not None:
|
||||
self._show_timed_success_status(self._duration)
|
||||
else:
|
||||
self._show_success_status()
|
||||
self._update_output_display()
|
||||
|
||||
def _show_timed_success_status(self, duration: float) -> None:
|
||||
"""Render the preserved duration for a completed timed tool call.
|
||||
|
||||
Args:
|
||||
duration: Elapsed tool run time in seconds.
|
||||
"""
|
||||
if self._status_widget is None:
|
||||
return
|
||||
self._status_widget.remove_class("pending")
|
||||
self._status_widget.update(
|
||||
Content.styled(f"Took {format_duration(duration)}", "dim")
|
||||
)
|
||||
self._status_widget.display = True
|
||||
|
||||
def _show_success_status(self) -> None:
|
||||
"""Render the status marker for a completed successful call.
|
||||
|
||||
|
||||
@@ -9187,6 +9187,7 @@ class TestMessageTimestampFooters:
|
||||
app._sync_tool_message_state(tool)
|
||||
assert stored.tool_status == ToolStatus.SUCCESS
|
||||
assert stored.tool_output == "done"
|
||||
assert stored.tool_duration is not None
|
||||
assert not app._message_store.is_protected("tool-sync")
|
||||
|
||||
async def test_transcript_mounts_stay_chronological_around_spinner(self) -> None:
|
||||
|
||||
@@ -11,6 +11,7 @@ of scrolling with a trackpad.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from textual import events
|
||||
@@ -178,10 +179,14 @@ class TestScrollDrivenHydration:
|
||||
chat.scroll_end(animate=False)
|
||||
await pilot.pause()
|
||||
chat.scroll_to(y=0, animate=False)
|
||||
for _ in range(30):
|
||||
await pilot.pause()
|
||||
if not app._message_store.has_messages_above:
|
||||
break
|
||||
|
||||
async def wait_for_head_hydration() -> None:
|
||||
while app._message_store.has_messages_above:
|
||||
await pilot.pause()
|
||||
chat.scroll_to(y=0, animate=False)
|
||||
|
||||
await asyncio.wait_for(wait_for_head_hydration(), timeout=5)
|
||||
await pilot.pause()
|
||||
|
||||
start_after, _end_after = app._message_store.get_visible_range()
|
||||
assert start_after == 0
|
||||
@@ -216,11 +221,14 @@ class TestScrollDrivenHydration:
|
||||
chat.scroll_to(y=0, animate=False)
|
||||
await pilot.pause()
|
||||
chat.scroll_end(animate=False)
|
||||
for _ in range(30):
|
||||
await pilot.pause()
|
||||
if not app._message_store.has_messages_below:
|
||||
break
|
||||
chat.scroll_end(animate=False)
|
||||
|
||||
async def wait_for_tail_hydration() -> None:
|
||||
while app._message_store.has_messages_below:
|
||||
await pilot.pause()
|
||||
chat.scroll_end(animate=False)
|
||||
|
||||
await asyncio.wait_for(wait_for_tail_hydration(), timeout=5)
|
||||
await pilot.pause()
|
||||
|
||||
_start_after, end_after = app._message_store.get_visible_range()
|
||||
assert end_after == app._message_store.total_count
|
||||
|
||||
@@ -222,6 +222,7 @@ class TestMessageData:
|
||||
assert data.id.startswith("msg-")
|
||||
assert data.timestamp > 0
|
||||
assert data.tool_name is None
|
||||
assert data.tool_duration is None
|
||||
assert data.is_streaming is False
|
||||
assert data.height_hint is None
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ from deepagents_code.tool_display import (
|
||||
EXECUTE_HEADER_MAX_LENGTH,
|
||||
JS_EVAL_HEADER_MAX_LENGTH,
|
||||
)
|
||||
from deepagents_code.tui.widgets.message_store import MessageData
|
||||
from deepagents_code.tui.widgets.messages import (
|
||||
AppMessage,
|
||||
AssistantMessage,
|
||||
@@ -722,7 +723,7 @@ class TestDiffMessageCredentialRedaction:
|
||||
|
||||
|
||||
class TestToolCallMessageDuration:
|
||||
"""Tests for the post-run duration shown on `execute` tool calls."""
|
||||
"""Tests for the post-run duration shown on long-running tool calls."""
|
||||
|
||||
async def test_execute_shows_took_after_success(self) -> None:
|
||||
"""`execute` keeps its status row and reports how long it ran."""
|
||||
@@ -762,6 +763,48 @@ class TestToolCallMessageDuration:
|
||||
assert isinstance(content, Content)
|
||||
assert content.plain == "Took 0.3s"
|
||||
|
||||
async def test_task_shows_took_after_success(self) -> None:
|
||||
"""`task` subagent calls keep their status row and report how long they ran."""
|
||||
app = _tool_msg_app("task", {"description": "investigate the bug"})
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
app.msg.set_running()
|
||||
app.msg._start_time -= 5 # ty: ignore
|
||||
app.msg.set_success("done")
|
||||
await pilot.pause()
|
||||
|
||||
status = app.msg._status_widget
|
||||
assert status is not None
|
||||
assert status.display is True
|
||||
content = status._Static__content # ty: ignore
|
||||
assert isinstance(content, Content)
|
||||
assert content.plain == "Took 5s"
|
||||
|
||||
async def test_task_took_duration_survives_rehydration(self) -> None:
|
||||
"""A virtualized task row restores its completed duration."""
|
||||
app = _tool_msg_app("task", {"description": "investigate the bug"})
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
app.msg.set_running()
|
||||
app.msg._start_time -= 5 # ty: ignore
|
||||
app.msg.set_success("done")
|
||||
data = MessageData.from_widget(app.msg)
|
||||
assert data.tool_duration == pytest.approx(5, abs=0.1)
|
||||
|
||||
restored = data.to_widget()
|
||||
assert isinstance(restored, ToolCallMessage)
|
||||
rehydrated_app = _tool_msg_app("task")
|
||||
rehydrated_app.msg = restored
|
||||
async with rehydrated_app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
|
||||
status = restored._status_widget
|
||||
assert status is not None
|
||||
assert status.display is True
|
||||
content = status._Static__content # ty: ignore
|
||||
assert isinstance(content, Content)
|
||||
assert content.plain == "Took 5s"
|
||||
|
||||
async def test_execute_without_run_falls_back_to_success_status(self) -> None:
|
||||
"""`execute` success with no recorded start time hides the row.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user