fix(code): keep TODO and edit tools expanded (#4704)

TODO updates and file edits now remain expanded in the dcode TUI.

---

Keep `write_todos` and `edit_file` calls outside collapsed tool-group
summaries so their user-facing details remain visible in live and
restored transcripts.

Made by [Open
SWE](https://openswe.vercel.app/agents/019f5d2e-6138-71e1-91e5-95ba16811dad)

---------

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:
Johannes du Plessis
2026-07-13 18:52:00 -07:00
committed by GitHub
parent 55b60ca08d
commit 1d549d3f3c
7 changed files with 218 additions and 13 deletions
+28 -9
View File
@@ -160,6 +160,16 @@ single thread imports it re-entrantly, but can trip CPython's per-module import
deadlock detector when two threads cold-import overlapping modules.
"""
_TOOL_GROUP_EXCLUSIONS = frozenset({"ask_user", "edit_file", "write_todos"})
"""Tools that stay expanded instead of collapsing into step summaries.
Each surfaces user-facing content worth keeping visible on its own an
interactive prompt (`ask_user`), a diff (`edit_file`), or a todo list
(`write_todos`) so it renders standalone and acts as a boundary between
adjacent tool groups. Add a tool here only when its collapsed one-line
summary would hide something the user needs to see.
"""
_MESSAGE_TIMESTAMP_FOOTER_CLASS = "message-timestamp-footer"
"""CSS class applied to individual message timestamp footer widgets."""
@@ -12128,12 +12138,16 @@ class DeepAgentsApp(App):
# 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.
# step's group; a diff from a groupable tool 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"
isinstance(widget, ToolCallMessage)
and widget.tool_name not in _TOOL_GROUP_EXCLUSIONS
)
is_groupable_diff = (
isinstance(widget, DiffMessage)
and widget._tool_name not in _TOOL_GROUP_EXCLUSIONS
)
is_diff = isinstance(widget, DiffMessage)
# Store message data for virtualization
message_data = MessageData.from_widget(widget)
@@ -12150,7 +12164,7 @@ class DeepAgentsApp(App):
# 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):
if not (is_groupable_tool or is_groupable_diff):
self._close_active_tool_group()
# Re-derive groups for any tools mounted outside this path
# (resumed history), which carry no live group.
@@ -12176,7 +12190,7 @@ class DeepAgentsApp(App):
):
if is_groupable_tool:
self._active_tool_group.add_member(widget)
elif is_diff:
elif is_groupable_diff:
self._active_tool_group.add_collapsible(widget)
self._schedule_message_height_measurement(message_data.id)
@@ -12341,7 +12355,7 @@ class DeepAgentsApp(App):
continue # footers are transparent to grouping
if isinstance(child, ToolCallMessage):
groupable = (
child.tool_name != "ask_user"
child.tool_name not in _TOOL_GROUP_EXCLUSIONS
and child.is_success
and not child.has_class("-grouped")
)
@@ -12354,8 +12368,13 @@ class DeepAgentsApp(App):
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:
# A diff belongs to the tool above it and never starts a
# run: normally it folds into the open run, but a diff from
# an excluded tool (e.g. edit_file) stays standalone and
# ends the run so the edit and its diff remain visible.
if child._tool_name in _TOOL_GROUP_EXCLUSIONS:
await flush()
elif run_anchor is not None:
run_collapsible.append(child)
continue
# Assistant text, notices, an existing summary, etc. end the run.
@@ -1114,7 +1114,11 @@ async def execute_task_textual(
pending_text_by_namespace[ns_key] = ""
if record.diff:
await adapter._mount_message(
DiffMessage(record.diff, record.display_path)
DiffMessage(
record.diff,
record.display_path,
tool_name=record.tool_name,
)
)
# Reshow spinner only when all in-flight tools have
@@ -159,6 +159,9 @@ class MessageData:
diff_file_path: str | None = None
"""File path associated with the diff (DIFF messages only)."""
diff_tool_name: str | None = None
"""Name of the file tool that produced the diff (DIFF messages only)."""
# SKILL message fields - only populated for SKILL messages
skill_name: str | None = None
"""Name of the skill that was invoked."""
@@ -280,6 +283,7 @@ class MessageData:
return DiffMessage(
self.content,
file_path=self.diff_file_path or "",
tool_name=self.diff_tool_name,
id=self.id,
)
@@ -384,6 +388,7 @@ class MessageData:
content=widget._diff_content,
id=widget_id,
diff_file_path=widget._file_path,
diff_tool_name=widget._tool_name,
)
if isinstance(widget, SummarizationMessage):
@@ -3232,17 +3232,26 @@ class DiffMessage(Static):
"""
"""Diff syntax coloring per theme: additions, removals, muted context."""
def __init__(self, diff_content: str, file_path: str = "", **kwargs: Any) -> None:
def __init__(
self,
diff_content: str,
file_path: str = "",
*,
tool_name: str | None = None,
**kwargs: Any,
) -> None:
"""Initialize a diff message.
Args:
diff_content: The unified diff content
file_path: Path to the file being modified
tool_name: Name of the file tool that produced the diff
**kwargs: Additional arguments passed to parent
"""
super().__init__(**kwargs)
self._diff_content = diff_content
self._file_path = file_path
self._tool_name = tool_name
def compose(self) -> ComposeResult:
"""Compose the diff message layout.
+160
View File
@@ -9001,6 +9001,7 @@ class TestMessageTimestampFooters:
async with app.run_test() as pilot:
await pilot.pause()
monkeypatch.setattr(app, "_check_hydration_below_needed", lambda: None)
# Shrink the window so a small load archives messages above the
# visible range, mirroring a long thread scrolled to the bottom.
monkeypatch.setattr(app._message_store, "WINDOW_SIZE", 2)
@@ -24272,6 +24273,92 @@ class TestToolGroupCollapse:
assert isinstance(rendered, Content)
assert "Read 1 file, ran 1 shell command" in rendered.plain
@pytest.mark.parametrize("tool_name", ["ask_user", "edit_file", "write_todos"])
async def test_regroup_leaves_excluded_tools_expanded(self, tool_name: str) -> None:
"""Excluded tools stay visible and split adjacent tool groups."""
from deepagents_code.tui.widgets.messages import ToolGroupSummary
app = DeepAgentsApp(agent=MagicMock(), thread_id="t-excluded-history")
app._load_thread_history = AsyncMock() # ty: ignore
async with app.run_test() as pilot:
messages = app.query_one("#messages", Container)
await messages.remove_children()
before, excluded, after = await self._mount_tools(
pilot,
messages,
[
("before", "read_file", {"file_path": "a.py"}, "success"),
("excluded", tool_name, {}, "success"),
("after", "execute", {"command": "ls"}, "success"),
],
)
await app._regroup_completed_tools()
await pilot.pause()
assert len(list(app.query(ToolGroupSummary))) == 2
assert before.display is False
assert excluded.display is True
assert not excluded.has_class("-grouped")
assert after.display is False
async def test_regroup_leaves_edit_diff_outside_later_tool_group(self) -> None:
"""An edit diff arriving after a parallel read stays expanded."""
from deepagents_code.tui.widgets.messages import DiffMessage, ToolGroupSummary
app = DeepAgentsApp(agent=MagicMock(), thread_id="t-edit-diff-history")
app._load_thread_history = AsyncMock() # ty: ignore
async with app.run_test() as pilot:
messages = app.query_one("#messages", Container)
await messages.remove_children()
edit, read = await self._mount_tools(
pilot,
messages,
[
("edit", "edit_file", {"file_path": "a.py"}, "success"),
("read", "read_file", {"file_path": "b.py"}, "success"),
],
)
diff = DiffMessage("-old\n+new", "a.py", tool_name="edit_file")
await messages.mount(diff)
await pilot.pause()
await app._regroup_completed_tools()
await pilot.pause()
assert len(list(app.query(ToolGroupSummary))) == 1
assert edit.display is True
assert read.display is False
assert diff.display is True
assert not diff.has_class("-grouped")
async def test_regroup_leaves_consecutive_excluded_tools_expanded(self) -> None:
"""Two adjacent excluded tools stay expanded with no summary between them."""
from deepagents_code.tui.widgets.messages import ToolGroupSummary
app = DeepAgentsApp(agent=MagicMock(), thread_id="t-excluded-adjacent")
app._load_thread_history = AsyncMock() # ty: ignore
async with app.run_test() as pilot:
messages = app.query_one("#messages", Container)
await messages.remove_children()
edit, todos = await self._mount_tools(
pilot,
messages,
[
("edit", "edit_file", {"file_path": "a.py"}, "success"),
("todos", "write_todos", {}, "success"),
],
)
await app._regroup_completed_tools()
await pilot.pause()
assert len(list(app.query(ToolGroupSummary))) == 0
assert edit.display is True
assert todos.display is True
assert not edit.has_class("-grouped")
assert not todos.has_class("-grouped")
async def test_regroup_treats_timestamp_footer_as_transparent(self) -> None:
"""A timestamp footer between two tools does not split the run.
@@ -24455,6 +24542,79 @@ class TestToolGroupCollapse:
assert tool.display is False
await pilot.pause()
@pytest.mark.parametrize("tool_name", ["ask_user", "edit_file", "write_todos"])
async def test_mount_leaves_excluded_tools_expanded(self, tool_name: str) -> None:
"""Excluded tools mount standalone instead of opening a live group."""
from deepagents_code.tui.widgets.messages import ToolCallMessage
app = DeepAgentsApp(agent=MagicMock(), thread_id="t-excluded-live")
app._load_thread_history = AsyncMock() # ty: ignore
async with app.run_test():
messages = app.query_one("#messages", Container)
await messages.remove_children()
tool = ToolCallMessage(tool_name, {})
await app._mount_message(tool)
assert app._active_tool_group is None
assert tool.display is True
assert not tool.has_class("-grouped")
async def test_mount_leaves_edit_diff_outside_parallel_read_group(self) -> None:
"""A late edit diff does not join the active parallel read group."""
from deepagents_code.tui.widgets.messages import DiffMessage, ToolGroupSummary
app = DeepAgentsApp(agent=MagicMock(), thread_id="t-edit-diff-live")
app._load_thread_history = AsyncMock() # ty: ignore
async with app.run_test():
messages = app.query_one("#messages", Container)
await messages.remove_children()
edit = ToolCallMessage("edit_file", {"file_path": "a.py"})
read = ToolCallMessage("read_file", {"file_path": "b.py"})
diff = DiffMessage("-old\n+new", "a.py", tool_name="edit_file")
await app._mount_message(edit)
await app._mount_message(read)
await app._mount_message(diff)
assert len(list(app.query(ToolGroupSummary))) == 1
assert edit.display is True
assert read.display is False
assert diff.display is True
assert not diff.has_class("-grouped")
assert app._active_tool_group is None
@pytest.mark.parametrize("tool_name", ["ask_user", "edit_file", "write_todos"])
async def test_mount_excluded_tool_closes_open_live_group(
self, tool_name: str
) -> None:
"""An excluded tool closes the live group; the next tool opens a new one."""
from deepagents_code.tui.widgets.messages import ToolCallMessage
app = DeepAgentsApp(agent=MagicMock(), thread_id="t-excluded-boundary")
app._load_thread_history = AsyncMock() # ty: ignore
async with app.run_test():
messages = app.query_one("#messages", Container)
await messages.remove_children()
first = ToolCallMessage("execute", {"command": "ls"})
excluded = ToolCallMessage(tool_name, {})
later = ToolCallMessage("read_file", {"file_path": "a.py"})
await app._mount_message(first)
opened = app._active_tool_group
assert opened is not None # groupable tool opened a live group
await app._mount_message(excluded)
assert app._active_tool_group is None # excluded tool closed it
assert excluded.display is True
assert not excluded.has_class("-grouped")
await app._mount_message(later)
# A fresh groupable tool opens a new group, not the closed one.
assert app._active_tool_group is not None
assert app._active_tool_group is not opened
async def test_group_survives_idle_after_completion(self) -> None:
"""A folded group stays mounted across completion, idle, and a boundary.
@@ -200,7 +200,8 @@ class TestScrollDrivenHydration:
# Archive the newest rows below the window (the state after the user
# has scrolled up and older history was mounted in their place).
monkeypatch.setattr(app._message_store, "WINDOW_SIZE", 3)
monkeypatch.setattr(app._message_store, "HYDRATE_BUFFER", 20)
monkeypatch.setattr(app._message_store, "HYDRATE_BUFFER", 2)
monkeypatch.setattr(app, "_check_hydration_needed", lambda: None)
messages = app.query_one("#messages", Container)
await app._prune_messages_below_window(messages)
await pilot.pause()
@@ -178,13 +178,19 @@ class TestMessageData:
def test_diff_message_roundtrip(self):
"""Test DiffMessage serialization and deserialization."""
diff_content = "--- a/file.py\n+++ b/file.py\n@@ -1 +1 @@\n-old\n+new"
original = DiffMessage(diff_content, file_path="src/file.py", id="test-diff-1")
original = DiffMessage(
diff_content,
file_path="src/file.py",
tool_name="edit_file",
id="test-diff-1",
)
# Serialize
data = MessageData.from_widget(original)
assert data.type == MessageType.DIFF
assert data.content == diff_content
assert data.diff_file_path == "src/file.py"
assert data.diff_tool_name == "edit_file"
assert data.id == "test-diff-1"
# Deserialize
@@ -192,6 +198,7 @@ class TestMessageData:
assert isinstance(restored, DiffMessage)
assert restored._diff_content == diff_content
assert restored._file_path == "src/file.py"
assert restored._tool_name == "edit_file"
assert restored.id == "test-diff-1"
def test_summarization_message_roundtrip(self):