From 276326dc7e78af29fd635feb9d227ecd7f80b256 Mon Sep 17 00:00:00 2001 From: Johannes du Plessis <51395795+johannes117@users.noreply.github.com> Date: Mon, 13 Jul 2026 20:37:44 +0000 Subject: [PATCH 1/3] fix(code): keep TODO and edit tools expanded Co-authored-by: open-swe[bot] --- libs/code/deepagents_code/app.py | 8 +++-- libs/code/tests/unit_tests/test_app.py | 47 ++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/libs/code/deepagents_code/app.py b/libs/code/deepagents_code/app.py index 2e7da00a5f..76fafe74c5 100644 --- a/libs/code/deepagents_code/app.py +++ b/libs/code/deepagents_code/app.py @@ -160,6 +160,9 @@ def _parse_rubric_max_iterations(raw: str) -> tuple[int | None, str | None]: deadlock detector when two threads cold-import overlapping modules. """ +_TOOL_GROUP_EXCLUSIONS = frozenset({"ask_user", "edit_file", "write_todos"}) +"""Tools that remain standalone instead of collapsing into step summaries.""" + _MESSAGE_TIMESTAMP_FOOTER_CLASS = "message-timestamp-footer" """CSS class applied to individual message timestamp footer widgets.""" @@ -12072,7 +12075,8 @@ async def _mount_message( # 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" + isinstance(widget, ToolCallMessage) + and widget.tool_name not in _TOOL_GROUP_EXCLUSIONS ) is_diff = isinstance(widget, DiffMessage) @@ -12282,7 +12286,7 @@ async def flush() -> None: 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") ) diff --git a/libs/code/tests/unit_tests/test_app.py b/libs/code/tests/unit_tests/test_app.py index e29703029d..2c9e662456 100644 --- a/libs/code/tests/unit_tests/test_app.py +++ b/libs/code/tests/unit_tests/test_app.py @@ -24268,6 +24268,35 @@ async def test_regroup_collapses_success_run(self) -> None: assert isinstance(rendered, Content) assert "Read 1 file, ran 1 shell command" in rendered.plain + @pytest.mark.parametrize("tool_name", ["edit_file", "write_todos"]) + async def test_regroup_leaves_excluded_tools_expanded(self, tool_name: str) -> None: + """TODO and edit 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_treats_timestamp_footer_as_transparent(self) -> None: """A timestamp footer between two tools does not split the run. @@ -24451,6 +24480,24 @@ async def test_mount_tool_creates_collapsed_live_group(self) -> None: assert tool.display is False await pilot.pause() + @pytest.mark.parametrize("tool_name", ["edit_file", "write_todos"]) + async def test_mount_leaves_excluded_tools_expanded(self, tool_name: str) -> None: + """TODO and edit 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_group_survives_idle_after_completion(self) -> None: """A folded group stays mounted across completion, idle, and a boundary. From 3e5a56af26c7060de38657b3617ef55bc106e6dc Mon Sep 17 00:00:00 2001 From: Mason Daugherty Date: Mon, 13 Jul 2026 18:44:50 -0400 Subject: [PATCH 2/3] cr --- libs/code/deepagents_code/app.py | 31 +++-- .../deepagents_code/tui/textual_adapter.py | 6 +- .../tui/widgets/message_store.py | 5 + .../deepagents_code/tui/widgets/messages.py | 11 +- libs/code/tests/unit_tests/test_app.py | 120 +++++++++++++++++- .../test_transcript_virtualization.py | 2 +- .../tui/widgets/test_message_store.py | 9 +- 7 files changed, 168 insertions(+), 16 deletions(-) diff --git a/libs/code/deepagents_code/app.py b/libs/code/deepagents_code/app.py index d10de93646..6d517870a5 100644 --- a/libs/code/deepagents_code/app.py +++ b/libs/code/deepagents_code/app.py @@ -161,7 +161,14 @@ def _parse_rubric_max_iterations(raw: str) -> tuple[int | None, str | None]: """ _TOOL_GROUP_EXCLUSIONS = frozenset({"ask_user", "edit_file", "write_todos"}) -"""Tools that remain standalone instead of collapsing into step summaries.""" +"""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.""" @@ -12131,13 +12138,16 @@ async def _mount_message( # 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 not in _TOOL_GROUP_EXCLUSIONS ) - is_diff = isinstance(widget, DiffMessage) + is_groupable_diff = ( + isinstance(widget, DiffMessage) + and widget._tool_name not in _TOOL_GROUP_EXCLUSIONS + ) # Store message data for virtualization message_data = MessageData.from_widget(widget) @@ -12154,7 +12164,7 @@ async def _mount_message( # 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. @@ -12180,7 +12190,7 @@ async def _mount_message( ): 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) @@ -12358,8 +12368,13 @@ async def flush() -> None: 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. diff --git a/libs/code/deepagents_code/tui/textual_adapter.py b/libs/code/deepagents_code/tui/textual_adapter.py index a5d792da53..9b27c17712 100644 --- a/libs/code/deepagents_code/tui/textual_adapter.py +++ b/libs/code/deepagents_code/tui/textual_adapter.py @@ -1114,7 +1114,11 @@ def _notify_user_visible_output_started() -> None: 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 diff --git a/libs/code/deepagents_code/tui/widgets/message_store.py b/libs/code/deepagents_code/tui/widgets/message_store.py index f167add3ba..1c6e313104 100644 --- a/libs/code/deepagents_code/tui/widgets/message_store.py +++ b/libs/code/deepagents_code/tui/widgets/message_store.py @@ -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 @@ def to_widget(self) -> Widget: return DiffMessage( self.content, file_path=self.diff_file_path or "", + tool_name=self.diff_tool_name, id=self.id, ) @@ -384,6 +388,7 @@ def from_widget(cls, widget: Widget) -> MessageData: content=widget._diff_content, id=widget_id, diff_file_path=widget._file_path, + diff_tool_name=widget._tool_name, ) if isinstance(widget, SummarizationMessage): diff --git a/libs/code/deepagents_code/tui/widgets/messages.py b/libs/code/deepagents_code/tui/widgets/messages.py index 31927bcece..895a864afa 100644 --- a/libs/code/deepagents_code/tui/widgets/messages.py +++ b/libs/code/deepagents_code/tui/widgets/messages.py @@ -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. diff --git a/libs/code/tests/unit_tests/test_app.py b/libs/code/tests/unit_tests/test_app.py index 2c9e662456..945934d54e 100644 --- a/libs/code/tests/unit_tests/test_app.py +++ b/libs/code/tests/unit_tests/test_app.py @@ -24268,9 +24268,9 @@ async def test_regroup_collapses_success_run(self) -> None: assert isinstance(rendered, Content) assert "Read 1 file, ran 1 shell command" in rendered.plain - @pytest.mark.parametrize("tool_name", ["edit_file", "write_todos"]) + @pytest.mark.parametrize("tool_name", ["ask_user", "edit_file", "write_todos"]) async def test_regroup_leaves_excluded_tools_expanded(self, tool_name: str) -> None: - """TODO and edit tools stay visible and split adjacent tool groups.""" + """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") @@ -24297,6 +24297,63 @@ async def test_regroup_leaves_excluded_tools_expanded(self, tool_name: str) -> N 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. @@ -24480,9 +24537,9 @@ async def test_mount_tool_creates_collapsed_live_group(self) -> None: assert tool.display is False await pilot.pause() - @pytest.mark.parametrize("tool_name", ["edit_file", "write_todos"]) + @pytest.mark.parametrize("tool_name", ["ask_user", "edit_file", "write_todos"]) async def test_mount_leaves_excluded_tools_expanded(self, tool_name: str) -> None: - """TODO and edit tools mount standalone instead of opening a live group.""" + """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") @@ -24498,6 +24555,61 @@ async def test_mount_leaves_excluded_tools_expanded(self, tool_name: str) -> Non 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. diff --git a/libs/code/tests/unit_tests/test_transcript_virtualization.py b/libs/code/tests/unit_tests/test_transcript_virtualization.py index cd5eb98b7f..deec2a41d8 100644 --- a/libs/code/tests/unit_tests/test_transcript_virtualization.py +++ b/libs/code/tests/unit_tests/test_transcript_virtualization.py @@ -200,7 +200,7 @@ async def test_scroll_down_hydrates_tail_below( # 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) messages = app.query_one("#messages", Container) await app._prune_messages_below_window(messages) await pilot.pause() diff --git a/libs/code/tests/unit_tests/tui/widgets/test_message_store.py b/libs/code/tests/unit_tests/tui/widgets/test_message_store.py index ab5a6cd5b0..d15fdf6cd6 100644 --- a/libs/code/tests/unit_tests/tui/widgets/test_message_store.py +++ b/libs/code/tests/unit_tests/tui/widgets/test_message_store.py @@ -178,13 +178,19 @@ def test_app_message_markdown_roundtrip(self): 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 @@ def test_diff_message_roundtrip(self): 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): From 97245b6722e9d6c362e052ebe228ed6fe77d2cf2 Mon Sep 17 00:00:00 2001 From: Mason Daugherty Date: Mon, 13 Jul 2026 19:12:19 -0400 Subject: [PATCH 3/3] cr --- libs/code/tests/unit_tests/test_app.py | 1 + libs/code/tests/unit_tests/test_transcript_virtualization.py | 1 + 2 files changed, 2 insertions(+) diff --git a/libs/code/tests/unit_tests/test_app.py b/libs/code/tests/unit_tests/test_app.py index 945934d54e..17583bcf05 100644 --- a/libs/code/tests/unit_tests/test_app.py +++ b/libs/code/tests/unit_tests/test_app.py @@ -9001,6 +9001,7 @@ async def test_footers_render_for_hydrated_messages_above( 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) diff --git a/libs/code/tests/unit_tests/test_transcript_virtualization.py b/libs/code/tests/unit_tests/test_transcript_virtualization.py index deec2a41d8..b1b1debaaf 100644 --- a/libs/code/tests/unit_tests/test_transcript_virtualization.py +++ b/libs/code/tests/unit_tests/test_transcript_virtualization.py @@ -201,6 +201,7 @@ async def test_scroll_down_hydrates_tail_below( # 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", 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()