mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-22 01:35:28 -04:00
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:
committed by
GitHub
parent
55b60ca08d
commit
1d549d3f3c
@@ -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.
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user