Fix notifications skipped during state snapshots

Capture each notification payload together with its GUID and update cursor under the notification manager lock. This prevents concurrent notify_user events from advancing the WebUI cursor without delivering the corresponding toast.\n\nAdd regression coverage for notification cursor continuity and update the helper contracts.

Alessandro committed Jul 12, 2026 at 12:46 UTC 5c1d50f92888ef007798e23455732330310cccec
5 files changed +34 -12
helpers/notification.py
+12 -9
@@ -156,21 +156,24 @@ class NotificationManager:
156 return [n for n in self.notifications if n.timestamp >= cutoff]
157
158 def output(self, start: int | None = None, end: int | None = None) -> list[dict]:
159 + return self.output_with_state(start, end)[0]
160 +
161 + def output_with_state(
162 + self, start: int | None = None, end: int | None = None
163 + ) -> tuple[list[dict], str, int]:
164 with self._lock:
165 if start is None:
166 start = 0
167 if end is None:
168 end = len(self.updates)
169 updates = self.updates[start:end]
165 - notifications = list(self.notifications)
166 -
167 - out = []
168 - seen = set()
169 - for update in updates:
170 - if update not in seen and update < len(notifications):
171 - out.append(notifications[update].output())
172 - seen.add(update)
173 - return out
170 + out = []
171 + seen = set()
172 + for update in updates:
173 + if update not in seen and update < len(self.notifications):
174 + out.append(self.notifications[update].output())
175 + seen.add(update)
176 + return out, self.guid, len(self.updates)
177
178 def output_all(self) -> list[dict]:
179 with self._lock:
helpers/notification.py.dox.md
+2
@@ -21,6 +21,7 @@
21 - `add_notification(self, type: NotificationType, priority: NotificationPriority, message: str, title: str=..., detail: str=..., display_time: int=..., group: str=..., id: str=...) -> NotificationItem`
22 - `get_recent_notifications(self, seconds: int=...) -> list[NotificationItem]`
23 - `output(self, start: int | None=..., end: int | None=...) -> list[dict]`
24 + - `output_with_state(self, start: int | None=..., end: int | None=...) -> tuple[list[dict], str, int]`
25 - `output_all(self) -> list[dict]`
26 - `mark_read_by_ids(self, notification_ids: list[str]) -> int`
27 - `update_item(self, no: int, **kwargs) -> None`
@@ -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 - Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
34 +- Notification payloads, GUIDs, and update cursors are captured under one lock so WebUI snapshots cannot skip notifications created during snapshot assembly.
35 - Observed side-effect areas: filesystem deletion, settings/state persistence.
36 - Imported dependency areas include: `dataclasses`, `datetime`, `enum`, `helpers.localization`, `threading`, `uuid`.
37
helpers/state_snapshot.py
+5 -3
@@ -282,7 +282,9 @@ async def build_snapshot_from_request(*, request: StateRequestV1) -> SnapshotV1:
282 log_end = 0
283
284 notification_manager = AgentContext.get_notification_manager()
285 - notifications = notification_manager.output(start=notifications_from_no)
285 + notifications, notifications_guid, notifications_version = (
286 + notification_manager.output_with_state(start=notifications_from_no)
287 + )
288
289 scheduler = TaskScheduler.get()
290
@@ -352,8 +354,8 @@ async def build_snapshot_from_request(*, request: StateRequestV1) -> SnapshotV1:
354 "log_progress_active": bool(active_context.log.progress_active) if active_context else False,
355 "paused": active_context.paused if active_context else False,
356 "notifications": notifications,
355 - "notifications_guid": notification_manager.guid,
356 - "notifications_version": len(notification_manager.updates),
357 + "notifications_guid": notifications_guid,
358 + "notifications_version": notifications_version,
359 }
360
361 validate_snapshot_schema_v1(snapshot)
helpers/state_snapshot.py.dox.md
+1
@@ -41,6 +41,7 @@
41
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 - Keep request/response, tool, or helper semantics documented here at the same time as source changes.
46
47 ## Work Guidance
tests/test_snapshot_schema_v1.py
+14
@@ -108,3 +108,17 @@ def test_snapshot_schema_rejects_unexpected_top_level_keys():
108
109 with pytest.raises(ValueError):
110 snapshot.validate_snapshot_schema_v1(payload)
111 +
112 +
113 +def test_notification_payload_and_cursor_are_captured_together():
114 + from helpers.notification import NotificationManager, NotificationPriority, NotificationType
115 +
116 + manager = NotificationManager()
117 + manager.add_notification(NotificationType.INFO, NotificationPriority.HIGH, "first")
118 +
119 + notifications, _, version = manager.output_with_state()
120 + manager.add_notification(NotificationType.INFO, NotificationPriority.HIGH, "second")
121 +
122 + assert [item["message"] for item in notifications] == ["first"]
123 + assert version == 1
124 + assert [item["message"] for item in manager.output(start=version)] == ["second"]