Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 28 additions & 9 deletions libs/code/deepagents_code/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,16 @@ 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 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."""

Expand Down Expand Up @@ -12128,12 +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 != "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)
Expand All @@ -12150,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.
Expand All @@ -12176,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)
Expand Down Expand Up @@ -12341,7 +12355,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")
)
Expand All @@ -12354,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.
Expand Down
6 changes: 5 additions & 1 deletion libs/code/deepagents_code/tui/textual_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions libs/code/deepagents_code/tui/widgets/message_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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,
)

Expand Down Expand Up @@ -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):
Expand Down
11 changes: 10 additions & 1 deletion libs/code/deepagents_code/tui/widgets/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
160 changes: 160 additions & 0 deletions libs/code/tests/unit_tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -24272,6 +24273,92 @@ 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", ["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.

Expand Down Expand Up @@ -24455,6 +24542,79 @@ async def test_mount_tool_creates_collapsed_live_group(self) -> None:
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.

Expand Down
3 changes: 2 additions & 1 deletion libs/code/tests/unit_tests/test_transcript_virtualization.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,8 @@ 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)
monkeypatch.setattr(app, "_check_hydration_needed", lambda: None)
messages = app.query_one("#messages", Container)
await app._prune_messages_below_window(messages)
await pilot.pause()
Expand Down
9 changes: 8 additions & 1 deletion libs/code/tests/unit_tests/tui/widgets/test_message_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,20 +178,27 @@ 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
restored = data.to_widget()
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):
Expand Down