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:
Mason Daugherty
2026-07-14 00:09:17 -04:00
committed by GitHub
parent 2089b54e2a
commit 19538e0a46
7 changed files with 115 additions and 22 deletions
+1
View File
@@ -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.
+1
View File
@@ -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.