Optimize realtime WebUI synchronization

Negotiate collection deltas and preserve sidebar row identity so high-rate log streams avoid resending and reconciling unchanged state. Bound streamed log fields, defer expensive response formatting until completion, update capped process groups only for new steps, and keep disconnect and terminal task state accurate. Add focused lifecycle, snapshot, sync, and WebUI regressions.

Alessandro committed Aug 26, 2026 at 15:24 UTC cb39a16c249a9a70144a74889ef52922cfb21631
33 files changed +486 -72
extensions/python/_functions/AGENTS.md
+1
@@ -16,6 +16,7 @@
16 - Extension functions must match the implicit hook's supplied arguments.
17 - Preserve ordering prefixes where exception handling, watchdog registration, or cleanup depends on them.
18 - Hooks that mirror persisted AI responses into UI logs must reuse existing stream log items and avoid duplicating live response-tool logs.
19 +- The `AgentContext.run_task/end` hook attaches integration callbacks to the returned `DeferredTask`; keep terminal side effects out of `agent.py`.
20 - Recovery-loop circuit breakers must stop at the General Settings limit and render their user-visible cost warning from a core framework prompt.
21 - Prompt settings snapshots must be task-local, accessed through `get_settings_for_prompt()`, and end with the matching `Agent.prepare_prompt` call, including exceptional exits.
22
extensions/python/_functions/agent/AgentContext/run_task/end/_10_mark_state_dirty.py new
+14
@@ -0,0 +1,14 @@
1 +from helpers.defer import DeferredTask
2 +from helpers.extension import Extension
3 +from helpers.state_monitor_integration import mark_dirty_all
4 +
5 +
6 +class MarkStateDirty(Extension):
7 + def execute(self, data: dict | None = None, **kwargs) -> None:
8 + task = data.get("result") if isinstance(data, dict) else None
9 + if isinstance(task, DeferredTask):
10 + task.add_done_callback(
11 + lambda _future: mark_dirty_all(
12 + reason="agent.AgentContext.run_task_done",
13 + )
14 + )
extensions/python/response_stream/AGENTS.md
+2
@@ -13,6 +13,8 @@
13 - Keep streaming output synchronized with UI log items.
14 - Treat parsed stream snapshots as partial data; nested tool fields may be `None`
15 until their values arrive.
16 +- Live root responses carry `finished: false` until the response tool completes
17 + them so the WebUI can defer expensive Markdown rendering while content grows.
18 - Preserve include-alias replacement semantics where prompts/tools rely on them.
19 - Do not expose unmasked secrets in live responses.
20
extensions/python/response_stream/_20_live_response.py
+1
@@ -41,6 +41,7 @@ class LiveResponse(Extension):
41 type="response",
42 heading=f"icon://chat {self.agent.agent_name}: Responding",
43 id=shared_id,
44 + finished=False,
45 )
46 )
47
helpers/defer.py
+5
@@ -101,6 +101,11 @@ class DeferredTask:
101 self._start_task()
102 return self
103
104 + def add_done_callback(self, callback: Callable[[Future], Any]) -> None:
105 + if not self._future:
106 + raise RuntimeError("Task hasn't been started")
107 + self._future.add_done_callback(callback)
108 +
109 def __del__(self):
110 self.kill()
111
helpers/defer.py.dox.md
+2
@@ -17,6 +17,7 @@
17 - `ChildTask` (no explicit base class)
18 - `DeferredTask` (no explicit base class)
19 - `start_task(self, func: Callable[..., Coroutine[Any, Any, Any]], *args, **kwargs)`
20 + - `add_done_callback(self, callback: Callable[[Future], Any]) -> None`
21 - `is_ready(self) -> bool`
22 - `result_sync(self, timeout: Optional[float]=...) -> Any`
23 - `async result(self, timeout: Optional[float]=...) -> Any`
@@ -30,6 +31,7 @@
31
32 - Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together.
33 - `DeferredTask` retains its callable and arguments only while an invocation is active; completion and `kill()` clear those references after the running coroutine has taken its own snapshot.
34 +- `add_done_callback()` forwards to the current invocation's concurrent future and rejects calls before `start_task()`; callbacks observe `is_alive() == False` and must remain lightweight.
35 - Task results remain available after completion. `restart()` can restart an active invocation, but a completed invocation has no retained call recipe and must be started again explicitly.
36 - Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
37 - Observed side-effect areas: scheduler state.
helpers/log.py
+2 -1
@@ -36,7 +36,7 @@ def _lazy_mark_dirty_for_context(context_id: str, *, reason: str | None = None)
36 from helpers.state_monitor_integration import mark_dirty_for_context
37
38 _MARK_DIRTY_FOR_CONTEXT = mark_dirty_for_context
39 - _MARK_DIRTY_FOR_CONTEXT(context_id, reason=reason)
39 + _MARK_DIRTY_FOR_CONTEXT(context_id, reason=reason, include_collections=False)
40
41
42 T = TypeVar("T")
@@ -309,6 +309,7 @@ class Log:
309 if kwargs:
310 kwargs_out = copy.deepcopy(kwargs)
311 kwargs_out = self._mask_recursive(kwargs_out)
312 + kwargs_out = _truncate_value(kwargs_out)
313
314 with self._lock:
315 item = self.logs[no]
helpers/log.py.dox.md
+2
@@ -43,6 +43,8 @@
43
44 - Important called helpers/classes observed in the source: `TypeVar`, `dataclass`, `_MARK_DIRTY_ALL`, `_MARK_DIRTY_FOR_CONTEXT`, `truncate_text_by_ratio`, `cast`, `threading.RLock`, `self.set_initial_progress`, `self._update_item`, `self._notify_state_monitor`, `_lazy_mark_dirty_all`, `_lazy_mark_dirty_for_context`, `self._mask_recursive`, `_truncate_progress`, `self.set_progress`, `LogOutput`, `_truncate_value`, `json.dumps`, `time.time`, `self.log._update_item`.
45 - Keep request/response, tool, or helper semantics documented here at the same time as source changes.
46 +- Keyword KVP updates use the same recursive key/value truncation as an explicit `kvps` mapping, preventing streamed reasoning and other live fields from bypassing payload limits.
47 +- Existing log-item and progress updates mark only the selected context stream dirty and explicitly omit unchanged context/task collections; creating a new log item still broadcasts a full collection refresh.
48
49 ## Work Guidance
50
helpers/state_monitor.py
+43 -6
@@ -32,6 +32,7 @@ class ConnectionProjection:
32 # pushes indefinitely during continuous activity (throttled coalescing).
33 dirty_version: int = 0
34 pushed_version: int = 0
35 + collections_dirty_version: int = 0
36 # Development-only diagnostics - last known cause of the most recent dirty wave.
37 dirty_reason: str | None = None
38 dirty_wave_id: str | None = None
@@ -41,7 +42,7 @@ class ConnectionProjection:
42 class StateMonitor:
43 """Per-sid dirty tracking with debounced snapshot push scheduling."""
44
44 - def __init__(self, debounce_seconds: float = 0.025) -> None:
45 + def __init__(self, debounce_seconds: float = 0.1) -> None:
46 self.debounce_seconds = float(debounce_seconds)
47 self._lock = threading.RLock()
48 self._projections: dict[ConnectionIdentity, ConnectionProjection] = {}
@@ -95,7 +96,13 @@ class StateMonitor:
96 for namespace, sid in identities:
97 self.mark_dirty(namespace, sid, reason=reason, wave_id=wave_id)
98
98 - def mark_dirty_for_context(self, context_id: str, *, reason: str | None = None) -> None:
99 + def mark_dirty_for_context(
100 + self,
101 + context_id: str,
102 + *,
103 + reason: str | None = None,
104 + include_collections: bool = True,
105 + ) -> None:
106 if not isinstance(context_id, str) or not context_id.strip():
107 return
108 target = context_id.strip()
@@ -111,7 +118,13 @@ class StateMonitor:
118 if projection.request is not None and projection.request.context == target
119 ]
120 for namespace, sid in identities:
114 - self.mark_dirty(namespace, sid, reason=reason, wave_id=wave_id)
121 + self.mark_dirty(
122 + namespace,
123 + sid,
124 + reason=reason,
125 + wave_id=wave_id,
126 + include_collections=include_collections,
127 + )
128
129 def update_projection(
130 self,
@@ -142,6 +155,7 @@ class StateMonitor:
155 *,
156 reason: str | None = None,
157 wave_id: str | None = None,
158 + include_collections: bool = True,
159 ) -> None:
160 identity: ConnectionIdentity = (namespace, sid)
161 loop = self._dispatcher_loop
@@ -157,22 +171,36 @@ class StateMonitor:
171 running_loop = None
172
173 if running_loop is loop:
160 - self._mark_dirty_on_loop(identity, reason=reason, wave_id=wave_id)
174 + self._mark_dirty_on_loop(
175 + identity,
176 + reason=reason,
177 + wave_id=wave_id,
178 + include_collections=include_collections,
179 + )
180 return
181
163 - loop.call_soon_threadsafe(self._mark_dirty_on_loop, identity, reason, wave_id)
182 + loop.call_soon_threadsafe(
183 + self._mark_dirty_on_loop,
184 + identity,
185 + reason,
186 + wave_id,
187 + include_collections,
188 + )
189
190 def _mark_dirty_on_loop(
191 self,
192 identity: ConnectionIdentity,
193 reason: str | None = None,
194 wave_id: str | None = None,
195 + include_collections: bool = True,
196 ) -> None:
197 with self._lock:
198 projection = self._projections.get(identity)
199 if projection is None:
200 return
201 projection.dirty_version += 1
202 + if include_collections:
203 + projection.collections_dirty_version = projection.dirty_version
204 if runtime.is_development():
205 projection.dirty_reason = (
206 reason.strip()
@@ -228,6 +256,7 @@ class StateMonitor:
256 namespace, sid = identity
257 task = asyncio.current_task()
258 base_version = 0
259 + include_collections = True
260 dirty_reason: str | None = None
261 dirty_wave_id: str | None = None
262 try:
@@ -250,10 +279,17 @@ class StateMonitor:
279 if request is None:
280 return
281 base_version = projection.dirty_version
282 + include_collections = (
283 + not request.collections_delta
284 + or projection.collections_dirty_version > projection.pushed_version
285 + )
286 dirty_reason = projection.dirty_reason
287 dirty_wave_id = projection.dirty_wave_id
288
256 - snapshot = await build_snapshot_from_request(request=request)
289 + snapshot = await build_snapshot_from_request(
290 + request=request,
291 + include_collections=include_collections,
292 + )
293
294 with self._lock:
295 projection = self._projections.get(identity)
@@ -288,6 +324,7 @@ class StateMonitor:
324 ws_debug(
325 f"[StateMonitor] emit state_push namespace={namespace} sid={sid} seq={seq} "
326 f"context={request.context!r} logs_len={logs_len} "
327 + f"include_collections={include_collections} "
328 f"reason={dirty_reason!r} wave={dirty_wave_id!r}"
329 )
330 await manager.emit_to(
helpers/state_monitor.py.dox.md
+4 -2
@@ -17,9 +17,9 @@
17 - `register_sid(self, namespace: str, sid: str) -> None`
18 - `unregister_sid(self, namespace: str, sid: str) -> None`
19 - `mark_dirty_all(self, reason: str | None=...) -> None`
20 - - `mark_dirty_for_context(self, context_id: str, reason: str | None=...) -> None`
20 + - `mark_dirty_for_context(self, context_id: str, reason: str | None=..., include_collections: bool=...) -> None`
21 - `update_projection(self, namespace: str, sid: str, request: StateRequestV1, seq_base: int) -> None`
22 - - `mark_dirty(self, namespace: str, sid: str, reason: str | None=..., wave_id: str | None=...) -> None`
22 + - `mark_dirty(self, namespace: str, sid: str, reason: str | None=..., wave_id: str | None=..., include_collections: bool=...) -> None`
23 - Top-level functions:
24 - `get_state_monitor() -> StateMonitor`
25 - `_reset_state_monitor_for_testing() -> None`
@@ -35,6 +35,8 @@
35 ## Key Concepts
36
37 - Important called helpers/classes observed in the source: `threading.RLock`, `field`, `ws_debug`, `_ws_debug_enabled`, `context_id.strip`, `loop.call_soon_threadsafe`, `self._schedule_debounce_on_loop`, `asyncio.get_running_loop`, `asyncio.current_task`, `loop.is_closed`, `self._debounce_handles.pop`, `self._push_tasks.pop`, `self._projections.pop`, `self.mark_dirty`, `self._mark_dirty_on_loop`, `runtime.is_development`, `loop.call_later`, `StateMonitor`, `ConnectionProjection`, `handle.cancel`.
38 +- The default monitor coalesces dirty waves into at most one state push per 100 ms per connection without postponing an already scheduled push.
39 +- Collection-dirty versions are coalesced independently from ordinary stream dirties. Only clients that explicitly negotiate `collections_delta` may receive `contexts: null` and `tasks: null`; legacy clients remain on full snapshots.
40 - Keep request/response, tool, or helper semantics documented here at the same time as source changes.
41
42 ## Work Guidance
helpers/state_monitor_integration.py
+11 -2
@@ -7,7 +7,16 @@ def mark_dirty_all(*, reason: str | None = None) -> None:
7 get_state_monitor().mark_dirty_all(reason=reason)
8
9
10 -def mark_dirty_for_context(context_id: str, *, reason: str | None = None) -> None:
10 +def mark_dirty_for_context(
11 + context_id: str,
12 + *,
13 + reason: str | None = None,
14 + include_collections: bool = True,
15 +) -> None:
16 from helpers.state_monitor import get_state_monitor
17
13 - get_state_monitor().mark_dirty_for_context(context_id, reason=reason)
18 + get_state_monitor().mark_dirty_for_context(
19 + context_id,
20 + reason=reason,
21 + include_collections=include_collections,
22 + )
helpers/state_monitor_integration.py.dox.md
+2 -1
@@ -12,7 +12,7 @@
12 - `state_monitor_integration.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation.
13 - Top-level functions:
14 - `mark_dirty_all(reason: str | None=...) -> None`
15 -- `mark_dirty_for_context(context_id: str, reason: str | None=...) -> None`
15 +- `mark_dirty_for_context(context_id: str, reason: str | None=..., include_collections: bool=...) -> None`
16
17 ## Runtime Contracts
18
@@ -24,6 +24,7 @@
24 ## Key Concepts
25
26 - Important called helpers/classes observed in the source: `get_state_monitor.mark_dirty_all`, `get_state_monitor.mark_dirty_for_context`, `get_state_monitor`.
27 +- Global dirty waves always include context/task collections; high-frequency context-local owners may explicitly omit them when their mutation cannot change collection metadata.
28 - Keep request/response, tool, or helper semantics documented here at the same time as source changes.
29
30 ## Work Guidance
helpers/state_snapshot.py
+23 -12
@@ -3,7 +3,7 @@ from __future__ import annotations
3 import types
4 from typing import Any, Mapping, TypedDict, Union, get_args, get_origin, get_type_hints
5
6 -from dataclasses import dataclass
6 +from dataclasses import dataclass, replace
7
8 import pytz # type: ignore[import-untyped]
9
@@ -17,8 +17,8 @@ from helpers.task_scheduler import TaskScheduler
17 class SnapshotV1(TypedDict):
18 deselect_chat: bool
19 context: str
20 - contexts: list[dict[str, Any]]
21 - tasks: list[dict[str, Any]]
20 + contexts: list[dict[str, Any]] | None
21 + tasks: list[dict[str, Any]] | None
22 logs: list[dict[str, Any]]
23 log_guid: str
24 log_version: int
@@ -37,6 +37,7 @@ class StateRequestV1:
37 log_from: int
38 notifications_from: int
39 timezone: str
40 + collections_delta: bool = False
41
42
43 class StateRequestValidationError(ValueError):
@@ -159,6 +160,7 @@ def parse_state_request_payload(payload: Mapping[str, Any]) -> StateRequestV1:
160 log_from = payload.get("log_from")
161 notifications_from = payload.get("notifications_from")
162 timezone = payload.get("timezone")
163 + collections_delta = payload.get("collections_delta", False)
164
165 if context is not None and not isinstance(context, str):
166 raise StateRequestValidationError(
@@ -184,6 +186,12 @@ def parse_state_request_payload(payload: Mapping[str, Any]) -> StateRequestV1:
186 message="timezone must be a non-empty string",
187 details={"timezone": timezone},
188 )
189 + if not isinstance(collections_delta, bool):
190 + raise StateRequestValidationError(
191 + reason="collections_delta_type",
192 + message="collections_delta must be a boolean",
193 + details={"collections_delta_type": type(collections_delta).__name__},
194 + )
195
196 tz = timezone.strip()
197 try:
@@ -203,6 +211,7 @@ def parse_state_request_payload(payload: Mapping[str, Any]) -> StateRequestV1:
211 log_from=log_from,
212 notifications_from=notifications_from,
213 timezone=tz,
214 + collections_delta=collections_delta,
215 )
216
217
@@ -245,15 +254,16 @@ def advance_state_request_after_snapshot(
254 except (TypeError, ValueError):
255 pass
256
248 - return StateRequestV1(
249 - context=request.context,
257 + return replace(
258 + request,
259 log_from=log_from,
260 notifications_from=notifications_from,
252 - timezone=request.timezone,
261 )
262
263
256 -async def build_snapshot_from_request(*, request: StateRequestV1) -> SnapshotV1:
264 +async def build_snapshot_from_request(
265 + *, request: StateRequestV1, include_collections: bool = True
266 +) -> SnapshotV1:
267 """Build a poll-shaped snapshot for both /poll and state_push."""
268
269 localization = Localization.get()
@@ -269,7 +279,8 @@ async def build_snapshot_from_request(*, request: StateRequestV1) -> SnapshotV1:
279 from_no = _coerce_non_negative_int(request.log_from, default=0)
280 notifications_from_no = _coerce_non_negative_int(request.notifications_from, default=0)
281
272 - _prune_missing_saved_contexts()
282 + if include_collections:
283 + _prune_missing_saved_contexts()
284
285 active_context = AgentContext.get(ctxid) if ctxid else None
286
@@ -291,9 +302,9 @@ async def build_snapshot_from_request(*, request: StateRequestV1) -> SnapshotV1:
302 ctxs: list[dict[str, Any]] = []
303 tasks: list[dict[str, Any]] = []
304 processed_contexts: set[str] = set()
294 - agent_profile_labels = _get_agent_profile_labels()
305 + agent_profile_labels = _get_agent_profile_labels() if include_collections else {}
306
296 - all_ctxs = AgentContext.all()
307 + all_ctxs = AgentContext.all() if include_collections else []
308 for ctx in all_ctxs:
309 if ctx.id in processed_contexts:
310 continue
@@ -345,8 +356,8 @@ async def build_snapshot_from_request(*, request: StateRequestV1) -> SnapshotV1:
356 snapshot: SnapshotV1 = {
357 "deselect_chat": bool(ctxid) and active_context is None,
358 "context": active_context.id if active_context else "",
348 - "contexts": ctxs,
349 - "tasks": tasks,
359 + "contexts": ctxs if include_collections else None,
360 + "tasks": tasks if include_collections else None,
361 "logs": logs,
362 "log_guid": active_context.log.guid if active_context else "",
363 "log_version": log_end,
helpers/state_snapshot.py.dox.md
+2 -1
@@ -25,7 +25,7 @@
25 - `parse_state_request_payload(payload: Mapping[str, Any]) -> StateRequestV1`
26 - `_coerce_state_request_inputs(context: Any, log_from: Any, notifications_from: Any, timezone: Any) -> StateRequestV1`
27 - `advance_state_request_after_snapshot(request: StateRequestV1, snapshot: Mapping[str, Any]) -> StateRequestV1`
28 -- `async build_snapshot_from_request(request: StateRequestV1) -> SnapshotV1`: Build a poll-shaped snapshot for both /poll and state_push.
28 +- `async build_snapshot_from_request(request: StateRequestV1, include_collections: bool=...) -> SnapshotV1`: Build a poll-shaped snapshot for both /poll and state_push.
29 - `_notify_timezone_changed(previous_timezone: str, current_timezone: str) -> None`
30 - `async build_snapshot(context: str | None, log_from: int, notifications_from: int, timezone: str | None) -> SnapshotV1`
31 - Notable constants/configuration names: `_SNAPSHOT_V1_SCHEMA`, `SNAPSHOT_SCHEMA_V1_KEYS`.
@@ -42,6 +42,7 @@
42 - Important called helpers/classes observed in the source: `dataclass`, `_build_schema_from_typeddict`, `get_origin`, `timezone.strip`, `StateRequestV1`, `localization.get_timezone`, `localization.set_timezone`, `ctxid.strip`, `_coerce_non_negative_int`, `AgentContext.get_notification_manager`, `notification_manager.output`, `_get_agent_profile_labels`, `ctxs.sort`, `tasks.sort`, `validate_snapshot_schema_v1`, `_coerce_state_request_inputs`, `super.__init__`, `get_args`, `_annotation_to_isinstance_types`, `TypeError`.
43 - Snapshot building prunes non-running in-memory contexts that were previously saved but no longer have a `chat.json`, preventing stale sidebar rows after chat files are deleted outside `/chat_remove`.
44 - Notification payloads use the manager's matching GUID and cursor from the same atomic read, preventing a concurrent notification from being skipped by the WebUI.
45 +- `StateRequestV1.collections_delta` is an optional, false-by-default capability. Negotiated state pushes may use `null` for both `contexts` and `tasks` when those collections are unchanged; HTTP polling and legacy WebSocket clients always receive full lists.
46 - Keep request/response, tool, or helper semantics documented here at the same time as source changes.
47
48 ## Work Guidance
tests/test_defer_lifecycle.py
+36
@@ -48,6 +48,42 @@ def test_completed_task_releases_call_references_and_children():
48 task.kill(terminate_thread=True)
49
50
51 +def test_run_task_end_extension_marks_state_dirty_after_completion(monkeypatch):
52 + from extensions.python._functions.agent.AgentContext.run_task.end import (
53 + _10_mark_state_dirty as task_done_extension,
54 + )
55 +
56 + task = make_task()
57 + callback_called = threading.Event()
58 + observations: list[tuple[str | None, bool]] = []
59 +
60 + def mark_dirty(*, reason=None):
61 + observations.append((reason, bool(task.is_alive())))
62 + callback_called.set()
63 +
64 + monkeypatch.setattr(
65 + task_done_extension,
66 + "mark_dirty_all",
67 + mark_dirty,
68 + )
69 +
70 + async def run():
71 + return "done"
72 +
73 + try:
74 + with pytest.raises(RuntimeError, match="Task hasn't been started"):
75 + task.add_done_callback(lambda _future: None)
76 + task.start_task(run)
77 + task_done_extension.MarkStateDirty(agent=None).execute(
78 + data={"result": task}
79 + )
80 + assert task.result_sync(timeout=2) == "done"
81 + assert callback_called.wait(2)
82 + assert observations == [("agent.AgentContext.run_task_done", False)]
83 + finally:
84 + task.kill(terminate_thread=True)
85 +
86 +
87 def test_kill_clears_stored_call_without_clearing_running_arguments():
88 task = make_task()
89 owner = Owner()
tests/test_multi_tab_isolation.py
+1 -1
@@ -20,7 +20,7 @@ async def test_state_monitor_per_sid_isolation_independent_snapshots_seq_and_cur
20
21 namespace = "/ws"
22
23 - async def fake_build_snapshot_from_request(*, request):
23 + async def fake_build_snapshot_from_request(*, request, include_collections=True):
24 context = request.context
25 log_from = request.log_from
26 notifications_from = request.notifications_from
tests/test_parallel_tool.py
+3 -3
@@ -1154,9 +1154,9 @@ def test_chats_sidebar_projects_parallel_children_as_indented_accordion() -> Non
1154 )
1155
1156 assert "parent_context_id" in store
1157 - assert "const nextExpandedParents = { ...this.expandedParents };" in store
1158 - assert "nextExpandedParents[selectedId] === undefined" in store
1159 - assert "nextExpandedParents[selectedId] = true;" in store
1157 + assert "this.expandedParents[selectedId] === undefined" in store
1158 + assert "...this.expandedParents," in store
1159 + assert "[selectedId]: true," in store
1160 assert "topLevelContexts()" in html
1161 assert "childContexts(context.id)" in html
1162 assert "chat-child-container" in html
tests/test_plain_response_logging.py
+43 -1
@@ -23,6 +23,47 @@ def _agent_with_generating_log():
23 return agent, item
24
25
26 +def test_log_keyword_updates_share_kvp_value_limit():
27 + item = Log().log(type="agent", heading="A0: Reasoning")
28 +
29 + item.update(reasoning="r" * 6_000, finished=True)
30 +
31 + assert len(item.kvps["reasoning"]) <= 5_000
32 + assert "Characters hidden" in item.kvps["reasoning"]
33 + assert item.kvps["finished"] is True
34 +
35 +
36 +def test_live_log_updates_omit_unchanged_collections(monkeypatch):
37 + import helpers.log as log_module
38 +
39 + full_dirty: list[str | None] = []
40 + context_dirty: list[tuple[str, str | None, bool]] = []
41 + monkeypatch.setattr(
42 + log_module,
43 + "_MARK_DIRTY_ALL",
44 + lambda *, reason=None: full_dirty.append(reason),
45 + )
46 + monkeypatch.setattr(
47 + log_module,
48 + "_MARK_DIRTY_FOR_CONTEXT",
49 + lambda context_id, *, reason=None, include_collections=True: context_dirty.append(
50 + (context_id, reason, include_collections)
51 + ),
52 + )
53 +
54 + log = Log()
55 + log.context = SimpleNamespace(id="ctx", streaming_agent=None)
56 + item = log.log(type="agent", heading="Calling LLM")
57 + item.update(content="stream update")
58 + log.set_progress("Receiving")
59 +
60 + assert full_dirty == ["log.Log._notify_state_monitor"]
61 + assert context_dirty == [
62 + ("ctx", "log.Log._update_item", False),
63 + ("ctx", "log.Log.set_progress", False),
64 + ]
65 +
66 +
67 def test_responses_plain_text_completion_finishes_generating_log_as_response():
68 agent, item = _agent_with_generating_log()
69 data = {
@@ -89,7 +130,7 @@ def test_responses_plain_text_completion_does_not_replace_live_response_log():
130
131
132 @pytest.mark.asyncio
92 -async def test_live_response_renders_single_action_wrapper():
133 +async def test_live_response_renders_single_unfinished_action_wrapper():
134 log = Log()
135 generating = log.log(type="agent", id="msg-1")
136 loop_data = SimpleNamespace(params_temporary={"log_item_generating": generating})
@@ -111,6 +152,7 @@ async def test_live_response_renders_single_action_wrapper():
152 assert response.type == "response"
153 assert response.content == "wrapper works"
154 assert response.id == "msg-1"
155 + assert response.kvps["finished"] is False
156
157
158 @pytest.mark.asyncio
tests/test_snapshot_schema_v1.py
+46
@@ -86,6 +86,52 @@ async def test_snapshot_builder_produces_contract_schema_key_set_and_defaults():
86 assert payload["notifications_version"] >= 0
87
88
89 +@pytest.mark.asyncio
90 +async def test_negotiated_incremental_snapshot_uses_null_collection_sentinel():
91 + from helpers import state_snapshot as snapshot
92 +
93 + request = snapshot.StateRequestV1(
94 + context=None,
95 + log_from=0,
96 + notifications_from=0,
97 + timezone="UTC",
98 + collections_delta=True,
99 + )
100 + payload = await snapshot.build_snapshot_from_request(
101 + request=request,
102 + include_collections=False,
103 + )
104 +
105 + snapshot.validate_snapshot_schema_v1(payload)
106 + assert set(payload) == EXPECTED_SNAPSHOT_KEYS
107 + assert payload["contexts"] is None
108 + assert payload["tasks"] is None
109 +
110 +
111 +def test_state_request_collection_delta_is_optional_and_type_checked():
112 + from helpers import state_snapshot as snapshot
113 +
114 + base = {
115 + "context": None,
116 + "log_from": 0,
117 + "notifications_from": 0,
118 + "timezone": "UTC",
119 + }
120 +
121 + assert snapshot.parse_state_request_payload(base).collections_delta is False
122 + assert (
123 + snapshot.parse_state_request_payload(
124 + {**base, "collections_delta": True}
125 + ).collections_delta
126 + is True
127 + )
128 + with pytest.raises(snapshot.StateRequestValidationError) as error:
129 + snapshot.parse_state_request_payload(
130 + {**base, "collections_delta": "yes"}
131 + )
132 + assert error.value.reason == "collections_delta_type"
133 +
134 +
135 def test_snapshot_schema_rejects_unexpected_top_level_keys():
136 from helpers import state_snapshot as snapshot
137
tests/test_state_monitor.py
+99
@@ -8,6 +8,12 @@ if str(PROJECT_ROOT) not in sys.path:
8 sys.path.insert(0, str(PROJECT_ROOT))
9
10
11 +def test_state_monitor_defaults_to_ten_pushes_per_second() -> None:
12 + from helpers.state_monitor import StateMonitor
13 +
14 + assert StateMonitor().debounce_seconds == 0.1
15 +
16 +
17 @pytest.mark.asyncio
18 async def test_state_monitor_debounce_coalesces_without_postponing_and_cleanup_cancels_pending():
19 from helpers.state_monitor import StateMonitor
@@ -101,3 +107,96 @@ async def test_state_monitor_namespace_identity_prevents_cross_namespace_state_p
107
108 assert captured
109 assert all(ns == ns_a for ns, _ in captured)
110 +
111 +
112 +@pytest.mark.asyncio
113 +async def test_collection_delta_tracks_full_and_stream_dirty_waves(monkeypatch) -> None:
114 + import asyncio
115 +
116 + import helpers.state_monitor as state_monitor_module
117 + from helpers.state_monitor import StateMonitor
118 + from helpers.state_snapshot import StateRequestV1
119 +
120 + namespace = "/ws"
121 + sid = "sid-delta"
122 + identity = (namespace, sid)
123 + include_calls: list[bool] = []
124 + emitted: list[dict] = []
125 +
126 + async def fake_snapshot(*, request, include_collections=True):
127 + include_calls.append(include_collections)
128 + return {
129 + "deselect_chat": False,
130 + "context": request.context or "",
131 + "contexts": [] if include_collections else None,
132 + "tasks": [] if include_collections else None,
133 + "logs": [],
134 + "log_guid": "guid",
135 + "log_version": request.log_from,
136 + "log_progress": "",
137 + "log_progress_active": False,
138 + "paused": False,
139 + "notifications": [],
140 + "notifications_guid": "notifications",
141 + "notifications_version": request.notifications_from,
142 + }
143 +
144 + class FakeManager:
145 + def __init__(self, loop):
146 + self._dispatcher_loop = loop
147 +
148 + async def emit_to(self, _namespace, _sid, _event_type, payload, **_kwargs):
149 + emitted.append(payload["snapshot"])
150 +
151 + monitor = StateMonitor(debounce_seconds=60.0)
152 + monitor.bind_manager(FakeManager(asyncio.get_running_loop()))
153 + monitor.register_sid(namespace, sid)
154 + monitor.update_projection(
155 + namespace,
156 + sid,
157 + request=StateRequestV1(
158 + context="ctx",
159 + log_from=0,
160 + notifications_from=0,
161 + timezone="UTC",
162 + collections_delta=True,
163 + ),
164 + seq_base=1,
165 + )
166 + monkeypatch.setattr(
167 + state_monitor_module,
168 + "build_snapshot_from_request",
169 + fake_snapshot,
170 + )
171 +
172 + async def flush() -> None:
173 + handle = monitor._debounce_handles.pop(identity)
174 + handle.cancel()
175 + await monitor._flush_push(identity)
176 +
177 + monitor.mark_dirty(namespace, sid, include_collections=False)
178 + await flush()
179 +
180 + monitor.mark_dirty(namespace, sid, include_collections=False)
181 + monitor.mark_dirty(namespace, sid, include_collections=True)
182 + await flush()
183 +
184 + monitor.update_projection(
185 + namespace,
186 + sid,
187 + request=StateRequestV1(
188 + context="ctx",
189 + log_from=0,
190 + notifications_from=0,
191 + timezone="UTC",
192 + ),
193 + seq_base=1,
194 + )
195 + monitor.mark_dirty(namespace, sid, include_collections=False)
196 + await flush()
197 +
198 + assert include_calls == [False, True, True]
199 + assert emitted[0]["contexts"] is None
200 + assert emitted[0]["tasks"] is None
201 + assert emitted[1]["contexts"] == []
202 + assert emitted[2]["contexts"] == []
tests/test_state_sync_handler.py
+6
@@ -55,6 +55,8 @@ async def _create_manager_with_socketio() -> tuple[WsManager, "WsWebui", FakeSoc
55
56 @pytest.mark.asyncio
57 async def test_state_request_success_returns_wire_level_shape_and_contract_payload():
58 + from helpers.state_monitor import get_state_monitor
59 +
60 _manager, handler = await _create_manager()
61
62 result = await handler.process(
@@ -65,6 +67,7 @@ async def test_state_request_success_returns_wire_level_shape_and_contract_paylo
67 "log_from": 0,
68 "notifications_from": 0,
69 "timezone": "UTC",
70 + "collections_delta": True,
71 },
72 "sid-1",
73 )
@@ -73,6 +76,9 @@ async def test_state_request_success_returns_wire_level_shape_and_contract_paylo
76 assert set(result.keys()) >= {"runtime_epoch", "seq_base"}
77 assert isinstance(result["runtime_epoch"], str) and result["runtime_epoch"]
78 assert isinstance(result["seq_base"], int)
79 + projection = get_state_monitor()._projections[(NAMESPACE, "sid-1")]
80 + assert projection.request is not None
81 + assert projection.request.collections_delta is True
82
83
84 @pytest.mark.asyncio
tests/test_webui_chat_deletion.py
+10 -2
@@ -78,10 +78,18 @@ assert(
78 model.contexts === unchangedContexts,
79 "unchanged snapshots must preserve the Alpine contexts array",
80 );
81 +const unchangedFirstRow = model.contexts[0];
82 model.applyContexts([{{ ...chats[0], name: "Renamed" }}, ...chats.slice(1)]);
83 assert(
83 - model.contexts !== unchangedContexts && model.contexts[0].name === "Renamed",
84 - "changed context metadata must replace the contexts array",
84 + model.contexts === unchangedContexts &&
85 + model.contexts[0] === unchangedFirstRow &&
86 + model.contexts[0].name === "Renamed",
87 + "changed context metadata must update its existing row in place",
88 +);
89 +model.applyContexts([...chats, {{ id: "d", created_at: 40 }}]);
90 +assert(
91 + model.contexts !== unchangedContexts && model.contexts[0].id === "d",
92 + "structural context changes must replace and reorder the contexts array",
93 );
94
95 const tree = [
tests/test_webui_message_ordering_static.py
+8
@@ -19,6 +19,14 @@ def test_full_log_replays_replace_existing_message_dom():
19 assert "normalized.sort(" in messages_js
20
21
22 +def test_unfinished_root_responses_defer_markdown_rendering():
23 + messages_js = read("webui", "js", "messages.js")
24 +
25 + assert "const renderMarkdown = kvps?.finished !== false;" in messages_js
26 + assert "markdown: renderMarkdown," in messages_js
27 + assert "latex: renderMarkdown," in messages_js
28 +
29 +
30 def test_message_ordering_uses_a_bounded_tail_first_renderer_cache():
31 messages_js = read("webui", "js", "messages.js")
32 message_window_js = read("webui", "js", "message-window.js")
tests/test_webui_message_window.py
+14 -1
@@ -77,10 +77,21 @@ assert(
77 );
78
79 const sharedIdGroup = new MessageWindow({{ initialLimit: 60 }});
80 -sharedIdGroup.reset([
80 +const sharedIdKeys = sharedIdGroup.merge([
81 {{ no: 1, id: "shared-run-id", type: "agent", content: "final generation" }},
82 {{ no: 2, id: "shared-run-id", type: "response", content: "final response" }},
83 ]);
84 +assert(
85 + sharedIdKeys.has("id:shared-run-id:type:response"),
86 + "merge must report a newly added step",
87 +);
88 +const updatedSharedIdKeys = sharedIdGroup.merge([
89 + {{ no: 2, id: "shared-run-id", type: "response", content: "updated response" }},
90 +]);
91 +assert(
92 + updatedSharedIdKeys.size === 0,
93 + "merge must not report an existing record update as newly added",
94 +);
95 assert(sharedIdGroup.size === 2, "a shared id must not merge GEN and response records");
96 assert(
97 sharedIdGroup.visibleMessages().map((entry) => entry.type).join(",") ===
@@ -329,6 +340,8 @@ def test_process_groups_are_atomic_and_page_steps_in_fifties():
340 assert '"code_exe",' in MESSAGE_WINDOW_JS.read_text(encoding="utf-8")
341 assert "getUnitKeys: getMessageRenderUnitKeys" in messages
342 assert "getProcessGroupRenderMessages(windowMessages)" in messages
343 + assert "const addedMessageKeys = _messageWindow.merge" in messages
344 + assert "addedMessageKeys.has(getMessageCacheKey(message))" in messages
345 assert 'button.className = "process-group-show-more"' in messages
346 assert "current + PROCESS_GROUP_STEP_PAGE_SIZE" in messages
347 assert "group.dataset.fullStartTimestamp" in messages
tests/test_ws_client_api_surface.py
+27
@@ -39,3 +39,30 @@ def test_websocket_js_exports_minimal_namespaced_api_surface() -> None:
39
40 assert "broadcast" not in exports
41 assert "requestAll" not in exports
42 +
43 +
44 +def test_completed_state_push_cannot_overwrite_disconnected_mode() -> None:
45 + source = (
46 + PROJECT_ROOT / "webui" / "components" / "sync" / "sync-store.js"
47 + ).read_text(encoding="utf-8")
48 +
49 + apply_end = source.split("await applySnapshot(data.snapshot", 1)[1].split(
50 + 'this._setMode(SYNC_MODES.HEALTHY, "push applied");', 1
51 + )[0]
52 + assert "if (!stateSocket.isConnected()) return;" in apply_end
53 +
54 +
55 +def test_partial_snapshot_retains_sidebar_collections_and_extension_shape() -> None:
56 + source = (PROJECT_ROOT / "webui" / "index.js").read_text(encoding="utf-8")
57 + request_builder = source.split(
58 + "export function buildStateRequestPayload", 1
59 + )[1].split("export async function applySnapshot", 1)[0]
60 +
61 + assert "collections_delta: true" in request_builder
62 + assert "const hasCollections =" in source
63 + assert "Array.isArray(snapshot.contexts) && Array.isArray(snapshot.tasks)" in source
64 + assert "snapshot: extensionSnapshot" in source
65 + assert "contexts: chatsStore.contexts" in source
66 + assert "tasks: tasksStore.tasks" in source
67 + assert "if (hasCollections)" in source
68 + assert "snapshot.contexts || []" not in source
webui/components/sidebar/AGENTS.md
+1 -1
@@ -32,7 +32,7 @@
32 - The utility-message preference controls both individual utility steps and utility-only process-group chrome so hidden utility runs cannot leave empty headers in the transcript.
33 - Chat deletion removes the sidebar row optimistically in the same render batch as fallback selection. Keep successful local deletion tombstones for the page session so out-of-order poll or push snapshots cannot reinsert rows; restore the row and clear its tombstone if the delete request fails.
34 - Chat selection must synchronize the sidebar store even when the low-level context has already switched to the requested ID.
35 -- Unchanged context snapshots preserve the Alpine contexts-array identity to avoid chat-list reconciliation, while selection and parent-expansion synchronization still run; changed metadata and deletion tombstones must still replace the visible list.
35 +- Context snapshots preserve the Alpine contexts-array and row identities while their order is stable, updating changed row metadata in place so streaming log counters do not reconcile the whole chat list. Additions, removals, reordering, and deletion tombstones must still replace the visible list; selection and parent-expansion synchronization must not publish unchanged state.
36
37 ## Work Guidance
38
webui/components/sidebar/chats/chats-store.js
+28 -7
@@ -65,7 +65,22 @@ const model = {
65 const contextsJson = JSON.stringify(nextContexts);
66 if (contextsJson !== this.contextsJson) {
67 this.contextsJson = contextsJson;
68 - this.contexts = nextContexts;
68 + const sameRows =
69 + nextContexts.length === this.contexts.length &&
70 + nextContexts.every((context, index) => context?.id === this.contexts[index]?.id);
71 +
72 + if (sameRows) {
73 + nextContexts.forEach((context, index) => {
74 + const current = this.contexts[index];
75 + if (JSON.stringify(current) === JSON.stringify(context)) return;
76 + Object.keys(current).forEach((key) => {
77 + if (!(key in context)) delete current[key];
78 + });
79 + Object.assign(current, context);
80 + });
81 + } else {
82 + this.contexts = nextContexts;
83 + }
84 }
85
86 // Keep selectedContext in sync when the currently selected context's
@@ -74,17 +89,23 @@ const model = {
89 const selectedId = this.selected;
90 const updated = this.contexts.find((ctx) => ctx.id === selectedId);
91 if (updated) {
77 - this.selectedContext = updated;
78 - const nextExpandedParents = { ...this.expandedParents };
92 + if (this.selectedContext !== updated) this.selectedContext = updated;
93 if (updated.parent_context_id) {
80 - nextExpandedParents[updated.parent_context_id] = true;
94 + if (!this.expandedParents[updated.parent_context_id]) {
95 + this.expandedParents = {
96 + ...this.expandedParents,
97 + [updated.parent_context_id]: true,
98 + };
99 + }
100 } else if (
101 this.hasChildren(selectedId) &&
83 - nextExpandedParents[selectedId] === undefined
102 + this.expandedParents[selectedId] === undefined
103 ) {
85 - nextExpandedParents[selectedId] = true;
104 + this.expandedParents = {
105 + ...this.expandedParents,
106 + [selectedId]: true,
107 + };
108 }
87 - this.expandedParents = nextExpandedParents;
109 }
110 }
111 },
webui/components/sync/AGENTS.md
+2
@@ -13,6 +13,8 @@
13 ## Local Contracts
14
15 - Keep sync state compatible with WebSocket state-sync events.
16 +- A queued state push that finishes after transport loss must not overwrite the
17 + `DISCONNECTED` mode or flush reconnect notifications.
18 - Avoid noisy user-facing alerts for transient sync state unless existing UX expects them.
19 - Keep the compact status cluster free of native title tooltips; interactive
20 extensions must provide accessible names directly.
webui/components/sync/sync-store.js
+1
@@ -499,6 +499,7 @@ const model = {
499 await this.sendStateRequest({ forceFull: true });
500 },
501 });
502 + if (!stateSocket.isConnected()) return;
503 this._setMode(SYNC_MODES.HEALTHY, "push applied");
504 await this._flushPendingReconnectToast();
505 }
webui/index.js
+34 -25
@@ -355,6 +355,7 @@ export function buildStateRequestPayload(options = {}) {
355 log_from: forceFull ? 0 : lastLogVersion,
356 notifications_from: forceFull ? 0 : notificationStore.lastNotificationVersion || 0,
357 timezone,
358 + collections_delta: true,
359 };
360 }
361
@@ -382,8 +383,17 @@ export async function applySnapshot(snapshot, options = {}) {
383 return { updated: false };
384 }
385
386 + const hasCollections =
387 + Array.isArray(snapshot.contexts) && Array.isArray(snapshot.tasks);
388 + const extensionSnapshot = hasCollections
389 + ? snapshot
390 + : {
391 + ...snapshot,
392 + contexts: chatsStore.contexts,
393 + tasks: tasksStore.tasks,
394 + };
395 const snapCtx = {
386 - snapshot,
396 + snapshot: extensionSnapshot,
397 willUpdateMessages: lastLogVersion != snapshot.log_version,
398 skip: false,
399 };
@@ -433,25 +443,25 @@ export async function applySnapshot(snapshot, options = {}) {
443 setConnectionStatus(true);
444 }
445
436 - // Update chats list using store
437 - let contexts = snapshot.contexts || [];
438 - chatsStore.applyContexts(contexts);
446 + if (hasCollections) {
447 + // Update chats list using store
448 + chatsStore.applyContexts(snapshot.contexts);
449
440 - // Update tasks list using store
441 - let tasks = snapshot.tasks || [];
442 - tasksStore.applyTasks(tasks);
450 + // Update tasks list using store
451 + tasksStore.applyTasks(snapshot.tasks);
452
444 - // Make sure the active context is properly selected in both lists
445 - if (context) {
446 - // Update selection in both stores
447 - chatsStore.setSelected(context);
453 + // Make sure the active context is properly selected in both lists
454 + // Leave an empty selection unchanged so the welcome screen stays visible.
455 + if (context) {
456 + // Update selection in both stores
457 + chatsStore.setSelected(context);
458
449 - const contextInChats = chatsStore.contains(context);
450 - const contextInTasks = tasksStore.contains(context);
459 + const contextInChats = chatsStore.contains(context);
460 + const contextInTasks = tasksStore.contains(context);
461
452 - if (contextInTasks) {
453 - tasksStore.setSelected(context);
454 - }
462 + if (contextInTasks) {
463 + tasksStore.setSelected(context);
464 + }
465
466 if (!contextInChats && !contextInTasks) {
467 if (chatsStore.contexts.length > 0) {
@@ -466,19 +476,18 @@ export async function applySnapshot(snapshot, options = {}) {
476 deselectChat();
477 }
478 }
469 - } else {
470 - // No context selected: keep it that way so the welcome screen stays visible.
479 }
480 + }
481
473 - // update message queue
474 - messageQueueStore.updateFromPoll();
482 + // update message queue
483 + messageQueueStore.updateFromPoll();
484
476 - // A context switch is visually complete only after its matching snapshot
477 - // has rendered and the surrounding chat state has been synchronized.
478 - finishChatLoading(snapshot.context);
485 + // A context switch is visually complete only after its matching snapshot
486 + // has rendered and the surrounding chat state has been synchronized.
487 + finishChatLoading(snapshot.context);
488
480 - return { updated };
481 - }
489 + return { updated };
490 +}
491
492 export async function poll() {
493 try {
webui/js/AGENTS.md
+3 -1
@@ -53,7 +53,9 @@
53 - Convert standard TeX delimiters before Markdown parsing without touching inline or fenced code. Keep thought-card math rendering local to the agent-message handler rather than adding math flags to generic process-step or key/value rendering.
54 - Do not expose secrets in localStorage, console logs, URLs, or WebSocket payloads.
55 - Full message snapshots that start at backend log `no` 0 must replace the current message DOM before rendering; incremental snapshots should keep patching existing messages.
56 -- Long histories stay cached as raw log data but render a contiguous tail-first DOM window. The initial base view contains one 60-entry page; after paging, the base window contains two aligned pages, retaining the adjacent page and discarding only the far page in either direction. Visible boundaries expand to whole logical process groups so a page never reconstructs a partial group; the unit classifier must include plugin-backed process steps such as `code_exe`, and oversized groups use their own 50-step incremental window. Paging must preserve a visible anchor and occur at the scroll boundary after user intent, using passive loading indicators rather than count-bearing controls. Live entries and late content growth follow the tail until the reader deliberately moves away; historical window rebuilds must cancel pending auto-scroll effects, render in an off-screen staging history, and atomically swap fully laid-out content into the live scroller before restoring its anchor.
56 +- The state request builder advertises collection-delta support. An incremental snapshot may carry `contexts: null` and `tasks: null`; retain the current stores and skip sidebar selection/fallback reconciliation in that case. Extension hooks still receive the cached full collections so existing plugin contracts remain list-shaped.
57 +- Root responses with explicit `finished: false` render as escaped plain text while streaming, then switch to Markdown and LaTeX when finished; legacy responses without the flag remain formatted.
58 +- Long histories stay cached as raw log data but render a contiguous tail-first DOM window. The initial base view contains one 60-entry page; after paging, the base window contains two aligned pages, retaining the adjacent page and discarding only the far page in either direction. Visible boundaries expand to whole logical process groups so a page never reconstructs a partial group; the unit classifier must include plugin-backed process steps such as `code_exe`, and oversized groups use their own 50-step incremental window. A capped process group rebuilds only when a newly added step advances that window; updates to an existing step patch it in place. Paging must preserve a visible anchor and occur at the scroll boundary after user intent, using passive loading indicators rather than count-bearing controls. Live entries and late content growth follow the tail until the reader deliberately moves away; historical window rebuilds must cancel pending auto-scroll effects, render in an off-screen staging history, and atomically swap fully laid-out content into the live scroller before restoring its anchor.
59 - Message-window cache identity must keep different log types distinct even when they share a backend ID; root-agent GEN and response records intentionally use the same ID and must both survive replay, while same-ID/same-type updates still replace their earlier cached version.
60 - Utility records join a process render unit only when a substantive process step follows before the next standalone boundary. Group visibility must use that full-log classification rather than infer utility-only state from partially mounted DOM children. Standalone utility-only runs must not wrap root responses or reopen completed groups, and their group chrome stays hidden unless utility messages are enabled.
61 - Warnings classified into a full-log process render unit must remain process steps during replay even when the current DOM tail is already complete; use the live DOM tail only when render metadata is unavailable.
webui/js/message-window.js
+3
@@ -178,6 +178,7 @@ export class MessageWindow {
178 const previousStartKey = this._records[this.start]?.key || null;
179 const previousEndKey = this._records[this.end - 1]?.key || null;
180 const wasAtTail = followTail && this.end >= this._records.length;
181 + const addedKeys = new Set();
182 let requiresSort = false;
183
184 for (const message of Array.isArray(messages) ? messages : []) {
@@ -202,6 +203,7 @@ export class MessageWindow {
203 this._recordsByKey.set(key, record);
204 this._indexByKey.set(key, this._records.length);
205 this._records.push(record);
206 + addedKeys.add(key);
207 }
208 }
209
@@ -226,6 +228,7 @@ export class MessageWindow {
228 this.end = previousEnd >= 0 ? previousEnd + 1 : this.end;
229 this._clampBounds();
230 }
231 + return addedKeys;
232 }
233
234 showTail() {
webui/js/messages.js
+7 -5
@@ -205,7 +205,7 @@ async function setMessagesNow(messages, generation) {
205 const history = getChatHistoryEl();
206 const followTail = shouldFollowMessageTail();
207
208 - _messageWindow.merge(messages, { followTail });
208 + const addedMessageKeys = _messageWindow.merge(messages, { followTail });
209 bindMessageWindow(history);
210 if (_messageWindowRenderPromise) await _messageWindowRenderPromise;
211
@@ -217,6 +217,7 @@ async function setMessagesNow(messages, generation) {
217 const cappedProcessGroupUpdate = hasCappedProcessGroupUpdate(
218 messages,
219 windowMessages,
220 + addedMessageKeys,
221 );
222 if (initialWindow || compactedTail || cappedProcessGroupUpdate) {
223 return await renderMessageWindow({
@@ -661,7 +662,7 @@ function getProcessGroupRenderMessages(messages) {
662 });
663 }
664
664 -function hasCappedProcessGroupUpdate(messages, windowMessages) {
665 +function hasCappedProcessGroupUpdate(messages, windowMessages, addedMessageKeys) {
666 if (!messages.length) return false;
667 const groupStates = getProcessGroupPageState(windowMessages);
668 return messages.some((message) => {
@@ -670,7 +671,7 @@ function hasCappedProcessGroupUpdate(messages, windowMessages) {
671 const total = groupStates.get(group.key)?.steps.length || 0;
672 const limit = _processGroupStepLimits.get(group.key) ||
673 PROCESS_GROUP_STEP_PAGE_SIZE;
673 - return total > limit;
674 + return total > limit && addedMessageKeys.has(getMessageCacheKey(message));
675 });
676 }
677
@@ -1995,6 +1996,7 @@ export function drawMessageResponse({
1996 // no container or valid process group, create new container
1997 if (!container) container = getOrCreateMessageContainer(id, "left");
1998
1999 + const renderMarkdown = kvps?.finished !== false;
2000 const messageDiv = _drawMessage({
2001 messageContainer: container,
2002 heading: undefined,
@@ -2002,8 +2004,8 @@ export function drawMessageResponse({
2004 kvps: undefined,
2005 messageClasses: [],
2006 contentClasses: [],
2005 - markdown: true,
2006 - latex: true,
2007 + markdown: renderMarkdown,
2008 + latex: renderMarkdown,
2009 mainClass: "message-agent-response",
2010 smoothStream: false, // smooth render disabled, not reliable yet !isMassRender(), // stream smoothly if not in mass render mode
2011 });