mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-21 17:25:26 -04:00
fix(code): preserve transcript order during virtualization (#4549)
Fixes long `dcode` conversations so transcript virtualization preserves message order, keeps the scroll position stable when loading older history, and avoids older or newer messages disappearing when scrolling. --- - Make `MessageStore` the canonical source for virtualized transcript geometry with height estimates, protected live rows, and spacer-backed scroll ranges. - Keep rendered transcript rows chronological around spinner/queued widgets by mounting all transcript content above the bottom spacer. - Sync live tool state back into `MessageStore` so hydrated rows preserve status, output, expansion, and rejection details. - Hydrate the hidden tail before appending fresh output, and only advance `_visible_end` on `append` when already at the tail, so new live output never skips messages hidden below the window. ## Correctness & robustness (review pass) - **Fix scroll-anchor double-count on hydrate-above.** The top spacer already shrinks by the hydrated rows' height, so `scroll_y` now stays put instead of jumping the viewport down by ~a screenful when scrolling up through history. - **Keep the mounted window contiguous on partial failure.** Both hydrate directions now mount from the window edge outward and stop at the first failure, so the count-based `mark_hydrated`/`mark_hydrated_below` can't desync `_visible_start`/`_visible_end` from the DOM. - **Fail safe when syncing tool state.** If a tool widget can't be serialized, its row stays protected (we can't prove it's terminal); an unknown/unmapped status no longer unprotects a still-live row. The adapter's tool-sync hook is now total (never raises), so a sync failure can't abort a turn. - **Reason-keyed protection.** `MessageStore` tracks protection reasons (active stream vs. live tool) independently, so releasing one source never revokes another's protection. - **Remove dead/speculative API.** Dropped `MessageWindow`, `get_window_for_viewport`, `prefix_height`, `total_estimated_height`, and `get_protected_messages` (unused by the feature); the live path uses `estimate_height`/`range_height`. - **Single clamped write path for `height_hint`** (`set_height_hint`), plus docstring/comment fixes for the now-populated height hints and protection semantics. --------- 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
fccf037321
commit
f6ee70c00a
@@ -170,6 +170,15 @@ message subtree (O(mounted widgets)), whereas flipping the leaf footers
|
||||
restyles only the footers.
|
||||
"""
|
||||
|
||||
_MESSAGE_SPACER_CLASS = "message-virtual-spacer"
|
||||
"""CSS class for transcript virtualization spacer rows."""
|
||||
|
||||
_MESSAGE_TOP_SPACER_ID = "message-top-spacer"
|
||||
"""DOM id for the spacer representing source messages above the mounted window."""
|
||||
|
||||
_MESSAGE_BOTTOM_SPACER_ID = "message-bottom-spacer"
|
||||
"""DOM id for the spacer representing source messages below the mounted window."""
|
||||
|
||||
_TIMESTAMP_FOOTER_EXCLUDED_TYPES: frozenset[MessageType] = frozenset(
|
||||
{MessageType.APP, MessageType.SUMMARIZATION}
|
||||
)
|
||||
@@ -335,10 +344,10 @@ if TYPE_CHECKING:
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from langgraph.pregel import Pregel
|
||||
from textual.app import ComposeResult
|
||||
from textual.events import MouseUp, Paste
|
||||
from textual.events import MouseUp, Paste, Resize
|
||||
from textual.geometry import Size
|
||||
from textual.layout import DockArrangeResult
|
||||
from textual.scrollbar import ScrollUp
|
||||
from textual.scrollbar import ScrollDown, ScrollUp
|
||||
from textual.timer import Timer
|
||||
from textual.widget import Widget
|
||||
from textual.worker import Worker
|
||||
@@ -2670,6 +2679,9 @@ class DeepAgentsApp(App):
|
||||
self._message_store = MessageStore()
|
||||
"""Message virtualization store."""
|
||||
|
||||
self._message_measure_width: int | None = None
|
||||
"""Chat width used for cached message height hints."""
|
||||
|
||||
self._deferred_actions: list[DeferredAction] = []
|
||||
"""Deferred actions executed after the current busy state resolves."""
|
||||
|
||||
@@ -2942,6 +2954,7 @@ class DeepAgentsApp(App):
|
||||
gc.freeze()
|
||||
|
||||
chat = self.query_one("#chat", VerticalScroll)
|
||||
self._message_measure_width = chat.size.width
|
||||
# Don't establish bottom-follow intent at startup. `_ChatScroll.anchor()`
|
||||
# defers the real anchor until content overflows, but not calling it at
|
||||
# all keeps the welcome banner pinned to the top of an empty chat (like
|
||||
@@ -3271,6 +3284,7 @@ class DeepAgentsApp(App):
|
||||
set_spinner=self._set_spinner,
|
||||
set_active_message=self._set_active_message,
|
||||
sync_message_content=self._sync_message_content,
|
||||
sync_tool_message=self._sync_tool_message_state,
|
||||
request_ask_user=self._request_ask_user,
|
||||
on_tool_complete=self._schedule_git_branch_refresh,
|
||||
on_subagent_event=self._on_subagent_event,
|
||||
@@ -5572,6 +5586,27 @@ class DeepAgentsApp(App):
|
||||
"""Handle scroll up to check if we need to hydrate older messages."""
|
||||
self._check_hydration_needed()
|
||||
|
||||
def on_scroll_down(self, _event: ScrollDown) -> None:
|
||||
"""Handle scroll down to hydrate newer messages below the window."""
|
||||
self._check_hydration_below_needed()
|
||||
|
||||
def on_resize(self, _event: Resize) -> None:
|
||||
"""Scale cached message heights when terminal width changes."""
|
||||
try:
|
||||
chat = self.query_one("#chat", VerticalScroll)
|
||||
except NoMatches:
|
||||
return
|
||||
width = chat.size.width
|
||||
previous = self._message_measure_width
|
||||
if previous is None or previous <= 0:
|
||||
self._message_measure_width = width
|
||||
return
|
||||
if width <= 0 or width == previous:
|
||||
return
|
||||
self._message_store.invalidate_height_hints(scale=previous / width)
|
||||
self._message_measure_width = width
|
||||
self._sync_transcript_spacers()
|
||||
|
||||
def _update_status(self, message: str) -> None:
|
||||
"""Update the status bar with a message."""
|
||||
if self._status_bar:
|
||||
@@ -5712,6 +5747,24 @@ class DeepAgentsApp(App):
|
||||
if self._message_store.should_hydrate_above(scroll_y, viewport_height):
|
||||
self.call_later(self._hydrate_messages_above)
|
||||
|
||||
def _check_hydration_below_needed(self) -> None:
|
||||
"""Check if newer messages should be mounted below the current window."""
|
||||
if not self._message_store.has_messages_below:
|
||||
return
|
||||
try:
|
||||
chat = self.query_one("#chat", VerticalScroll)
|
||||
except NoMatches:
|
||||
logger.debug("Skipping hydrate-below check: #chat container not found")
|
||||
return
|
||||
_start, end = self._message_store.get_visible_range()
|
||||
bottom_spacer_top = self._message_store.range_height(0, end)
|
||||
if self._message_store.should_hydrate_below(
|
||||
chat.scroll_y,
|
||||
chat.size.height,
|
||||
bottom_spacer_top,
|
||||
):
|
||||
self.call_later(self._hydrate_messages_below)
|
||||
|
||||
async def _hydrate_messages_above(self) -> None:
|
||||
"""Hydrate older messages when user scrolls near the top.
|
||||
|
||||
@@ -5732,75 +5785,119 @@ class DeepAgentsApp(App):
|
||||
except NoMatches:
|
||||
logger.debug("Skipping hydration: #messages not found")
|
||||
return
|
||||
await self._ensure_transcript_spacers(messages_container)
|
||||
|
||||
to_hydrate = self._message_store.get_messages_to_hydrate()
|
||||
if not to_hydrate:
|
||||
return
|
||||
|
||||
old_scroll_y = chat.scroll_y
|
||||
first_child = (
|
||||
messages_container.children[0] if messages_container.children else None
|
||||
)
|
||||
first_child = self._first_transcript_child(messages_container)
|
||||
|
||||
# Build widgets in chronological order, then mount in reverse so
|
||||
# each is inserted before the previous first_child, resulting in
|
||||
# correct chronological order in the DOM.
|
||||
# Mount from the window edge outward (newest archived first), each
|
||||
# inserted before the running `first_child` so the DOM stays
|
||||
# chronological. Stop at the first failure: `mark_hydrated` advances
|
||||
# `_visible_start` by a plain count, so the mounted rows must remain a
|
||||
# contiguous block adjacent to the window or the store desyncs from the
|
||||
# DOM.
|
||||
hydrated_count = 0
|
||||
hydrated_widgets: list[tuple] = [] # (widget, msg_data)
|
||||
for msg_data in to_hydrate:
|
||||
for msg_data in reversed(to_hydrate):
|
||||
try:
|
||||
widget = msg_data.to_widget()
|
||||
hydrated_widgets.append((widget, msg_data))
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to create widget for message %s",
|
||||
msg_data.id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
for widget, msg_data in reversed(hydrated_widgets):
|
||||
try:
|
||||
footer = self._build_message_timestamp_footer(
|
||||
msg_data, visible=self._message_timestamps_visible
|
||||
)
|
||||
if first_child:
|
||||
if footer is not None:
|
||||
await messages_container.mount(footer, before=first_child)
|
||||
await messages_container.mount(widget, before=footer)
|
||||
else:
|
||||
await messages_container.mount(widget, before=first_child)
|
||||
else:
|
||||
await messages_container.mount(widget)
|
||||
if footer is not None:
|
||||
await messages_container.mount(footer)
|
||||
nodes: list[Widget] = [widget]
|
||||
if footer is not None:
|
||||
nodes.append(footer)
|
||||
await self._mount_transcript_nodes(
|
||||
messages_container,
|
||||
nodes,
|
||||
before=first_child,
|
||||
)
|
||||
first_child = widget
|
||||
hydrated_count += 1
|
||||
self._schedule_message_height_measurement(msg_data.id)
|
||||
# Render Markdown content for hydrated assistant messages
|
||||
if isinstance(widget, AssistantMessage) and msg_data.content:
|
||||
await widget.set_content(msg_data.content)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to mount hydrated widget %s",
|
||||
widget.id,
|
||||
"Failed to hydrate message %s above window; stopping to "
|
||||
"keep the mounted window contiguous",
|
||||
msg_data.id,
|
||||
exc_info=True,
|
||||
)
|
||||
break
|
||||
|
||||
# Only update store for the number we actually mounted
|
||||
if hydrated_count > 0:
|
||||
self._message_store.mark_hydrated(hydrated_count)
|
||||
await self._prune_messages_below_window(messages_container)
|
||||
self._sync_transcript_spacers(messages_container)
|
||||
|
||||
# Adjust scroll position to maintain the user's view.
|
||||
# Widget heights aren't known until after layout, so we use a
|
||||
# heuristic. A more accurate approach would measure actual heights
|
||||
# via call_after_refresh.
|
||||
estimated_height_per_message = 5 # terminal rows, rough estimate
|
||||
added_height = hydrated_count * estimated_height_per_message
|
||||
chat.scroll_y = old_scroll_y + added_height
|
||||
# The top spacer already shrank by the hydrated rows' estimated height
|
||||
# (via `_sync_transcript_spacers` above) while real widgets filled the
|
||||
# freed space, so total content above the viewport is unchanged and the
|
||||
# anchor holds without adjusting scroll_y. (Mirrors _hydrate_below.)
|
||||
chat.scroll_y = old_scroll_y
|
||||
|
||||
# Collapse any completed tool runs brought in above the window so
|
||||
# hydrated history matches the live transcript.
|
||||
await self._regroup_completed_tools()
|
||||
|
||||
async def _hydrate_messages_below(self) -> None:
|
||||
"""Hydrate newer messages when scrolling down toward the tail."""
|
||||
if not self._message_store.has_messages_below:
|
||||
return
|
||||
try:
|
||||
chat = self.query_one("#chat", VerticalScroll)
|
||||
messages_container = self.query_one("#messages", Container)
|
||||
except NoMatches:
|
||||
logger.debug("Skipping hydrate below: chat/messages container not found")
|
||||
return
|
||||
await self._ensure_transcript_spacers(messages_container)
|
||||
|
||||
to_hydrate = self._message_store.get_messages_to_hydrate_below()
|
||||
if not to_hydrate:
|
||||
return
|
||||
|
||||
old_scroll_y = chat.scroll_y
|
||||
hydrated_count = 0
|
||||
# Mount in order from the window edge downward, stopping at the first
|
||||
# failure so `mark_hydrated_below`'s count stays contiguous with the
|
||||
# mounted rows (a mid-batch gap would desync `_visible_end`).
|
||||
for msg_data in to_hydrate:
|
||||
try:
|
||||
widget = msg_data.to_widget()
|
||||
footer = self._build_message_timestamp_footer(
|
||||
msg_data, visible=self._message_timestamps_visible
|
||||
)
|
||||
nodes = [widget]
|
||||
if footer is not None:
|
||||
nodes.append(footer)
|
||||
await self._mount_transcript_nodes(messages_container, nodes)
|
||||
hydrated_count += 1
|
||||
self._schedule_message_height_measurement(msg_data.id)
|
||||
if isinstance(widget, AssistantMessage) and msg_data.content:
|
||||
await widget.set_content(msg_data.content)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to hydrate message %s below window; stopping to "
|
||||
"keep the mounted window contiguous",
|
||||
msg_data.id,
|
||||
exc_info=True,
|
||||
)
|
||||
break
|
||||
|
||||
if hydrated_count == 0:
|
||||
return
|
||||
|
||||
self._message_store.mark_hydrated_below(hydrated_count)
|
||||
await self._prune_old_messages()
|
||||
self._sync_transcript_spacers(messages_container)
|
||||
chat.scroll_y = old_scroll_y
|
||||
await self._regroup_completed_tools()
|
||||
|
||||
async def _mount_before_queued(self, container: Container, widget: Widget) -> None:
|
||||
"""Mount a widget in the messages container, kept above the bottom anchors.
|
||||
|
||||
@@ -5818,14 +5915,23 @@ class DeepAgentsApp(App):
|
||||
if not container.is_attached:
|
||||
return
|
||||
anchor: Widget | None = None
|
||||
is_transcript_widget = not (
|
||||
isinstance(widget, LoadingWidget | QueuedUserMessage)
|
||||
or widget.has_class(_MESSAGE_SPACER_CLASS)
|
||||
)
|
||||
if is_transcript_widget and widget.id != _MESSAGE_BOTTOM_SPACER_ID:
|
||||
with suppress(NoMatches):
|
||||
anchor = container.query_one(f"#{_MESSAGE_BOTTOM_SPACER_ID}")
|
||||
|
||||
spinner = self._loading_widget
|
||||
if (
|
||||
widget is not spinner
|
||||
anchor is None
|
||||
and widget is not spinner
|
||||
and spinner is not None
|
||||
and spinner.parent is container
|
||||
):
|
||||
anchor = spinner
|
||||
else:
|
||||
if anchor is None:
|
||||
first_queued = self._queued_widgets[0] if self._queued_widgets else None
|
||||
if first_queued is not None and first_queued.parent is container:
|
||||
anchor = first_queued
|
||||
@@ -5841,6 +5947,126 @@ class DeepAgentsApp(App):
|
||||
return
|
||||
await container.mount(widget)
|
||||
|
||||
@staticmethod
|
||||
def _is_virtual_spacer(widget: Widget) -> bool:
|
||||
"""Return whether `widget` is a transcript spacer."""
|
||||
return widget.has_class(_MESSAGE_SPACER_CLASS)
|
||||
|
||||
def _first_transcript_child(self, container: Container) -> Widget | None:
|
||||
"""Return the first mounted transcript child after spacer rows."""
|
||||
for child in container.children:
|
||||
if self._is_virtual_spacer(child):
|
||||
continue
|
||||
if isinstance(child, LoadingWidget | QueuedUserMessage):
|
||||
continue
|
||||
return child
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _bottom_spacer(container: Container) -> Static | None:
|
||||
"""Return the bottom transcript spacer if it is mounted."""
|
||||
with suppress(NoMatches):
|
||||
return container.query_one(f"#{_MESSAGE_BOTTOM_SPACER_ID}", Static)
|
||||
return None
|
||||
|
||||
async def _mount_transcript_nodes(
|
||||
self,
|
||||
container: Container,
|
||||
nodes: list[Widget],
|
||||
*,
|
||||
before: Widget | None = None,
|
||||
) -> None:
|
||||
"""Mount transcript nodes before an anchor or the bottom spacer."""
|
||||
if not nodes:
|
||||
return
|
||||
anchor = before or self._bottom_spacer(container)
|
||||
if anchor is None:
|
||||
await container.mount(*nodes)
|
||||
else:
|
||||
await container.mount(*nodes, before=anchor)
|
||||
|
||||
async def _ensure_transcript_spacers(self, container: Container) -> None:
|
||||
"""Mount spacer rows that preserve full transcript scroll geometry."""
|
||||
if not container.is_attached:
|
||||
return
|
||||
|
||||
if not container.query(f"#{_MESSAGE_TOP_SPACER_ID}"):
|
||||
top = Static("", id=_MESSAGE_TOP_SPACER_ID, classes=_MESSAGE_SPACER_CLASS)
|
||||
first = container.children[0] if container.children else None
|
||||
if first is None:
|
||||
await container.mount(top)
|
||||
else:
|
||||
await container.mount(top, before=first)
|
||||
|
||||
if not container.query(f"#{_MESSAGE_BOTTOM_SPACER_ID}"):
|
||||
bottom = Static(
|
||||
"",
|
||||
id=_MESSAGE_BOTTOM_SPACER_ID,
|
||||
classes=_MESSAGE_SPACER_CLASS,
|
||||
)
|
||||
anchor = self._loading_widget
|
||||
if anchor is None or anchor.parent is not container:
|
||||
anchor = next(
|
||||
(
|
||||
queued
|
||||
for queued in self._queued_widgets
|
||||
if queued.parent is container
|
||||
),
|
||||
None,
|
||||
)
|
||||
if anchor is None:
|
||||
await container.mount(bottom)
|
||||
else:
|
||||
await container.mount(bottom, before=anchor)
|
||||
|
||||
self._sync_transcript_spacers(container)
|
||||
|
||||
@staticmethod
|
||||
def _set_spacer_height(widget: Static, height: int) -> None:
|
||||
"""Set spacer height in terminal rows."""
|
||||
rows = max(0, height)
|
||||
widget.styles.height = rows
|
||||
widget.display = rows > 0
|
||||
|
||||
def _sync_transcript_spacers(self, container: Container | None = None) -> None:
|
||||
"""Update spacer rows from the current `MessageStore` visible range."""
|
||||
if container is None:
|
||||
try:
|
||||
container = self.query_one("#messages", Container)
|
||||
except NoMatches:
|
||||
return
|
||||
try:
|
||||
top = container.query_one(f"#{_MESSAGE_TOP_SPACER_ID}", Static)
|
||||
bottom = container.query_one(f"#{_MESSAGE_BOTTOM_SPACER_ID}", Static)
|
||||
except NoMatches:
|
||||
return
|
||||
start, end = self._message_store.get_visible_range()
|
||||
self._set_spacer_height(top, self._message_store.range_height(0, start))
|
||||
self._set_spacer_height(
|
||||
bottom,
|
||||
self._message_store.range_height(end, self._message_store.total_count),
|
||||
)
|
||||
|
||||
def _schedule_message_height_measurement(self, message_id: str) -> None:
|
||||
"""Measure a message after Textual lays it out."""
|
||||
self.call_after_refresh(self._measure_message_height, message_id)
|
||||
|
||||
def _measure_message_height(self, message_id: str) -> None:
|
||||
"""Cache the mounted row height for spacer estimates."""
|
||||
try:
|
||||
messages = self.query_one("#messages", Container)
|
||||
widget = messages.query_one(f"#{message_id}")
|
||||
except NoMatches:
|
||||
return
|
||||
height = max(1, widget.region.height)
|
||||
footer_id = _message_timestamp_footer_id(message_id)
|
||||
with suppress(NoMatches):
|
||||
footer = messages.query_one(f"#{footer_id}")
|
||||
if footer.display:
|
||||
height += max(1, footer.region.height)
|
||||
if self._message_store.set_height_hint(message_id, height):
|
||||
self._sync_transcript_spacers(messages)
|
||||
|
||||
async def _mount_transient_app_message(self, content: str) -> AppMessage | None:
|
||||
"""Mount an `AppMessage` that is not tracked by the message store.
|
||||
|
||||
@@ -11151,6 +11377,7 @@ class DeepAgentsApp(App):
|
||||
messages_container = self.query_one("#messages", Container)
|
||||
except NoMatches:
|
||||
return
|
||||
await self._ensure_transcript_spacers(messages_container)
|
||||
|
||||
# 3. Reconcile against existing state before loading the store.
|
||||
# Mounting a widget whose ID already exists raises `DuplicateIds`,
|
||||
@@ -11218,7 +11445,7 @@ class DeepAgentsApp(App):
|
||||
if footer is not None:
|
||||
nodes.append(footer)
|
||||
if nodes:
|
||||
await messages_container.mount(*nodes)
|
||||
await self._mount_transcript_nodes(messages_container, nodes)
|
||||
|
||||
# 8. Render content for AssistantMessage after mount
|
||||
assistant_updates = [
|
||||
@@ -11238,6 +11465,9 @@ class DeepAgentsApp(App):
|
||||
history_thread_id,
|
||||
error,
|
||||
)
|
||||
for _widget, msg_data in mounted:
|
||||
self._schedule_message_height_measurement(msg_data.id)
|
||||
self._sync_transcript_spacers(messages_container)
|
||||
|
||||
# 9. Add footer immediately and resolve link asynchronously
|
||||
thread_msg_widget = AppMessage(f"Resumed thread: {history_thread_id}")
|
||||
@@ -11429,6 +11659,9 @@ class DeepAgentsApp(App):
|
||||
pass
|
||||
return
|
||||
|
||||
await self._ensure_transcript_spacers(messages)
|
||||
await self._hydrate_all_messages_below()
|
||||
|
||||
# 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
|
||||
@@ -11483,6 +11716,9 @@ class DeepAgentsApp(App):
|
||||
elif is_diff:
|
||||
self._active_tool_group.add_collapsible(widget)
|
||||
|
||||
self._schedule_message_height_measurement(message_data.id)
|
||||
self._sync_transcript_spacers(messages)
|
||||
|
||||
# Prune old widgets if window exceeded
|
||||
await self._prune_old_messages()
|
||||
|
||||
@@ -11493,6 +11729,15 @@ class DeepAgentsApp(App):
|
||||
except NoMatches:
|
||||
pass
|
||||
|
||||
async def _hydrate_all_messages_below(self) -> None:
|
||||
"""Mount any hidden tail before appending fresh transcript output."""
|
||||
while self._message_store.has_messages_below:
|
||||
before = self._message_store.get_visible_range()[1]
|
||||
await self._hydrate_messages_below()
|
||||
after = self._message_store.get_visible_range()[1]
|
||||
if after == before:
|
||||
break
|
||||
|
||||
async def _prune_old_messages(self) -> None:
|
||||
"""Prune oldest message widgets if we exceed the window size.
|
||||
|
||||
@@ -11532,6 +11777,7 @@ class DeepAgentsApp(App):
|
||||
|
||||
if pruned_ids:
|
||||
self._message_store.mark_pruned(pruned_ids)
|
||||
self._sync_transcript_spacers(messages_container)
|
||||
# Drop any group summaries whose members were all pruned away so a
|
||||
# stray collapsed line never lingers above the window. Only reachable
|
||||
# when something was actually pruned this pass.
|
||||
@@ -11545,6 +11791,39 @@ class DeepAgentsApp(App):
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
async def _prune_messages_below_window(
|
||||
self, messages_container: Container | None = None
|
||||
) -> None:
|
||||
"""Prune newest mounted widgets when scrolling into older history."""
|
||||
to_prune = self._message_store.get_messages_to_prune_below()
|
||||
if not to_prune:
|
||||
return
|
||||
if messages_container is None:
|
||||
try:
|
||||
messages_container = self.query_one("#messages", Container)
|
||||
except NoMatches:
|
||||
return
|
||||
|
||||
pruned_ids: list[str] = []
|
||||
for msg_data in to_prune:
|
||||
try:
|
||||
widget = messages_container.query_one(f"#{msg_data.id}")
|
||||
footer_id = _message_timestamp_footer_id(msg_data.id)
|
||||
with suppress(NoMatches):
|
||||
footer = messages_container.query_one(f"#{footer_id}")
|
||||
await footer.remove()
|
||||
await widget.remove()
|
||||
pruned_ids.append(msg_data.id)
|
||||
except NoMatches:
|
||||
logger.debug(
|
||||
"Widget %s not found during bottom pruning, skipping",
|
||||
msg_data.id,
|
||||
)
|
||||
|
||||
if pruned_ids:
|
||||
self._message_store.mark_pruned_below(pruned_ids)
|
||||
self._sync_transcript_spacers(messages_container)
|
||||
|
||||
def _close_active_tool_group(self) -> None:
|
||||
"""Finalize the open tool group into its collapsed past-tense form."""
|
||||
group = self._active_tool_group
|
||||
@@ -11664,6 +11943,37 @@ class DeepAgentsApp(App):
|
||||
is_streaming=False,
|
||||
)
|
||||
|
||||
def _sync_tool_message_state(self, widget: ToolCallMessage) -> None:
|
||||
"""Sync mutable tool widget state back to `MessageStore`."""
|
||||
if not widget.id:
|
||||
return
|
||||
try:
|
||||
data = MessageData.from_widget(widget)
|
||||
except Exception:
|
||||
# Fail safe: we can't prove the tool is terminal, so keep the row
|
||||
# mounted rather than leaving a possibly-live tool pruneable.
|
||||
logger.warning(
|
||||
"Failed to serialize tool widget %s; keeping it protected",
|
||||
widget.id,
|
||||
exc_info=True,
|
||||
)
|
||||
self._message_store.protect_message(widget.id)
|
||||
return
|
||||
self._message_store.update_message(
|
||||
widget.id,
|
||||
tool_status=data.tool_status,
|
||||
tool_output=data.tool_output,
|
||||
tool_expanded=data.tool_expanded,
|
||||
tool_reject_reason=data.tool_reject_reason,
|
||||
)
|
||||
if data.tool_status in {ToolStatus.PENDING, ToolStatus.RUNNING}:
|
||||
self._message_store.protect_message(widget.id)
|
||||
elif data.tool_status is not None:
|
||||
# Only release protection for a known terminal status. An
|
||||
# unrecognized status serializes to None; unprotecting then could
|
||||
# let a still-live row be virtualized mid-run.
|
||||
self._message_store.unprotect_message(widget.id)
|
||||
|
||||
async def _clear_messages(self) -> None:
|
||||
"""Clear the messages area and message store."""
|
||||
# Drop buffered `!` shell output so it never leaks across a thread
|
||||
|
||||
@@ -299,6 +299,7 @@ class TextualUIAdapter:
|
||||
set_spinner: Callable[[SpinnerStatus], Awaitable[None]] | None = None,
|
||||
set_active_message: Callable[[str | None], None] | None = None,
|
||||
sync_message_content: Callable[[str, str], None] | None = None,
|
||||
sync_tool_message: Callable[[ToolCallMessage], None] | None = None,
|
||||
request_ask_user: (
|
||||
Callable[
|
||||
[list[Question]],
|
||||
@@ -336,6 +337,9 @@ class TextualUIAdapter:
|
||||
self._sync_message_content = sync_message_content
|
||||
"""Callback to sync final message content back to the store after streaming."""
|
||||
|
||||
self._sync_tool_message = sync_tool_message
|
||||
"""Callback to sync a tool widget's mutable state back to the store."""
|
||||
|
||||
self._request_ask_user = request_ask_user
|
||||
"""Async callback for `ask_user` interrupts.
|
||||
|
||||
@@ -372,6 +376,20 @@ class TextualUIAdapter:
|
||||
self._on_tokens_show: _TokensShowCallback | None = None
|
||||
"""Called to restore the token display with the cached value."""
|
||||
|
||||
def _sync_tool_widget(self, tool_msg: ToolCallMessage) -> None:
|
||||
"""Sync a tool widget when the app provided a store callback.
|
||||
|
||||
Total by contract: never raises. Call sites are scattered across the
|
||||
turn loop, some outside try/except, so a sync failure must not abort
|
||||
the turn — it is logged and swallowed here.
|
||||
"""
|
||||
if self._sync_tool_message is None:
|
||||
return
|
||||
try:
|
||||
self._sync_tool_message(tool_msg)
|
||||
except Exception:
|
||||
logger.exception("Failed to sync tool widget state to store")
|
||||
|
||||
def finalize_pending_tools_with_error(self, error: str) -> None:
|
||||
"""Mark all pending/running tool widgets as error and clear tracking.
|
||||
|
||||
@@ -388,6 +406,7 @@ class TextualUIAdapter:
|
||||
_dispatch_terminal_tool_result_hooks(self._current_tool_messages, error)
|
||||
for tool_msg in list(self._current_tool_messages.values()):
|
||||
tool_msg.set_error(error)
|
||||
self._sync_tool_widget(tool_msg)
|
||||
self._current_tool_messages.clear()
|
||||
|
||||
# Clear active streaming message to avoid stale "active" state in the store.
|
||||
@@ -1001,6 +1020,7 @@ async def execute_task_textual(
|
||||
tool_msg.set_success(output_str)
|
||||
else:
|
||||
tool_msg.set_error(output_str or "Error")
|
||||
adapter._sync_tool_widget(tool_msg)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to update tool row for %s", tool_id
|
||||
@@ -1283,6 +1303,7 @@ async def execute_task_textual(
|
||||
# the group, so this drives state, not a
|
||||
# visible per-tool spinner.
|
||||
tool_msg.set_running()
|
||||
adapter._sync_tool_widget(tool_msg)
|
||||
adapter._current_tool_messages[buffer_id] = tool_msg
|
||||
|
||||
if buffer_id is not None:
|
||||
@@ -1339,6 +1360,7 @@ async def execute_task_textual(
|
||||
for tool_msg in adapter._current_tool_messages.values():
|
||||
try:
|
||||
tool_msg.pause_running()
|
||||
adapter._sync_tool_widget(tool_msg)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to pause running state on tool widget %s",
|
||||
@@ -1412,6 +1434,7 @@ async def execute_task_textual(
|
||||
if tool_msg is not None:
|
||||
try:
|
||||
tool_msg.set_success(output)
|
||||
adapter._sync_tool_widget(tool_msg)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to update ask_user row for %s",
|
||||
@@ -1447,6 +1470,7 @@ async def execute_task_textual(
|
||||
if tool_msg is not None:
|
||||
try:
|
||||
tool_msg.set_error(output)
|
||||
adapter._sync_tool_widget(tool_msg)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to update ask_user row for %s",
|
||||
@@ -1471,6 +1495,7 @@ async def execute_task_textual(
|
||||
if tool_msg is not None:
|
||||
try:
|
||||
tool_msg.set_rejected()
|
||||
adapter._sync_tool_widget(tool_msg)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to update ask_user row for %s",
|
||||
@@ -1501,6 +1526,7 @@ async def execute_task_textual(
|
||||
if tool_msg is not None:
|
||||
try:
|
||||
tool_msg.set_error(error_text)
|
||||
adapter._sync_tool_widget(tool_msg)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to update ask_user row for %s",
|
||||
@@ -1530,6 +1556,7 @@ async def execute_task_textual(
|
||||
if tool_msg is not None:
|
||||
try:
|
||||
tool_msg.set_error(_ASK_USER_UNSUPPORTED_ERROR)
|
||||
adapter._sync_tool_widget(tool_msg)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to update ask_user row for %s", tool_id
|
||||
@@ -1545,6 +1572,7 @@ async def execute_task_textual(
|
||||
resume_payload[interrupt_id] = {"decisions": decisions}
|
||||
for tool_msg in list(adapter._current_tool_messages.values()):
|
||||
tool_msg.set_running()
|
||||
adapter._sync_tool_widget(tool_msg)
|
||||
else:
|
||||
# Batch approval - one dialog for all parallel tool calls
|
||||
await dispatch_hook(
|
||||
@@ -1613,6 +1641,7 @@ async def execute_task_textual(
|
||||
)
|
||||
for tool_msg in tool_msgs:
|
||||
tool_msg.set_running()
|
||||
adapter._sync_tool_widget(tool_msg)
|
||||
for action_request in action_requests:
|
||||
tool_name = action_request.get("name")
|
||||
if tool_name in {
|
||||
@@ -1636,6 +1665,7 @@ async def execute_task_textual(
|
||||
)
|
||||
for tool_msg in tool_msgs:
|
||||
tool_msg.set_running()
|
||||
adapter._sync_tool_widget(tool_msg)
|
||||
for action_request in action_requests:
|
||||
tool_name = action_request.get("name")
|
||||
if tool_name in {
|
||||
@@ -1670,6 +1700,7 @@ async def execute_task_textual(
|
||||
)
|
||||
for tool_msg in tool_msgs:
|
||||
tool_msg.set_rejected(reason=reject_message)
|
||||
adapter._sync_tool_widget(tool_msg)
|
||||
# Bare reject aborts the turn and shows the
|
||||
# canned "Command rejected" banner so the user
|
||||
# can redirect. When a reason is supplied, the
|
||||
@@ -1698,6 +1729,7 @@ async def execute_task_textual(
|
||||
adapter._current_tool_messages.values()
|
||||
):
|
||||
tool_msg.set_rejected()
|
||||
adapter._sync_tool_widget(tool_msg)
|
||||
completed_tool_result_ids.update(
|
||||
_dispatch_terminal_tool_result_hooks(
|
||||
adapter._current_tool_messages,
|
||||
@@ -1718,6 +1750,7 @@ async def execute_task_textual(
|
||||
adapter._current_tool_messages.values()
|
||||
):
|
||||
tool_msg.set_rejected()
|
||||
adapter._sync_tool_widget(tool_msg)
|
||||
completed_tool_result_ids.update(
|
||||
_dispatch_terminal_tool_result_hooks(
|
||||
adapter._current_tool_messages,
|
||||
@@ -2063,6 +2096,7 @@ async def _handle_interrupt_cleanup(
|
||||
for tool_msg in list(adapter._current_tool_messages.values()):
|
||||
try:
|
||||
tool_msg.set_rejected()
|
||||
adapter._sync_tool_widget(tool_msg)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to mark tool row rejected during interrupt cleanup"
|
||||
|
||||
@@ -23,15 +23,28 @@ if TYPE_CHECKING:
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_HEIGHT_HINT = 5
|
||||
"""Estimated terminal rows for a message whose rendered height is unknown."""
|
||||
|
||||
MIN_HEIGHT_HINT = 1
|
||||
"""Smallest useful row estimate for spacer and range-height math."""
|
||||
|
||||
_ACTIVE_REASON = "active"
|
||||
"""Protection reason for the currently-streaming message."""
|
||||
|
||||
_LIVE_REASON = "live"
|
||||
"""Protection reason for a pending/running tool row (default for protect_message)."""
|
||||
|
||||
|
||||
_UPDATABLE_FIELDS: frozenset[str] = frozenset(
|
||||
{
|
||||
"content",
|
||||
"tool_status",
|
||||
"tool_output",
|
||||
"tool_expanded",
|
||||
"tool_reject_reason",
|
||||
"skill_expanded",
|
||||
"is_streaming",
|
||||
"height_hint",
|
||||
}
|
||||
)
|
||||
"""Fields on `MessageData` that callers are allowed to update via `update_message`.
|
||||
@@ -180,15 +193,13 @@ class MessageData:
|
||||
"""
|
||||
|
||||
height_hint: int | None = None
|
||||
"""Cached widget height in terminal rows for scroll position estimation.
|
||||
"""Cached rendered widget height in terminal rows, or None if unmeasured.
|
||||
|
||||
When `_hydrate_messages_above` inserts widgets above the viewport it needs
|
||||
to adjust the scroll offset so the user's view doesn't jump. Currently this
|
||||
uses a fixed estimate (5 rows per message). Caching the actual rendered
|
||||
height here after first mount would make that estimate accurate, especially
|
||||
for tall messages like diffs or long assistant responses.
|
||||
|
||||
Not yet populated — see `_hydrate_messages_above` in `app.py`.
|
||||
Measured after layout by `_measure_message_height` in `app.py` and stored
|
||||
via `set_height_hint`. Consumed by `estimate_height`/`range_height` to size
|
||||
the transcript spacers and to keep the scroll anchor stable across
|
||||
hydrate-above/below. When None (not yet measured), `estimate_height` falls
|
||||
back to `DEFAULT_HEIGHT_HINT`. Always `>= MIN_HEIGHT_HINT` once set.
|
||||
"""
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
@@ -409,15 +420,19 @@ class MessageStore:
|
||||
of widgets that are actually mounted in the DOM.
|
||||
|
||||
Attributes:
|
||||
WINDOW_SIZE: Maximum number of widgets to keep in DOM.
|
||||
WINDOW_SIZE: Maximum number of messages to keep mounted in the DOM.
|
||||
|
||||
Balances DOM performance with smooth scrolling experience.
|
||||
Trades DOM cost against scroll smoothness. Note each message may
|
||||
also mount a timestamp footer, so the live widget count is up to
|
||||
~2x this value. Spacer rows above/below the window preserve full
|
||||
scroll geometry, so this only bounds how much is rendered at once,
|
||||
not what the user can scroll to.
|
||||
HYDRATE_BUFFER: Number of messages to hydrate when scrolling near edge.
|
||||
|
||||
Provides enough buffer to avoid visible loading pauses.
|
||||
"""
|
||||
|
||||
WINDOW_SIZE: int = 50
|
||||
WINDOW_SIZE: int = 200
|
||||
HYDRATE_BUFFER: int = 15
|
||||
|
||||
def __init__(self) -> None:
|
||||
@@ -433,8 +448,19 @@ class MessageStore:
|
||||
self._visible_start: int = 0
|
||||
self._visible_end: int = 0
|
||||
|
||||
# Track active streaming message - never archive this
|
||||
self._protection_reasons: dict[str, set[str]] = {}
|
||||
"""Message ID -> set of reasons it must stay mounted while live.
|
||||
|
||||
A message is protected from virtualization iff it has at least one
|
||||
reason. Reasons are independent (`_ACTIVE_REASON` for the streaming
|
||||
message, `_LIVE_REASON` for a pending/running tool), so releasing one
|
||||
source never revokes another's protection.
|
||||
"""
|
||||
|
||||
self._active_message_id: str | None = None
|
||||
"""The single currently-streaming message, mirrored into
|
||||
`_protection_reasons` under `_ACTIVE_REASON`. Retained so the
|
||||
`is_active`/`set_active_message` API keeps working."""
|
||||
|
||||
@property
|
||||
def total_count(self) -> int:
|
||||
@@ -462,6 +488,7 @@ class MessageStore:
|
||||
Args:
|
||||
message: The message data to add.
|
||||
"""
|
||||
was_at_tail = self._visible_end == len(self._messages)
|
||||
if message.id in self._index:
|
||||
logger.warning(
|
||||
"Duplicate message ID %r appended; previous entry will be "
|
||||
@@ -470,7 +497,8 @@ class MessageStore:
|
||||
)
|
||||
self._messages.append(message)
|
||||
self._index[message.id] = message
|
||||
self._visible_end = len(self._messages)
|
||||
if was_at_tail:
|
||||
self._visible_end = len(self._messages)
|
||||
|
||||
def bulk_load(
|
||||
self, messages: list[MessageData]
|
||||
@@ -556,12 +584,18 @@ class MessageStore:
|
||||
def set_active_message(self, message_id: str | None) -> None:
|
||||
"""Set the currently active (streaming) message.
|
||||
|
||||
Active messages are never archived.
|
||||
Active messages are never archived. Only the previous active message's
|
||||
`_ACTIVE_REASON` is released, so a message also protected for another
|
||||
reason (e.g. a live tool) stays protected.
|
||||
|
||||
Args:
|
||||
message_id: The ID of the active message, or None to clear.
|
||||
"""
|
||||
if self._active_message_id is not None:
|
||||
self.unprotect_message(self._active_message_id, reason=_ACTIVE_REASON)
|
||||
self._active_message_id = message_id
|
||||
if message_id is not None:
|
||||
self.protect_message(message_id, reason=_ACTIVE_REASON)
|
||||
|
||||
def is_active(self, message_id: str) -> bool:
|
||||
"""Check if a message is the active streaming message.
|
||||
@@ -574,6 +608,43 @@ class MessageStore:
|
||||
"""
|
||||
return message_id == self._active_message_id
|
||||
|
||||
def protect_message(self, message_id: str, *, reason: str = _LIVE_REASON) -> None:
|
||||
"""Keep a live message mounted during window updates.
|
||||
|
||||
Reasons accumulate independently; a message stays protected until every
|
||||
reason is released. Idempotent per reason.
|
||||
|
||||
Args:
|
||||
message_id: Message ID to protect.
|
||||
reason: Why the message is protected. Defaults to a live tool row.
|
||||
"""
|
||||
self._protection_reasons.setdefault(message_id, set()).add(reason)
|
||||
|
||||
def unprotect_message(self, message_id: str, *, reason: str = _LIVE_REASON) -> None:
|
||||
"""Release one protection reason from a message.
|
||||
|
||||
The message becomes virtualizable only once it has no remaining
|
||||
reasons. Releasing a reason the message does not hold is a no-op.
|
||||
|
||||
Args:
|
||||
message_id: Message ID to stop protecting.
|
||||
reason: Which reason to release. Defaults to a live tool row.
|
||||
"""
|
||||
reasons = self._protection_reasons.get(message_id)
|
||||
if reasons is None:
|
||||
return
|
||||
reasons.discard(reason)
|
||||
if not reasons:
|
||||
del self._protection_reasons[message_id]
|
||||
|
||||
def is_protected(self, message_id: str) -> bool:
|
||||
"""Check whether a message is protected from virtualization.
|
||||
|
||||
Returns:
|
||||
Whether the message is protected for at least one reason.
|
||||
"""
|
||||
return message_id in self._protection_reasons
|
||||
|
||||
def window_exceeded(self) -> bool:
|
||||
"""Check if the visible window exceeds the maximum size.
|
||||
|
||||
@@ -586,8 +657,9 @@ class MessageStore:
|
||||
"""Get the oldest visible messages that should be pruned.
|
||||
|
||||
Returns a contiguous run of messages from the START of the visible
|
||||
window. Stops at the active streaming message to avoid creating gaps
|
||||
in the visible window (which would desync store state from the DOM).
|
||||
window. Stops at the first protected message (the active stream or a
|
||||
live tool run) to avoid creating gaps in the visible window (which
|
||||
would desync store state from the DOM).
|
||||
|
||||
Args:
|
||||
count: Number of messages to prune, or None to prune
|
||||
@@ -607,14 +679,42 @@ class MessageStore:
|
||||
|
||||
while len(to_prune) < count and idx < self._visible_end:
|
||||
msg = self._messages[idx]
|
||||
# Stop at the active message to keep the window contiguous
|
||||
if msg.id == self._active_message_id:
|
||||
# Stop at the first protected message to keep the window contiguous
|
||||
if self.is_protected(msg.id):
|
||||
break
|
||||
to_prune.append(msg)
|
||||
idx += 1
|
||||
|
||||
return to_prune
|
||||
|
||||
def get_messages_to_prune_below(
|
||||
self, count: int | None = None
|
||||
) -> list[MessageData]:
|
||||
"""Get newest visible messages that should be pruned below the viewport.
|
||||
|
||||
Args:
|
||||
count: Number of messages to prune, or enough to return to
|
||||
`WINDOW_SIZE` when omitted.
|
||||
|
||||
Returns:
|
||||
Messages to remove from the bottom of the visible window.
|
||||
"""
|
||||
if count is None:
|
||||
count = max(0, self.visible_count - self.WINDOW_SIZE)
|
||||
if count <= 0:
|
||||
return []
|
||||
|
||||
to_prune: list[MessageData] = []
|
||||
idx = self._visible_end - 1
|
||||
while len(to_prune) < count and idx >= self._visible_start:
|
||||
msg = self._messages[idx]
|
||||
if self.is_protected(msg.id):
|
||||
break
|
||||
to_prune.append(msg)
|
||||
idx -= 1
|
||||
to_prune.reverse()
|
||||
return to_prune
|
||||
|
||||
def mark_pruned(self, message_ids: list[str]) -> None:
|
||||
"""Mark messages as pruned (widgets removed).
|
||||
|
||||
@@ -631,6 +731,19 @@ class MessageStore:
|
||||
):
|
||||
self._visible_start += 1
|
||||
|
||||
def mark_pruned_below(self, message_ids: list[str]) -> None:
|
||||
"""Mark bottom-window messages as pruned.
|
||||
|
||||
Args:
|
||||
message_ids: IDs removed from the bottom of the mounted window.
|
||||
"""
|
||||
pruned_set = set(message_ids)
|
||||
while (
|
||||
self._visible_end > self._visible_start
|
||||
and self._messages[self._visible_end - 1].id in pruned_set
|
||||
):
|
||||
self._visible_end -= 1
|
||||
|
||||
def get_messages_to_hydrate(self, count: int | None = None) -> list[MessageData]:
|
||||
"""Get messages above the visible window to hydrate.
|
||||
|
||||
@@ -657,6 +770,33 @@ class MessageStore:
|
||||
"""
|
||||
self._visible_start = max(0, self._visible_start - count)
|
||||
|
||||
def get_messages_to_hydrate_below(
|
||||
self, count: int | None = None
|
||||
) -> list[MessageData]:
|
||||
"""Get messages below the visible window to hydrate.
|
||||
|
||||
Args:
|
||||
count: Number of messages to hydrate; defaults to `HYDRATE_BUFFER`
|
||||
when omitted.
|
||||
|
||||
Returns:
|
||||
Messages below the mounted window, in order.
|
||||
"""
|
||||
if count is None:
|
||||
count = self.HYDRATE_BUFFER
|
||||
if self._visible_end >= len(self._messages):
|
||||
return []
|
||||
hydrate_end = min(len(self._messages), self._visible_end + count)
|
||||
return self._messages[self._visible_end : hydrate_end]
|
||||
|
||||
def mark_hydrated_below(self, count: int) -> None:
|
||||
"""Mark that messages below were hydrated.
|
||||
|
||||
Args:
|
||||
count: Number of messages that were hydrated below the window.
|
||||
"""
|
||||
self._visible_end = min(len(self._messages), self._visible_end + count)
|
||||
|
||||
def should_hydrate_above(
|
||||
self, scroll_position: float, viewport_height: int
|
||||
) -> bool:
|
||||
@@ -701,12 +841,36 @@ class MessageStore:
|
||||
threshold = viewport_height * 3
|
||||
return distance_from_bottom > threshold
|
||||
|
||||
def should_hydrate_below(
|
||||
self,
|
||||
scroll_position: float,
|
||||
viewport_height: int,
|
||||
bottom_spacer_top: int,
|
||||
) -> bool:
|
||||
"""Check if we should hydrate messages below the current view.
|
||||
|
||||
Args:
|
||||
scroll_position: Current scroll Y position.
|
||||
viewport_height: Height of the viewport.
|
||||
bottom_spacer_top: Estimated row where the bottom spacer begins.
|
||||
|
||||
Returns:
|
||||
True if the viewport is near the bottom spacer.
|
||||
"""
|
||||
if not self.has_messages_below:
|
||||
return False
|
||||
viewport_bottom = scroll_position + viewport_height
|
||||
distance_from_bottom_spacer = bottom_spacer_top - viewport_bottom
|
||||
threshold = viewport_height * 2
|
||||
return distance_from_bottom_spacer < threshold
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear all messages."""
|
||||
self._messages.clear()
|
||||
self._index.clear()
|
||||
self._visible_start = 0
|
||||
self._visible_end = 0
|
||||
self._protection_reasons.clear()
|
||||
self._active_message_id = None
|
||||
|
||||
def get_visible_range(self) -> tuple[int, int]:
|
||||
@@ -732,3 +896,57 @@ class MessageStore:
|
||||
List of visible message data.
|
||||
"""
|
||||
return self._messages[self._visible_start : self._visible_end]
|
||||
|
||||
def set_height_hint(self, message_id: str, rows: int) -> bool:
|
||||
"""Update a measured message height, clamped to `MIN_HEIGHT_HINT`.
|
||||
|
||||
The single write path for `height_hint`; `height_hint` is intentionally
|
||||
excluded from `update_message`'s allowlist so every write clamps here.
|
||||
|
||||
Args:
|
||||
message_id: Message ID to update.
|
||||
rows: Rendered height in terminal rows.
|
||||
|
||||
Returns:
|
||||
Whether the message existed and was updated.
|
||||
"""
|
||||
msg_data = self._index.get(message_id)
|
||||
if msg_data is None:
|
||||
return False
|
||||
msg_data.height_hint = max(MIN_HEIGHT_HINT, rows)
|
||||
return True
|
||||
|
||||
def invalidate_height_hints(self, *, scale: float | None = None) -> None:
|
||||
"""Invalidate or scale cached height hints after terminal reflow.
|
||||
|
||||
Args:
|
||||
scale: Optional multiplier used when terminal width changes. When
|
||||
omitted, all cached hints are cleared.
|
||||
"""
|
||||
for msg in self._messages:
|
||||
if msg.height_hint is None:
|
||||
continue
|
||||
if scale is None:
|
||||
msg.height_hint = None
|
||||
else:
|
||||
msg.height_hint = max(MIN_HEIGHT_HINT, round(msg.height_hint * scale))
|
||||
|
||||
@staticmethod
|
||||
def estimate_height(message: MessageData) -> int:
|
||||
"""Return the best available row estimate for a message."""
|
||||
if message.height_hint is None:
|
||||
return DEFAULT_HEIGHT_HINT
|
||||
return max(MIN_HEIGHT_HINT, message.height_hint)
|
||||
|
||||
def range_height(self, start: int, end: int) -> int:
|
||||
"""Estimate rows in `[start:end]`.
|
||||
|
||||
Returns:
|
||||
Estimated row count in the range.
|
||||
"""
|
||||
bounded_start = max(0, min(start, len(self._messages)))
|
||||
bounded_end = max(bounded_start, min(end, len(self._messages)))
|
||||
return sum(
|
||||
self.estimate_height(msg)
|
||||
for msg in self._messages[bounded_start:bounded_end]
|
||||
)
|
||||
|
||||
@@ -66,12 +66,14 @@ from deepagents_code.tui.widgets.launch_init import (
|
||||
LaunchDependenciesScreen,
|
||||
LaunchNameScreen,
|
||||
)
|
||||
from deepagents_code.tui.widgets.message_store import ToolStatus
|
||||
from deepagents_code.tui.widgets.messages import (
|
||||
AppMessage,
|
||||
AssistantMessage,
|
||||
ErrorMessage,
|
||||
QueuedUserMessage,
|
||||
SummarizationMessage,
|
||||
ToolCallMessage,
|
||||
UserMessage,
|
||||
)
|
||||
from deepagents_code.tui.widgets.startup_tip import StartupTip
|
||||
@@ -8543,6 +8545,178 @@ class TestMessageTimestampFooters:
|
||||
anchor = app.query_one("#hist-0", UserMessage)
|
||||
assert children[children.index(anchor) + 1] is footer
|
||||
|
||||
async def test_restored_history_uses_top_spacer_for_archived_rows(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Long restored threads keep source rows above the mounted tail."""
|
||||
from deepagents_code.app import _MESSAGE_TOP_SPACER_ID, _ThreadHistoryPayload
|
||||
from deepagents_code.tui.widgets.message_store import MessageData, MessageType
|
||||
|
||||
app = DeepAgentsApp()
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
monkeypatch.setattr(app._message_store, "WINDOW_SIZE", 2)
|
||||
payload = _ThreadHistoryPayload(
|
||||
[
|
||||
MessageData(
|
||||
type=MessageType.USER,
|
||||
content=f"m{index}",
|
||||
id=f"spacer-{index}",
|
||||
)
|
||||
for index in range(5)
|
||||
],
|
||||
0,
|
||||
"",
|
||||
)
|
||||
|
||||
await app._load_thread_history(
|
||||
thread_id="t-spacer", preloaded_payload=payload
|
||||
)
|
||||
await pilot.pause()
|
||||
|
||||
assert app._message_store.total_count >= 5
|
||||
assert app._message_store.has_messages_above
|
||||
top = app.query_one(f"#{_MESSAGE_TOP_SPACER_ID}", Static)
|
||||
assert top.display is True
|
||||
with pytest.raises(NoMatches):
|
||||
app.query_one("#spacer-0", UserMessage)
|
||||
assert app.query_one("#spacer-4", UserMessage)
|
||||
|
||||
async def test_mount_message_hydrates_hidden_tail_before_append(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""New live output should not skip messages hidden below the window."""
|
||||
from deepagents_code.tui.widgets.message_store import MessageType
|
||||
|
||||
app = DeepAgentsApp()
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
for index in range(5):
|
||||
await app._mount_message(UserMessage(f"m{index}", id=f"tail-{index}"))
|
||||
await pilot.pause()
|
||||
|
||||
monkeypatch.setattr(app._message_store, "WINDOW_SIZE", 3)
|
||||
messages = app.query_one("#messages", Container)
|
||||
await app._prune_messages_below_window(messages)
|
||||
await pilot.pause()
|
||||
|
||||
assert app._message_store.has_messages_below
|
||||
with pytest.raises(NoMatches):
|
||||
app.query_one("#tail-3", UserMessage)
|
||||
|
||||
await app._mount_message(UserMessage("new", id="tail-new"))
|
||||
await pilot.pause()
|
||||
|
||||
assert not app._message_store.has_messages_below
|
||||
assert [
|
||||
msg.id
|
||||
for msg in app._message_store.get_all_messages()
|
||||
if msg.type is MessageType.USER
|
||||
] == [
|
||||
"tail-0",
|
||||
"tail-1",
|
||||
"tail-2",
|
||||
"tail-3",
|
||||
"tail-4",
|
||||
"tail-new",
|
||||
]
|
||||
for message_id in ["tail-3", "tail-4", "tail-new"]:
|
||||
assert app.query_one(f"#{message_id}", UserMessage)
|
||||
|
||||
async def test_hydrate_below_stops_at_first_failure(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A mid-batch mount failure must not desync the store from the DOM."""
|
||||
app = DeepAgentsApp()
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
for index in range(6):
|
||||
await app._mount_message(UserMessage(f"m{index}", id=f"b-{index}"))
|
||||
await pilot.pause()
|
||||
|
||||
monkeypatch.setattr(app._message_store, "WINDOW_SIZE", 3)
|
||||
messages = app.query_one("#messages", Container)
|
||||
await app._prune_messages_below_window(messages)
|
||||
await pilot.pause()
|
||||
assert app._message_store.has_messages_below
|
||||
|
||||
# Make the SECOND hidden row fail to build; hydration must stop
|
||||
# there so the mounted block stays contiguous with the window.
|
||||
_start, end = app._message_store.get_visible_range()
|
||||
hidden = app._message_store.get_all_messages()[end:]
|
||||
assert len(hidden) >= 2
|
||||
failing = hidden[1]
|
||||
|
||||
def _boom() -> Widget:
|
||||
error_message = "boom"
|
||||
raise RuntimeError(error_message)
|
||||
|
||||
monkeypatch.setattr(failing, "to_widget", _boom)
|
||||
|
||||
await app._hydrate_messages_below()
|
||||
await pilot.pause()
|
||||
|
||||
# The store's visible range must match exactly the mounted rows:
|
||||
# no phantom (store-visible but unmounted) or orphan (mounted but
|
||||
# outside the window).
|
||||
all_ids = {f"b-{i}" for i in range(6)}
|
||||
visible_ids = {msg.id for msg in app._message_store.get_visible_messages()}
|
||||
mounted_ids = {
|
||||
child.id for child in messages.children if child.id in all_ids
|
||||
}
|
||||
assert mounted_ids == visible_ids
|
||||
assert failing.id not in visible_ids
|
||||
|
||||
async def test_tool_state_sync_updates_store_and_protection(self) -> None:
|
||||
"""Mutable tool widget state should be canonical in MessageStore."""
|
||||
app = DeepAgentsApp()
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
tool = ToolCallMessage("execute", {"command": "echo hi"}, id="tool-sync")
|
||||
await app._mount_message(tool)
|
||||
await pilot.pause()
|
||||
|
||||
tool.set_running()
|
||||
app._sync_tool_message_state(tool)
|
||||
stored = app._message_store.get_message("tool-sync")
|
||||
assert stored is not None
|
||||
assert stored.tool_status == ToolStatus.RUNNING
|
||||
assert app._message_store.is_protected("tool-sync")
|
||||
|
||||
tool.set_success("done")
|
||||
app._sync_tool_message_state(tool)
|
||||
assert stored.tool_status == ToolStatus.SUCCESS
|
||||
assert stored.tool_output == "done"
|
||||
assert not app._message_store.is_protected("tool-sync")
|
||||
|
||||
async def test_transcript_mounts_stay_chronological_around_spinner(self) -> None:
|
||||
"""Rows mounted while the spinner is active stay above the bottom spacer."""
|
||||
app = DeepAgentsApp()
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
await app._mount_message(UserMessage("first", id="order-user-1"))
|
||||
await app._set_spinner("Thinking")
|
||||
await app._mount_message(AssistantMessage("reply", id="order-agent-1"))
|
||||
# Reasserting the spinner used to move it below the bottom spacer;
|
||||
# later user rows then mounted above old assistant rows.
|
||||
await app._set_spinner("Thinking")
|
||||
await app._set_spinner(None)
|
||||
await app._mount_message(UserMessage("second", id="order-user-2"))
|
||||
await pilot.pause()
|
||||
|
||||
messages = app.query_one("#messages", Container)
|
||||
ordered_ids = [
|
||||
child.id
|
||||
for child in messages.children
|
||||
if child.id in {"order-user-1", "order-agent-1", "order-user-2"}
|
||||
]
|
||||
assert ordered_ids == ["order-user-1", "order-agent-1", "order-user-2"]
|
||||
|
||||
async def test_mount_message_adds_footer_when_enabled(self) -> None:
|
||||
"""New messages receive a footer while timestamps are enabled."""
|
||||
app = DeepAgentsApp()
|
||||
|
||||
@@ -4,6 +4,8 @@ import pytest
|
||||
from textual.widgets import Static
|
||||
|
||||
from deepagents_code.tui.widgets.message_store import (
|
||||
DEFAULT_HEIGHT_HINT,
|
||||
MIN_HEIGHT_HINT,
|
||||
MessageData,
|
||||
MessageStore,
|
||||
MessageType,
|
||||
@@ -316,6 +318,28 @@ class TestMessageStore:
|
||||
assert store.total_count == 2
|
||||
assert store.visible_count == 2
|
||||
|
||||
def test_append_preserves_hidden_tail(self):
|
||||
"""Appending while scrolled up should keep newer messages hidden."""
|
||||
store = MessageStore()
|
||||
for i in range(6):
|
||||
store.append(
|
||||
MessageData(type=MessageType.USER, content=f"msg{i}", id=f"id-{i}")
|
||||
)
|
||||
store._visible_start = 1
|
||||
store._visible_end = 3
|
||||
|
||||
store.append(MessageData(type=MessageType.USER, content="new", id="id-new"))
|
||||
|
||||
assert store.total_count == 7
|
||||
assert store.get_visible_range() == (1, 3)
|
||||
assert store.has_messages_below
|
||||
assert [msg.id for msg in store.get_messages_to_hydrate_below(10)] == [
|
||||
"id-3",
|
||||
"id-4",
|
||||
"id-5",
|
||||
"id-new",
|
||||
]
|
||||
|
||||
def test_window_exceeded(self):
|
||||
"""Test window size detection."""
|
||||
store = MessageStore()
|
||||
@@ -538,6 +562,28 @@ class TestMessageStore:
|
||||
scroll_position=800, viewport_height=100, content_height=1000
|
||||
)
|
||||
|
||||
def test_should_hydrate_below_uses_bottom_spacer_top(self):
|
||||
"""Hydration should start near mounted rows, not virtual transcript end."""
|
||||
store = MessageStore()
|
||||
for i in range(100):
|
||||
store.append(
|
||||
MessageData(type=MessageType.USER, content=f"msg{i}", id=f"id-{i}")
|
||||
)
|
||||
store._visible_start = 20
|
||||
store._visible_end = 30
|
||||
|
||||
bottom_spacer_top = store.range_height(0, store.get_visible_range()[1])
|
||||
assert store.should_hydrate_below(
|
||||
scroll_position=bottom_spacer_top - 150,
|
||||
viewport_height=100,
|
||||
bottom_spacer_top=bottom_spacer_top,
|
||||
)
|
||||
assert not store.should_hydrate_below(
|
||||
scroll_position=bottom_spacer_top - 400,
|
||||
viewport_height=100,
|
||||
bottom_spacer_top=bottom_spacer_top,
|
||||
)
|
||||
|
||||
def test_visible_range(self):
|
||||
"""Test getting visible range."""
|
||||
store = MessageStore()
|
||||
@@ -723,6 +769,171 @@ class TestVirtualizationFlow:
|
||||
assert retrieved.content == "Updated content"
|
||||
assert retrieved.is_streaming is False
|
||||
|
||||
def test_height_hints_drive_range_estimates(self):
|
||||
"""Height hints should drive the range-height estimates spacers use."""
|
||||
store = MessageStore()
|
||||
for i in range(5):
|
||||
store.append(
|
||||
MessageData(type=MessageType.USER, content=f"msg{i}", id=f"id-{i}")
|
||||
)
|
||||
# Unmeasured messages fall back to DEFAULT_HEIGHT_HINT.
|
||||
assert store.range_height(0, 2) == 2 * DEFAULT_HEIGHT_HINT
|
||||
assert store.estimate_height(store._messages[0]) == DEFAULT_HEIGHT_HINT
|
||||
|
||||
store.set_height_hint("id-0", 3)
|
||||
store.set_height_hint("id-1", 7)
|
||||
|
||||
# id-0=3, id-1=7 measured; id-2 still the default → 3 + 7 + 5 == 15.
|
||||
assert store.range_height(0, 3) == 10 + DEFAULT_HEIGHT_HINT
|
||||
assert store.estimate_height(store._messages[0]) == 3
|
||||
|
||||
def test_set_height_hint_clamps_and_update_message_rejects(self):
|
||||
"""height_hint has a single clamping write path (set_height_hint)."""
|
||||
store = MessageStore()
|
||||
store.append(MessageData(type=MessageType.USER, content="msg", id="id-1"))
|
||||
|
||||
# set_height_hint clamps to the floor rather than storing 0/negatives.
|
||||
assert store.set_height_hint("id-1", 0)
|
||||
clamped = store.get_message("id-1")
|
||||
assert clamped is not None
|
||||
assert clamped.height_hint == MIN_HEIGHT_HINT
|
||||
|
||||
# The generic update path must not smuggle an unclamped height through.
|
||||
with pytest.raises(ValueError, match="height_hint"):
|
||||
store.update_message("id-1", height_hint=-4)
|
||||
|
||||
def test_height_hints_scale_and_clear(self):
|
||||
"""Width reflow can scale or clear cached height hints."""
|
||||
store = MessageStore()
|
||||
store.append(MessageData(type=MessageType.USER, content="msg", id="id-1"))
|
||||
store.set_height_hint("id-1", 10)
|
||||
|
||||
store.invalidate_height_hints(scale=0.5)
|
||||
msg = store.get_message("id-1")
|
||||
assert msg is not None
|
||||
assert msg.height_hint == 5
|
||||
|
||||
store.invalidate_height_hints()
|
||||
assert msg.height_hint is None
|
||||
|
||||
def test_protected_messages_block_top_and_bottom_pruning(self):
|
||||
"""Live messages should not be pruned from either edge."""
|
||||
store = MessageStore()
|
||||
store.WINDOW_SIZE = 3
|
||||
for i in range(6):
|
||||
store.append(
|
||||
MessageData(type=MessageType.USER, content=f"msg{i}", id=f"id-{i}")
|
||||
)
|
||||
|
||||
# A protected message at the front blocks top pruning entirely.
|
||||
store.protect_message("id-0")
|
||||
assert store.get_messages_to_prune() == []
|
||||
store.unprotect_message("id-0")
|
||||
|
||||
# Once released, the unprotected front messages prune normally.
|
||||
assert [m.id for m in store.get_messages_to_prune()] == ["id-0", "id-1", "id-2"]
|
||||
|
||||
# A protected newest message blocks bottom pruning; releasing it lets
|
||||
# the newest rows prune.
|
||||
store.protect_message("id-5")
|
||||
assert store.get_messages_to_prune_below() == []
|
||||
store.unprotect_message("id-5")
|
||||
assert [m.id for m in store.get_messages_to_prune_below()] == [
|
||||
"id-3",
|
||||
"id-4",
|
||||
"id-5",
|
||||
]
|
||||
|
||||
def test_protection_reasons_are_independent(self):
|
||||
"""Independent protection sources must not clobber each other."""
|
||||
store = MessageStore()
|
||||
store.append(MessageData(type=MessageType.USER, content="msg", id="id-1"))
|
||||
|
||||
# Protect for two reasons: a live tool and the active stream.
|
||||
store.protect_message("id-1") # default _LIVE_REASON
|
||||
store.set_active_message("id-1") # _ACTIVE_REASON
|
||||
assert store.is_protected("id-1")
|
||||
|
||||
# Releasing the live-tool reason must leave active protection intact.
|
||||
store.unprotect_message("id-1")
|
||||
assert store.is_protected("id-1")
|
||||
assert store.is_active("id-1")
|
||||
|
||||
# Swapping the active message away releases only the active reason.
|
||||
store.set_active_message(None)
|
||||
assert not store.is_protected("id-1")
|
||||
assert not store.is_active("id-1")
|
||||
|
||||
def test_active_swap_preserves_live_tool_protection(self):
|
||||
"""Changing the active message must not unprotect a still-live tool."""
|
||||
store = MessageStore()
|
||||
for msg_id in ("tool-1", "asst-1", "asst-2"):
|
||||
store.append(MessageData(type=MessageType.USER, content=msg_id, id=msg_id))
|
||||
|
||||
store.protect_message("tool-1") # live tool
|
||||
store.set_active_message("asst-1")
|
||||
# A new streaming message takes over; the live tool stays protected.
|
||||
store.set_active_message("asst-2")
|
||||
assert store.is_protected("tool-1")
|
||||
assert not store.is_protected("asst-1")
|
||||
assert store.is_protected("asst-2")
|
||||
|
||||
def test_hydrate_below_advances_visible_end(self):
|
||||
"""Hydrating below should mount the next block and advance the tail."""
|
||||
store = MessageStore()
|
||||
store.WINDOW_SIZE = 3
|
||||
for i in range(10):
|
||||
store.append(
|
||||
MessageData(type=MessageType.USER, content=f"msg{i}", id=f"id-{i}")
|
||||
)
|
||||
store._visible_start = 0
|
||||
store._visible_end = 3
|
||||
|
||||
to_hydrate = store.get_messages_to_hydrate_below(2)
|
||||
assert [m.id for m in to_hydrate] == ["id-3", "id-4"]
|
||||
|
||||
store.mark_hydrated_below(len(to_hydrate))
|
||||
assert store.get_visible_range() == (0, 5)
|
||||
|
||||
# Nothing left below once the tail is reached.
|
||||
store.mark_hydrated_below(store.total_count)
|
||||
assert store.get_visible_range() == (0, 10)
|
||||
assert not store.has_messages_below
|
||||
assert store.get_messages_to_hydrate_below() == []
|
||||
|
||||
def test_prune_below_returns_newest_and_marks_visible_end(self):
|
||||
"""Bottom pruning removes the newest rows and rewinds _visible_end."""
|
||||
store = MessageStore()
|
||||
store.WINDOW_SIZE = 3
|
||||
for i in range(6):
|
||||
store.append(
|
||||
MessageData(type=MessageType.USER, content=f"msg{i}", id=f"id-{i}")
|
||||
)
|
||||
store._visible_start = 0
|
||||
store._visible_end = 6
|
||||
|
||||
to_prune = store.get_messages_to_prune_below() # back to WINDOW_SIZE
|
||||
assert [m.id for m in to_prune] == ["id-3", "id-4", "id-5"]
|
||||
|
||||
store.mark_pruned_below([m.id for m in to_prune])
|
||||
assert store.get_visible_range() == (0, 3)
|
||||
assert store.has_messages_below
|
||||
|
||||
def test_mark_pruned_below_only_rewinds_contiguous_tail(self):
|
||||
"""A gap at the tail must not over-rewind _visible_end."""
|
||||
store = MessageStore()
|
||||
for i in range(6):
|
||||
store.append(
|
||||
MessageData(type=MessageType.USER, content=f"msg{i}", id=f"id-{i}")
|
||||
)
|
||||
store._visible_start = 0
|
||||
store._visible_end = 6
|
||||
|
||||
# The newest row (id-5) was NOT removed from the DOM; only inner rows
|
||||
# were. mark_pruned_below must stop at the still-mounted tail.
|
||||
store.mark_pruned_below(["id-3", "id-4"])
|
||||
assert store.get_visible_range() == (0, 6)
|
||||
|
||||
|
||||
class TestBulkLoad:
|
||||
"""Tests for MessageStore.bulk_load."""
|
||||
|
||||
Reference in New Issue
Block a user