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
});