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:
Johannes du Plessis
2026-07-08 16:38:58 -07:00
committed by GitHub
parent fccf037321
commit f6ee70c00a
5 changed files with 1009 additions and 62 deletions
+353 -43
View File
@@ -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]
)
+174
View File
@@ -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."""