feat(apps): native session-list observer digests on iOS, Android, and macOS (#112597)

* feat(apps): native session-list observer digests on iOS, Android, and macOS

* fix(apps): satisfy swift, periphery, and lint gates for native digests
This commit is contained in:
Peter Steinberger
2026-07-22 01:53:29 -07:00
committed by GitHub
parent e10d004257
commit 0b080b9c2e
22 changed files with 1477 additions and 202 deletions
+92 -92
View File
@@ -1779,7 +1779,7 @@
},
{
"kind": "ui-call",
"line": 1770,
"line": 1771,
"path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt",
"source": "Wait for the current response to finish before starting a new chat.",
"surface": "android",
@@ -1787,7 +1787,7 @@
},
{
"kind": "ui-call",
"line": 1903,
"line": 1904,
"path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt",
"source": "Could not update model.",
"surface": "android",
@@ -1795,7 +1795,7 @@
},
{
"kind": "ui-call",
"line": 1968,
"line": 1969,
"path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt",
"source": "Could not update thinking level.",
"surface": "android",
@@ -1803,7 +1803,7 @@
},
{
"kind": "ui-call",
"line": 2479,
"line": 2480,
"path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt",
"source": "Chat failed before the run started; try again.",
"surface": "android",
@@ -1811,7 +1811,7 @@
},
{
"kind": "ui-call",
"line": 3990,
"line": 3995,
"path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt",
"source": "Could not stage an attachment for sending.",
"surface": "android",
@@ -1819,7 +1819,7 @@
},
{
"kind": "ui-call",
"line": 4023,
"line": 4028,
"path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt",
"source": "Offline queue is full ($OUTBOX_MAX_QUEUED messages); delete queued items first.",
"surface": "android",
@@ -1827,7 +1827,7 @@
},
{
"kind": "ui-call",
"line": 4029,
"line": 4034,
"path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt",
"source": "Attachments are too large to queue for one message; remove some and try again.",
"surface": "android",
@@ -1835,7 +1835,7 @@
},
{
"kind": "ui-call",
"line": 4035,
"line": 4040,
"path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt",
"source": "Offline attachment storage is full; delete queued items first.",
"surface": "android",
@@ -1843,7 +1843,7 @@
},
{
"kind": "ui-call",
"line": 4040,
"line": 4045,
"path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt",
"source": "Gateway health not OK; cannot send",
"surface": "android",
@@ -1851,7 +1851,7 @@
},
{
"kind": "ui-call",
"line": 4047,
"line": 4052,
"path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt",
"source": "Could not queue message for later delivery.",
"surface": "android",
@@ -1859,7 +1859,7 @@
},
{
"kind": "ui-call",
"line": 4950,
"line": 4955,
"path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt",
"source": "Chat failed",
"surface": "android",
@@ -1867,7 +1867,7 @@
},
{
"kind": "ui-call",
"line": 5166,
"line": 5176,
"path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt",
"source": "Event stream interrupted; try refreshing.",
"surface": "android",
@@ -1875,7 +1875,7 @@
},
{
"kind": "ui-call",
"line": 5303,
"line": 5313,
"path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt",
"source": "Timed out waiting for a reply; try again or refresh.",
"surface": "android",
@@ -1883,7 +1883,7 @@
},
{
"kind": "ui-call",
"line": 5513,
"line": 5523,
"path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt",
"source": "Timed out confirming the sent message; refresh to check delivery.",
"surface": "android",
@@ -6107,7 +6107,7 @@
},
{
"kind": "ui-call",
"line": 342,
"line": 345,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt",
"source": "Current thread",
"surface": "android",
@@ -6115,7 +6115,7 @@
},
{
"kind": "ui-call",
"line": 342,
"line": 345,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt",
"source": "OpenClaw thread",
"surface": "android",
@@ -6123,7 +6123,7 @@
},
{
"kind": "ui-call",
"line": 397,
"line": 401,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt",
"source": "Rename thread",
"surface": "android",
@@ -6131,7 +6131,7 @@
},
{
"kind": "ui-call",
"line": 441,
"line": 445,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt",
"source": "Rename group",
"surface": "android",
@@ -6139,7 +6139,7 @@
},
{
"kind": "ui-call",
"line": 444,
"line": 448,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt",
"source": "Rename",
"surface": "android",
@@ -6147,7 +6147,7 @@
},
{
"kind": "ui-call",
"line": 459,
"line": 463,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt",
"source": "New group",
"surface": "android",
@@ -6155,7 +6155,7 @@
},
{
"kind": "ui-call",
"line": 462,
"line": 466,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt",
"source": "Create",
"surface": "android",
@@ -6163,7 +6163,7 @@
},
{
"kind": "ui-call",
"line": 476,
"line": 480,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt",
"source": "Delete group?",
"surface": "android",
@@ -6171,7 +6171,7 @@
},
{
"kind": "ui-call",
"line": 477,
"line": 481,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt",
"source": "Threads in \"$group\" are kept and move back to Ungrouped.",
"surface": "android",
@@ -6179,7 +6179,7 @@
},
{
"kind": "ui-call",
"line": 500,
"line": 504,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt",
"source": "Delete thread?",
"surface": "android",
@@ -6187,7 +6187,7 @@
},
{
"kind": "ui-call",
"line": 501,
"line": 505,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt",
"source": "This permanently deletes the thread and its transcript.",
"surface": "android",
@@ -6195,7 +6195,7 @@
},
{
"kind": "ui-call",
"line": 510,
"line": 514,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt",
"source": "Delete",
"surface": "android",
@@ -6203,7 +6203,7 @@
},
{
"kind": "ui-call",
"line": 645,
"line": 649,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt",
"source": "Workspace",
"surface": "android",
@@ -6211,7 +6211,7 @@
},
{
"kind": "ui-call",
"line": 646,
"line": 650,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt",
"source": "Current",
"surface": "android",
@@ -6219,7 +6219,7 @@
},
{
"kind": "ui-call",
"line": 646,
"line": 650,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt",
"source": "OpenClaw",
"surface": "android",
@@ -6227,7 +6227,7 @@
},
{
"kind": "ui-call",
"line": 666,
"line": 670,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt",
"source": "Unarchive",
"surface": "android",
@@ -6235,7 +6235,7 @@
},
{
"kind": "ui-call",
"line": 670,
"line": 674,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt",
"source": "Delete…",
"surface": "android",
@@ -6243,7 +6243,7 @@
},
{
"kind": "ui-call",
"line": 675,
"line": 679,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt",
"source": "← Back",
"surface": "android",
@@ -6251,7 +6251,7 @@
},
{
"kind": "ui-call",
"line": 689,
"line": 693,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt",
"source": "Remove from group",
"surface": "android",
@@ -6259,7 +6259,7 @@
},
{
"kind": "ui-call",
"line": 696,
"line": 700,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt",
"source": "Pin",
"surface": "android",
@@ -6267,7 +6267,7 @@
},
{
"kind": "ui-call",
"line": 696,
"line": 700,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt",
"source": "Unpin",
"surface": "android",
@@ -6275,7 +6275,7 @@
},
{
"kind": "ui-call",
"line": 700,
"line": 704,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt",
"source": "Mark as read",
"surface": "android",
@@ -6283,7 +6283,7 @@
},
{
"kind": "ui-call",
"line": 700,
"line": 704,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt",
"source": "Mark as unread",
"surface": "android",
@@ -6291,7 +6291,7 @@
},
{
"kind": "ui-call",
"line": 704,
"line": 708,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt",
"source": "Rename…",
"surface": "android",
@@ -6299,7 +6299,7 @@
},
{
"kind": "ui-call",
"line": 708,
"line": 712,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt",
"source": "Fork",
"surface": "android",
@@ -6307,7 +6307,7 @@
},
{
"kind": "ui-call",
"line": 712,
"line": 716,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt",
"source": "Move to group",
"surface": "android",
@@ -6315,7 +6315,7 @@
},
{
"kind": "ui-call",
"line": 713,
"line": 717,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt",
"source": "Archive",
"surface": "android",
@@ -6323,7 +6323,7 @@
},
{
"kind": "ui-call",
"line": 747,
"line": 751,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt",
"source": "Rename group…",
"surface": "android",
@@ -6331,7 +6331,7 @@
},
{
"kind": "ui-call",
"line": 751,
"line": 755,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt",
"source": "New group…",
"surface": "android",
@@ -6339,7 +6339,7 @@
},
{
"kind": "ui-call",
"line": 755,
"line": 759,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt",
"source": "Delete group…",
"surface": "android",
@@ -6347,7 +6347,7 @@
},
{
"kind": "ui-call",
"line": 795,
"line": 799,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt",
"source": "Group name",
"surface": "android",
@@ -6355,7 +6355,7 @@
},
{
"kind": "ui-call",
"line": 795,
"line": 799,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt",
"source": "Name",
"surface": "android",
@@ -6363,7 +6363,7 @@
},
{
"kind": "ui-call",
"line": 805,
"line": 809,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt",
"source": "Cancel",
"surface": "android",
@@ -6371,7 +6371,7 @@
},
{
"kind": "ui-call",
"line": 930,
"line": 966,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt",
"source": "Pinned",
"surface": "android",
@@ -6379,7 +6379,7 @@
},
{
"kind": "ui-call",
"line": 933,
"line": 969,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt",
"source": "Ungrouped",
"surface": "android",
@@ -6387,7 +6387,7 @@
},
{
"kind": "ui-call",
"line": 958,
"line": 994,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt",
"source": "No threads yet",
"surface": "android",
@@ -6395,7 +6395,7 @@
},
{
"kind": "ui-call",
"line": 959,
"line": 995,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt",
"source": "No current thread",
"surface": "android",
@@ -6403,7 +6403,7 @@
},
{
"kind": "ui-call",
"line": 960,
"line": 996,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt",
"source": "No archived threads",
"surface": "android",
@@ -6411,7 +6411,7 @@
},
{
"kind": "ui-call",
"line": 966,
"line": 1002,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt",
"source": "Start a new conversation and it will show up here.",
"surface": "android",
@@ -6419,7 +6419,7 @@
},
{
"kind": "ui-call",
"line": 967,
"line": 1003,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt",
"source": "Open Chat to start or resume the current thread.",
"surface": "android",
@@ -6427,7 +6427,7 @@
},
{
"kind": "ui-call",
"line": 968,
"line": 1004,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt",
"source": "Archived threads will show up here.",
"surface": "android",
@@ -6435,7 +6435,7 @@
},
{
"kind": "ui-call",
"line": 978,
"line": 1014,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt",
"source": "now",
"surface": "android",
@@ -6443,7 +6443,7 @@
},
{
"kind": "ui-call",
"line": 979,
"line": 1015,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt",
"source": "${minutes}m",
"surface": "android",
@@ -6451,7 +6451,7 @@
},
{
"kind": "ui-call",
"line": 981,
"line": 1017,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt",
"source": "${hours}h",
"surface": "android",
@@ -6459,7 +6459,7 @@
},
{
"kind": "ui-call",
"line": 983,
"line": 1019,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt",
"source": "${days}d",
"surface": "android",
@@ -6467,7 +6467,7 @@
},
{
"kind": "ui-call",
"line": 990,
"line": 1026,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt",
"source": "Main thread",
"surface": "android",
@@ -26179,7 +26179,7 @@
},
{
"kind": "ui-localized-call",
"line": 672,
"line": 675,
"path": "apps/ios/Sources/RootSidebar.swift",
"source": "Unread",
"surface": "apple",
@@ -26187,7 +26187,7 @@
},
{
"kind": "ui-localized-call",
"line": 698,
"line": 701,
"path": "apps/ios/Sources/RootSidebar.swift",
"source": "Attention",
"surface": "apple",
@@ -26195,7 +26195,7 @@
},
{
"kind": "ui-localized-call",
"line": 738,
"line": 741,
"path": "apps/ios/Sources/RootSidebar.swift",
"source": "New Chat",
"surface": "apple",
@@ -26203,7 +26203,7 @@
},
{
"kind": "ui-localized-call",
"line": 767,
"line": 770,
"path": "apps/ios/Sources/RootSidebar.swift",
"source": "Connection",
"surface": "apple",
@@ -26211,7 +26211,7 @@
},
{
"kind": "ui-localized-call",
"line": 772,
"line": 775,
"path": "apps/ios/Sources/RootSidebar.swift",
"source": "Online",
"surface": "apple",
@@ -26219,7 +26219,7 @@
},
{
"kind": "ui-localized-call",
"line": 773,
"line": 776,
"path": "apps/ios/Sources/RootSidebar.swift",
"source": "Connecting",
"surface": "apple",
@@ -26227,7 +26227,7 @@
},
{
"kind": "ui-localized-call",
"line": 774,
"line": 777,
"path": "apps/ios/Sources/RootSidebar.swift",
"source": "Needs attention",
"surface": "apple",
@@ -26235,7 +26235,7 @@
},
{
"kind": "ui-localized-call",
"line": 775,
"line": 778,
"path": "apps/ios/Sources/RootSidebar.swift",
"source": "Offline",
"surface": "apple",
@@ -26243,7 +26243,7 @@
},
{
"kind": "ui-call",
"line": 874,
"line": 877,
"path": "apps/ios/Sources/RootSidebar.swift",
"source": "Pinned pages stay in the sidebar. Home is always shown.",
"surface": "apple",
@@ -26251,7 +26251,7 @@
},
{
"kind": "ui-localized-call",
"line": 878,
"line": 881,
"path": "apps/ios/Sources/RootSidebar.swift",
"source": "Pages",
"surface": "apple",
@@ -26259,7 +26259,7 @@
},
{
"kind": "ui-localized-call",
"line": 885,
"line": 888,
"path": "apps/ios/Sources/RootSidebar.swift",
"source": "Done",
"surface": "apple",
@@ -26267,7 +26267,7 @@
},
{
"kind": "ui-localized-call",
"line": 924,
"line": 927,
"path": "apps/ios/Sources/RootSidebar.swift",
"source": "Pinned",
"surface": "apple",
@@ -26275,7 +26275,7 @@
},
{
"kind": "ui-localized-call",
"line": 925,
"line": 928,
"path": "apps/ios/Sources/RootSidebar.swift",
"source": "Not pinned",
"surface": "apple",
@@ -26283,7 +26283,7 @@
},
{
"kind": "ui-named-argument",
"line": 251,
"line": 255,
"path": "apps/ios/Sources/RootTabs.swift",
"source": "Overview",
"surface": "apple",
@@ -26291,7 +26291,7 @@
},
{
"kind": "ui-named-argument",
"line": 279,
"line": 283,
"path": "apps/ios/Sources/RootTabs.swift",
"source": "Agents",
"surface": "apple",
@@ -26299,7 +26299,7 @@
},
{
"kind": "ui-named-argument",
"line": 286,
"line": 290,
"path": "apps/ios/Sources/RootTabs.swift",
"source": "Instances",
"surface": "apple",
@@ -26307,7 +26307,7 @@
},
{
"kind": "ui-named-argument",
"line": 297,
"line": 301,
"path": "apps/ios/Sources/RootTabs.swift",
"source": "Files",
"surface": "apple",
@@ -26315,7 +26315,7 @@
},
{
"kind": "ui-named-argument",
"line": 304,
"line": 308,
"path": "apps/ios/Sources/RootTabs.swift",
"source": "Dreaming",
"surface": "apple",
@@ -26323,7 +26323,7 @@
},
{
"kind": "ui-named-argument",
"line": 311,
"line": 315,
"path": "apps/ios/Sources/RootTabs.swift",
"source": "Usage",
"surface": "apple",
@@ -26331,7 +26331,7 @@
},
{
"kind": "ui-named-argument",
"line": 318,
"line": 322,
"path": "apps/ios/Sources/RootTabs.swift",
"source": "Automations",
"surface": "apple",
@@ -26339,7 +26339,7 @@
},
{
"kind": "ui-localized-call",
"line": 408,
"line": 412,
"path": "apps/ios/Sources/RootTabs.swift",
"source": "Hide Sidebar",
"surface": "apple",
@@ -26347,7 +26347,7 @@
},
{
"kind": "ui-localized-call",
"line": 414,
"line": 418,
"path": "apps/ios/Sources/RootTabs.swift",
"source": "Show Sidebar",
"surface": "apple",
@@ -26355,7 +26355,7 @@
},
{
"kind": "ui-modifier",
"line": 541,
"line": 545,
"path": "apps/ios/Sources/RootTabs.swift",
"source": "Close canvas",
"surface": "apple",
@@ -26363,7 +26363,7 @@
},
{
"kind": "conditional-branch",
"line": 804,
"line": 808,
"path": "apps/ios/Sources/RootTabs.swift",
"source": "Gateway needs attention",
"surface": "apple",
@@ -26371,7 +26371,7 @@
},
{
"kind": "conditional-branch",
"line": 804,
"line": 808,
"path": "apps/ios/Sources/RootTabs.swift",
"source": "OpenClaw iOS",
"surface": "apple",
@@ -26379,7 +26379,7 @@
},
{
"kind": "conditional-branch",
"line": 840,
"line": 844,
"path": "apps/ios/Sources/RootTabs.swift",
"source": "Available",
"surface": "apple",
@@ -26387,7 +26387,7 @@
},
{
"kind": "conditional-branch",
"line": 840,
"line": 844,
"path": "apps/ios/Sources/RootTabs.swift",
"source": "Gateway default",
"surface": "apple",
@@ -26395,7 +26395,7 @@
},
{
"kind": "conditional-branch",
"line": 840,
"line": 844,
"path": "apps/ios/Sources/RootTabs.swift",
"source": "Routed on this phone",
"surface": "apple",
@@ -40035,7 +40035,7 @@
},
{
"kind": "ui-localized-call",
"line": 342,
"line": 344,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessionSidebar.swift",
"source": "Gateway connected",
"surface": "apple",
@@ -40043,7 +40043,7 @@
},
{
"kind": "ui-localized-call",
"line": 343,
"line": 345,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessionSidebar.swift",
"source": "Connecting…",
"surface": "apple",
@@ -40051,7 +40051,7 @@
},
{
"kind": "ui-localized-call",
"line": 354,
"line": 356,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessionSidebar.swift",
"source": "Retry thread groups",
"surface": "apple",
@@ -40059,7 +40059,7 @@
},
{
"kind": "ui-named-argument",
"line": 68,
"line": 69,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessionSidebarModel.swift",
"source": "Pinned",
"surface": "apple",
@@ -10,6 +10,7 @@ import ai.openclaw.app.gateway.QuestionAnswers
import ai.openclaw.app.gateway.QuestionGetResult
import ai.openclaw.app.gateway.QuestionListResult
import ai.openclaw.app.gateway.QuestionRecord
import ai.openclaw.app.gateway.SessionObserverDigest
import ai.openclaw.app.gateway.parseChatSendAck
import ai.openclaw.app.i18n.NativeText
import ai.openclaw.app.i18n.nativeText
@@ -2842,6 +2843,10 @@ class ChatController internal constructor(
handleSessionsChangedEvent(payloadJson)
}
}
"session.observer" -> {
if (payloadJson.isNullOrBlank()) return
handleSessionObserverEvent(payloadJson)
}
"session.message" -> {
if (payloadJson.isNullOrBlank()) return
handleSessionMessageEvent(payloadJson)
@@ -5027,6 +5032,11 @@ class ChatController internal constructor(
applySessionEvent(payload, refreshWhenMissing = true)
}
private fun handleSessionObserverEvent(payloadJson: String) {
val digest = runCatching { json.decodeFromString<SessionObserverDigest>(payloadJson) }.getOrNull() ?: return
_sessions.value = applySessionObserverDigest(_sessions.value, digest)
}
private fun scheduleSessionsChangedBranchReconciliation(
eventGatewayScope: ChatCacheScope?,
sessionKey: String,
@@ -5673,6 +5683,13 @@ class ChatController internal constructor(
archived = obj["archived"].asBooleanOrNull(),
unread = obj["unread"].asBooleanOrNull(),
lastReadAt = obj["lastReadAt"].asLongOrNull(),
agentStatus = parseSessionAgentStatus(obj["agentStatus"]),
hasAgentStatusMetadata = "agentStatus" in obj,
observerDigest =
obj["observerDigest"]
?.takeUnless { it is JsonNull }
?.let { runCatching { json.decodeFromJsonElement<SessionObserverDigest>(it) }.getOrNull() },
hasObserverDigestMetadata = "observerDigest" in obj,
lastActivityAt = obj["lastActivityAt"].asLongOrNull(),
totalTokens = obj["totalTokens"].asLongOrNull(),
totalTokensFresh = obj["totalTokensFresh"].asBooleanOrNull(),
@@ -5691,13 +5708,16 @@ class ChatController internal constructor(
obj["activeRunIds"]
.asArrayOrNull()
?.mapNotNull { it.asStringOrNull()?.trim()?.takeIf(String::isNotEmpty) },
hasActiveRunMetadata = "hasActiveRun" in obj || "activeRunIds" in obj,
status = obj["status"].asStringOrNull()?.trim(),
lastRunError = obj["lastRunError"].asStringOrNull()?.trim(),
startedAt = obj["startedAt"].asLongOrNull(),
endedAt = obj["endedAt"].asLongOrNull(),
runtimeMs = obj["runtimeMs"].asLongOrNull(),
outputTokens = obj["outputTokens"].asLongOrNull(),
hasRunMetadata =
"status" in obj ||
"lastRunError" in obj ||
"startedAt" in obj ||
"endedAt" in obj ||
"runtimeMs" in obj ||
@@ -5705,6 +5725,17 @@ class ChatController internal constructor(
)
}
private fun parseSessionAgentStatus(element: JsonElement?): ChatSessionAgentStatus? {
val obj = element.asObjectOrNull() ?: return null
val note = obj["note"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } ?: return null
val expiresAt = obj["expiresAt"].asLongOrNull() ?: return null
return ChatSessionAgentStatus(
note = note,
expiresAt = expiresAt,
attention = obj["attention"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
)
}
private fun parseSessionSettingsPatchResolution(jsonString: String): SessionSettingsPatchResolution? {
val root = json.parseToJsonElement(jsonString).asObjectOrNull() ?: return null
val resolved = root["resolved"].asObjectOrNull() ?: return null
@@ -6430,6 +6461,17 @@ internal fun mergeChatSessionEntry(
preserveExistingContextUsageWithoutTotal: Boolean = false,
): ChatSessionEntry {
val preserveExistingContextUsage = preserveExistingContextUsageWithoutTotal && next.totalTokens == null
val hasActiveRun = if (next.hasActiveRunMetadata) next.hasActiveRun else existing.hasActiveRun
val activeRunIds = if (next.hasActiveRunMetadata) next.activeRunIds else existing.activeRunIds
val observerDigest =
reconcileSessionObserverDigest(
existing = existing.observerDigest,
next = next.observerDigest,
hasNextProjection = next.hasObserverDigestMetadata,
hasActiveRun = hasActiveRun,
activeRunIds = activeRunIds,
status = if (next.hasRunMetadata) next.status else existing.status,
)
return existing.copy(
updatedAtMs = next.updatedAtMs ?: existing.updatedAtMs,
ownerAgentId = next.ownerAgentId ?: existing.ownerAgentId,
@@ -6440,6 +6482,10 @@ internal fun mergeChatSessionEntry(
archived = next.archived ?: existing.archived,
unread = next.unread ?: existing.unread,
lastReadAt = next.lastReadAt ?: existing.lastReadAt,
agentStatus = if (next.hasAgentStatusMetadata) next.agentStatus else existing.agentStatus,
hasAgentStatusMetadata = existing.hasAgentStatusMetadata || next.hasAgentStatusMetadata,
observerDigest = observerDigest,
hasObserverDigestMetadata = existing.hasObserverDigestMetadata || next.hasObserverDigestMetadata,
lastActivityAt = next.lastActivityAt ?: existing.lastActivityAt,
totalTokens =
when {
@@ -6469,7 +6515,11 @@ internal fun mergeChatSessionEntry(
preserveExistingContextUsage -> existing.hasContextUsageMetadata || next.contextTokens != null
else -> next.hasContextUsageMetadata
},
hasActiveRun = hasActiveRun,
activeRunIds = activeRunIds,
hasActiveRunMetadata = existing.hasActiveRunMetadata || next.hasActiveRunMetadata,
status = if (next.hasRunMetadata) next.status else existing.status,
lastRunError = if (next.hasRunMetadata) next.lastRunError else existing.lastRunError,
startedAt = if (next.hasRunMetadata) next.startedAt else existing.startedAt,
endedAt = if (next.hasRunMetadata) next.endedAt else existing.endedAt,
runtimeMs = if (next.hasRunMetadata) next.runtimeMs else existing.runtimeMs,
@@ -6478,6 +6528,62 @@ internal fun mergeChatSessionEntry(
)
}
internal fun applySessionObserverDigest(
sessions: List<ChatSessionEntry>,
digest: SessionObserverDigest,
): List<ChatSessionEntry> {
val index = sessions.indexOfFirst { it.key == digest.sessionKey }
if (index < 0) return sessions
val session = sessions[index]
val runId = digest.runId?.trim()?.takeIf { it.isNotEmpty() } ?: return sessions
val isRunning = session.hasActiveRun == true || session.status?.trim()?.lowercase() == "running"
val matchesActiveRun = session.activeRunIds.orEmpty().any { it.trim() == runId }
if (!isRunning || !matchesActiveRun) return sessions
val previous = session.observerDigest
if (previous?.runId == runId && !observerDigestIsNewer(digest, previous)) return sessions
return sessions.toMutableList().also {
it[index] = session.copy(observerDigest = digest, hasObserverDigestMetadata = true)
}
}
private fun reconcileSessionObserverDigest(
existing: SessionObserverDigest?,
next: SessionObserverDigest?,
hasNextProjection: Boolean,
hasActiveRun: Boolean?,
activeRunIds: List<String>?,
status: String?,
): SessionObserverDigest? {
val isRunning = hasActiveRun == true || status?.trim()?.lowercase() == "running"
val activeIds = activeRunIds.orEmpty().mapNotNull { it.trim().takeIf(String::isNotEmpty) }.toSet()
var resolved = existing
if (isRunning && resolved?.runId?.trim()?.let(activeIds::contains) != true) {
resolved = null
}
if (next != null) {
val matchesActiveRun = !isRunning || next.runId?.trim()?.let(activeIds::contains) == true
if (matchesActiveRun) {
val previous = resolved
resolved =
if (previous != null && previous.runId == next.runId && !observerDigestIsNewer(next, previous)) {
previous
} else {
next
}
}
} else if (hasNextProjection) {
resolved = null
}
return resolved
}
private fun observerDigestIsNewer(
candidate: SessionObserverDigest,
previous: SessionObserverDigest,
): Boolean =
candidate.revision > previous.revision ||
(candidate.revision == previous.revision && candidate.updatedAt > previous.updatedAt)
private fun ChatSessionEntry.providerQualifiedModelRef(): String? {
val model = model?.trim()?.takeIf { it.isNotEmpty() } ?: return null
val provider = modelProvider?.trim()?.takeIf { it.isNotEmpty() } ?: return model
@@ -1,5 +1,6 @@
package ai.openclaw.app.chat
import ai.openclaw.app.gateway.SessionObserverDigest
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
@@ -176,6 +177,10 @@ data class ChatSessionEntry(
val archived: Boolean? = null,
val unread: Boolean? = null,
val lastReadAt: Long? = null,
val agentStatus: ChatSessionAgentStatus? = null,
val hasAgentStatusMetadata: Boolean = agentStatus != null,
val observerDigest: SessionObserverDigest? = null,
val hasObserverDigestMetadata: Boolean = observerDigest != null,
val lastActivityAt: Long? = null,
val totalTokens: Long? = null,
val totalTokensFresh: Boolean? = null,
@@ -188,7 +193,9 @@ data class ChatSessionEntry(
val hasContextUsageMetadata: Boolean = totalTokens != null || totalTokensFresh != null || contextTokens != null,
val hasActiveRun: Boolean? = null,
val activeRunIds: List<String>? = null,
val hasActiveRunMetadata: Boolean = hasActiveRun != null || activeRunIds != null,
val status: String? = null,
val lastRunError: String? = null,
val startedAt: Long? = null,
val endedAt: Long? = null,
val runtimeMs: Long? = null,
@@ -197,6 +204,12 @@ data class ChatSessionEntry(
status != null || startedAt != null || endedAt != null || runtimeMs != null || outputTokens != null,
)
data class ChatSessionAgentStatus(
val note: String,
val expiresAt: Long,
val attention: String? = null,
)
/** Local fallback for server-side `sessions.list` search over cached entries. */
fun filterSessionEntries(
sessions: List<ChatSessionEntry>,
@@ -339,7 +339,11 @@ internal fun SessionsScreen(
SessionRow(
session = session,
title = displaySessionTitle(session),
subtitle = if (active) nativeString("Current thread") else nativeString("OpenClaw thread"),
subtitle =
sessionListSubtitle(
session,
fallback = if (active) nativeString("Current thread") else nativeString("OpenClaw thread"),
),
metadata = (session.lastActivityAt ?: session.updatedAtMs)?.let(::relativeSessionTime) ?: nativeString("now"),
active = active,
compact = compactLayout,
@@ -846,6 +850,38 @@ private enum class SessionFilter {
Archived,
}
internal fun sessionListSubtitle(
session: ChatSessionEntry,
fallback: String,
nowMs: Long = System.currentTimeMillis(),
): String {
val agentStatus =
session.agentStatus?.takeIf { status ->
status.expiresAt > nowMs && status.note.isNotBlank()
}
val declaredAttention = agentStatus?.takeIf { it.attention != null }?.note
val runStatus = session.status?.trim()?.lowercase()
val failureAt = session.endedAt ?: session.updatedAtMs ?: 0L
val failedAttention =
session.lastRunError
?.trim()
?.takeIf { it.isNotEmpty() && (runStatus == "failed" || runStatus == "timeout") && (session.lastReadAt ?: 0L) < failureAt }
val digest = session.observerDigest
val running = session.hasActiveRun == true || runStatus == "running"
val digestMatchesActiveRun =
digest
?.runId
?.trim()
?.takeIf(String::isNotEmpty)
?.let { runId -> session.activeRunIds.orEmpty().any { it.trim() == runId } } == true
val finalDigestUnread =
digest != null &&
(digest.health == "done" || digest.health == "failed") &&
(session.lastReadAt ?: 0L) < digest.updatedAt
val observer = digest?.headline?.takeIf { (running && digestMatchesActiveRun) || (!running && finalDigestUnread) }
return declaredAttention ?: failedAttention ?: agentStatus?.note ?: observer ?: fallback
}
internal data class SessionSection(
val title: String?,
val entries: List<ChatSessionEntry>,
@@ -1459,7 +1459,7 @@ internal fun overviewRecentSessionRows(
key = session.key,
ownerAgentId = session.ownerAgentId,
title = title,
source = sessionSourceLabel(session.key, channelsSummary),
source = sessionListSubtitle(session, sessionSourceLabel(session.key, channelsSummary)),
metadata = (session.lastActivityAt ?: session.updatedAtMs)?.let(::overviewRelativeSessionTime) ?: "",
)
}
@@ -0,0 +1,162 @@
package ai.openclaw.app.ui
import ai.openclaw.app.chat.ChatSessionAgentStatus
import ai.openclaw.app.chat.ChatSessionEntry
import ai.openclaw.app.chat.applySessionObserverDigest
import ai.openclaw.app.chat.mergeChatSessionEntry
import ai.openclaw.app.gateway.SessionObserverDigest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class SessionObserverDigestTest {
@Test
fun observerEventsRequireTheServerRunAndAdvanceMonotonically() {
val running =
ChatSessionEntry(
key = "agent:main:work",
updatedAtMs = 100,
hasActiveRun = true,
activeRunIds = listOf(" run-1 "),
status = "running",
)
var sessions =
applySessionObserverDigest(
listOf(running),
digest(runId = "run-1", revision = 2, updatedAt = 200, headline = "Second"),
)
sessions =
applySessionObserverDigest(
sessions,
digest(runId = "run-1", revision = 1, updatedAt = 300, headline = "Stale"),
)
sessions =
applySessionObserverDigest(
sessions,
digest(runId = "run-old", revision = 3, updatedAt = 400, headline = "Wrong run"),
)
assertEquals("Second", sessions.single().observerDigest?.headline)
assertEquals("Second", sessionListSubtitle(sessions.single(), fallback = "Work", nowMs = 1_000))
val projected =
ChatSessionEntry(
key = running.key,
updatedAtMs = 500,
observerDigest = digest(runId = "run-1", revision = 3, updatedAt = 500, headline = "Projected"),
hasObserverDigestMetadata = true,
hasActiveRun = true,
activeRunIds = listOf("run-1"),
hasActiveRunMetadata = true,
status = "running",
hasRunMetadata = true,
)
assertEquals("Projected", mergeChatSessionEntry(sessions.single(), projected).observerDigest?.headline)
}
@Test
fun sessionProjectionClearsDigestOnRunRollover() {
val existing =
ChatSessionEntry(
key = "agent:main:work",
updatedAtMs = 100,
hasActiveRun = true,
activeRunIds = listOf("run-1"),
status = "running",
observerDigest = digest(runId = "run-1", revision = 8, updatedAt = 800, headline = "Old run"),
)
val replacement =
ChatSessionEntry(
key = existing.key,
updatedAtMs = 900,
hasActiveRun = true,
activeRunIds = listOf("run-2"),
hasActiveRunMetadata = true,
status = "running",
hasRunMetadata = true,
)
val rolled = mergeChatSessionEntry(existing, replacement)
assertNull(rolled.observerDigest)
val accepted =
applySessionObserverDigest(
listOf(rolled),
digest(runId = "run-2", revision = 1, updatedAt = 901, headline = "New run"),
)
assertEquals("New run", accepted.single().observerDigest?.headline)
}
@Test
fun explicitNullProjectionClearsDigestWhileRunIsActive() {
val existing =
ChatSessionEntry(
key = "agent:main:work",
updatedAtMs = 100,
observerDigest = digest(runId = "run-1", revision = 4, updatedAt = 400, headline = "Stale"),
hasActiveRun = true,
activeRunIds = listOf("run-1"),
status = "running",
)
val explicitClear =
ChatSessionEntry(
key = existing.key,
updatedAtMs = 500,
observerDigest = null,
hasObserverDigestMetadata = true,
hasActiveRun = true,
activeRunIds = listOf("run-1"),
hasActiveRunMetadata = true,
status = "running",
hasRunMetadata = true,
)
assertNull(mergeChatSessionEntry(existing, explicitClear).observerDigest)
}
@Test
fun subtitlePrecedenceAndUnreadFinalRuleMatchTheWebSidebar() {
val liveDigest = digest(runId = "run-1", revision = 1, updatedAt = 200, headline = "Observer")
val agentStatus = ChatSessionAgentStatus(note = "Agent note", expiresAt = 10_000)
val failed =
ChatSessionEntry(
key = "work",
updatedAtMs = 500,
lastReadAt = 100,
agentStatus = agentStatus,
observerDigest = liveDigest,
hasActiveRun = true,
activeRunIds = listOf("run-1"),
status = "failed",
lastRunError = "Needs approval",
endedAt = 500,
)
assertEquals("Needs approval", sessionListSubtitle(failed, fallback = "Work", nowMs = 1_000))
assertEquals(
"Agent note",
sessionListSubtitle(failed.copy(status = "running", lastRunError = null), fallback = "Work", nowMs = 1_000),
)
val finalDigest = digest(runId = null, revision = 2, updatedAt = 2_000, headline = "Finished", health = "done")
val idle = ChatSessionEntry(key = "work", updatedAtMs = 2_000, lastReadAt = 1_999, observerDigest = finalDigest)
assertEquals("Finished", sessionListSubtitle(idle, fallback = "Work", nowMs = 3_000))
assertEquals("Work", sessionListSubtitle(idle.copy(lastReadAt = 2_000), fallback = "Work", nowMs = 3_000))
}
private fun digest(
runId: String?,
revision: Long,
updatedAt: Long,
headline: String,
health: String = "on-track",
): SessionObserverDigest =
SessionObserverDigest(
sessionKey = "agent:main:work",
runId = runId,
revision = revision,
updatedAt = updatedAt,
headline = headline,
health = health,
)
}
+5 -2
View File
@@ -617,8 +617,11 @@ struct RootSidebar: View {
.lineLimit(1)
// Web-parity subtitle: the work line (repo/branch) names the
// session; recency moves to the trailing metadata slot.
if let workSubtitle = ChatSessionSidebarModel.workSubtitle(for: session) {
Text(verbatim: workSubtitle)
if let subtitle = ChatSessionSidebarModel.subtitle(
for: session,
workSubtitle: ChatSessionSidebarModel.workSubtitle(for: session))
{
Text(verbatim: subtitle)
.font(OpenClawType.caption2Medium)
.foregroundStyle(OpenClawSidebarPalette.muted)
.lineLimit(1)
+102
View File
@@ -145,6 +145,108 @@ final class RootSidebarModel {
}
}
func observeSessionEvents(appModel: NodeAppModel) async {
await Self.consumeSubscribedSessionEvents(
makeStream: {
await appModel.operatorSession.subscribeServerEvents(bufferingNewest: 200)
},
subscribe: {
_ = try await appModel.operatorSession.request(
method: "sessions.subscribe",
paramsJSON: nil,
timeoutSeconds: 12)
},
onEvent: { [weak self] frame in
await self?.handleSessionEvent(frame, appModel: appModel) ?? false
})
}
static func consumeSubscribedSessionEvents(
makeStream: @MainActor () async -> AsyncStream<EventFrame>,
subscribe: @MainActor () async throws -> Void,
onEvent: @MainActor (EventFrame) async -> Bool,
retryDelays: [Duration] = [
.seconds(1),
.seconds(2),
.seconds(4),
.seconds(8),
.seconds(16),
.seconds(30),
],
sleep: @MainActor (Duration) async throws -> Void = { delay in
try await Task.sleep(for: delay)
}) async
{
var failureCount = 0
while !Task.isCancelled {
// Register the local continuation before the RPC. The gateway may
// synchronously emit a one-off final digest while handling subscribe.
let stream = await makeStream()
do {
try await subscribe()
failureCount = 0
} catch is CancellationError {
return
} catch {
failureCount += 1
guard await self.waitForSessionEventRetry(
failureCount: failureCount,
retryDelays: retryDelays,
sleep: sleep)
else { return }
continue
}
for await frame in stream {
guard !Task.isCancelled else { return }
if await onEvent(frame) {
break
}
}
guard !Task.isCancelled else { return }
failureCount += 1
guard await self.waitForSessionEventRetry(
failureCount: failureCount,
retryDelays: retryDelays,
sleep: sleep)
else { return }
}
}
private static func waitForSessionEventRetry(
failureCount: Int,
retryDelays: [Duration],
sleep: @MainActor (Duration) async throws -> Void) async -> Bool
{
let delay = retryDelays.isEmpty
? .zero
: retryDelays[min(max(0, failureCount - 1), retryDelays.count - 1)]
do {
try await sleep(delay)
return !Task.isCancelled
} catch {
return false
}
}
private func handleSessionEvent(_ frame: EventFrame, appModel: NodeAppModel) async -> Bool {
guard let event = OpenClawChatGatewayPayloadCodec.event(from: frame) else { return false }
switch event {
case .sessionsChanged:
await self.refreshSessions(appModel: appModel)
case let .sessionObserver(digest):
self.sessions = ChatSessionSidebarModel.applying(
observerDigest: digest,
to: self.sessions)
case .seqGap:
await self.refreshSessions(appModel: appModel)
return true
default:
return false
}
return false
}
func reportSessionError(_ error: any Error) {
self.sessionErrorText = error.localizedDescription
}
+4
View File
@@ -146,6 +146,10 @@ struct RootTabs: View {
await self.sidebarModel.refresh(appModel: self.appModel)
}
}
.task(id: "\(self.sidebarRefreshID):events") {
guard self.scenePhase == .active else { return }
await self.sidebarModel.observeSessionEvents(appModel: self.appModel)
}
}
}
@@ -610,6 +610,109 @@ struct RootTabsPresentationTests {
#expect(ChatSessionSidebarModel.workSubtitle(for: Self.sessionEntry(key: "plain")) == nil)
}
@Test func `sidebar subtitle keeps an unread final observer digest above work metadata`() {
let digest = OpenClawChatSessionObserverDigest(
revision: 4,
updatedAt: 2000,
headline: "Finished with warnings",
health: "done")
let unread = Self.sessionEntry(
key: "agent:main:work",
lastReadAt: 1999,
observerDigest: digest)
let read = Self.sessionEntry(
key: "agent:main:work",
lastReadAt: 2000,
observerDigest: digest)
#expect(ChatSessionSidebarModel.subtitle(
for: unread,
workSubtitle: "openclaw \u{2387} observer") == "Finished with warnings")
#expect(ChatSessionSidebarModel.subtitle(
for: read,
workSubtitle: "openclaw \u{2387} observer") == "openclaw \u{2387} observer")
}
@Test func `sidebar registers event stream before subscription request`() async {
var order: [String] = []
let (stream, continuation) = AsyncStream<EventFrame>.makeStream()
await RootSidebarModel.consumeSubscribedSessionEvents(
makeStream: {
order.append("stream")
return stream
},
subscribe: {
order.append("subscribe")
continuation.yield(EventFrame(type: "event", event: "tick"))
continuation.finish()
},
onEvent: { frame in
order.append("event:\(frame.event)")
return false
},
retryDelays: [.zero],
sleep: { _ in
throw CancellationError()
})
#expect(order == ["stream", "subscribe", "event:tick"])
}
@Test func `sidebar retries failed subscribe and resubscribes after stream completion`() async {
enum TestError: Error { case transient }
func sessionsChangedEvent(reason: String) -> EventFrame {
EventFrame(
type: "event",
event: "sessions.changed",
payload: AnyCodable([
"sessionKey": AnyCodable("agent:main:work"),
"reason": AnyCodable(reason),
"updatedAt": AnyCodable(200),
]))
}
var streamCount = 0
var subscribeAttempts = 0
var events: [String] = []
await RootSidebarModel.consumeSubscribedSessionEvents(
makeStream: {
streamCount += 1
return AsyncStream { continuation in
if streamCount == 2 {
continuation.yield(sessionsChangedEvent(reason: "patch"))
} else if streamCount == 3 {
continuation.yield(sessionsChangedEvent(reason: "message"))
}
continuation.finish()
}
},
subscribe: {
subscribeAttempts += 1
if subscribeAttempts == 1 {
throw TestError.transient
}
},
onEvent: { frame in
guard case let .sessionsChanged(change) = OpenClawChatGatewayPayloadCodec.event(from: frame)
else { return false }
events.append(change.reason)
return false
},
retryDelays: [.zero],
sleep: { _ in
if subscribeAttempts >= 3 {
throw CancellationError()
}
})
#expect(streamCount == 3)
#expect(subscribeAttempts == 3)
#expect(events == ["patch", "message"])
}
@Test func `pinned pages storage round trips and preserves pin order`() {
#expect(RootTabs.pinnedSidebarPages(from: "") == RootTabs.defaultPinnedSidebarPages)
#expect(RootTabs.pinnedSidebarPages(from: "none").isEmpty)
@@ -758,6 +861,8 @@ struct RootTabsPresentationTests {
totalTokens: Int? = nil,
totalTokensFresh: Bool? = nil,
contextTokens: Int? = nil,
lastReadAt: Double? = nil,
observerDigest: OpenClawChatSessionObserverDigest? = nil,
worktree: OpenClawChatSessionWorktree? = nil) -> OpenClawChatSessionEntry
{
OpenClawChatSessionEntry(
@@ -782,6 +887,8 @@ struct RootTabsPresentationTests {
model: nil,
contextTokens: contextTokens,
archived: archived,
observerDigest: observerDigest,
lastReadAt: lastReadAt,
worktree: worktree)
}
@@ -0,0 +1,52 @@
import OpenClawChatUI
import OpenClawProtocol
import Testing
struct MacSessionObserverDigestTests {
@Test func `native chat sidebar accepts only the server reported run`() {
let session = OpenClawChatSessionEntry(
key: "agent:main:work",
kind: nil,
displayName: nil,
surface: nil,
subject: nil,
room: nil,
space: nil,
updatedAt: 100,
sessionId: nil,
systemSent: nil,
abortedLastRun: nil,
thinkingLevel: nil,
verboseLevel: nil,
inputTokens: nil,
outputTokens: nil,
totalTokens: nil,
modelProvider: nil,
model: nil,
contextTokens: nil,
status: "running",
hasActiveRun: true,
activeRunIds: ["run-1"])
let wrongRun = SessionObserverDigest(
sessionkey: session.key,
runid: "run-old",
revision: 5,
updatedat: 500,
headline: "Wrong run",
health: .stuck)
let accepted = SessionObserverDigest(
sessionkey: session.key,
runid: "run-1",
revision: 1,
updatedat: 600,
headline: "On track",
health: .onTrack)
let rejected = ChatSessionSidebarModel.applying(observerDigest: wrongRun, to: [session])
let updated = ChatSessionSidebarModel.applying(observerDigest: accepted, to: rejected)
#expect(rejected[0].observerDigest == nil)
#expect(updated[0].observerDigest?.headline == "On track")
#expect(ChatSessionSidebarModel.subtitle(for: updated[0], workSubtitle: "Work") == "On track")
}
}
@@ -110,6 +110,13 @@ public enum OpenClawChatGatewayPayloadCodec {
as: OpenClawChatSessionsChangedEvent.self)
else { return nil }
return .sessionsChanged(change)
case "session.observer":
guard let payload = frame.payload,
let digest = try? GatewayPayloadDecoding.decode(
payload,
as: SessionObserverDigest.self)
else { return nil }
return .sessionObserver(digest)
case "seqGap":
return .seqGap
case "health":
@@ -330,7 +330,9 @@ struct ChatSessionSidebar: View {
let date = Date(timeIntervalSince1970: updatedAt / 1000)
parts.append(date.formatted(.relative(presentation: .named)))
}
return parts.isEmpty ? nil : parts.joined(separator: " · ")
return ChatSessionSidebarModel.subtitle(
for: session,
workSubtitle: parts.isEmpty ? nil : parts.joined(separator: " · "))
}
private var connectionFooter: some View {
@@ -1,4 +1,5 @@
import Foundation
import OpenClawProtocol
/// Pure grouping/filtering shared by the Apple sessions sidebars. Kept UI-free
/// so pin/search/ordering rules stay unit-testable across macOS and iOS.
@@ -197,6 +198,179 @@ public enum ChatSessionSidebarModel {
return "\(repoName) \u{2387} \(shortBranch)"
}
/// Resolves the single session-list subtitle slot with the same ownership
/// order as the web sidebar. Gateway-supplied text stays verbatim.
public static func subtitle(
for session: OpenClawChatSessionEntry,
workSubtitle: String?,
now: Double = Date().timeIntervalSince1970 * 1000) -> String?
{
let agentStatus = self.activeAgentStatus(session.agentStatus, now: now)
let declaredAttention = agentStatus?.attention == nil ? nil : agentStatus?.note
let failedAttention = self.unreadFailureReason(for: session)
let statusNote = agentStatus?.note
let observer = self.visibleObserverDigest(for: session)?.headline
return declaredAttention ?? failedAttention ?? statusNote ?? observer ?? workSubtitle
}
/// Live observer events are useful only after a server row names the
/// active run. This prevents a late event from a prior run taking over a
/// replacement run that reused the same session key.
public static func applying(
observerDigest digest: SessionObserverDigest,
to sessions: [OpenClawChatSessionEntry]) -> [OpenClawChatSessionEntry]
{
guard let index = sessions.firstIndex(where: { $0.key == digest.sessionkey }) else { return sessions }
var session = sessions[index]
let candidate = OpenClawChatSessionObserverDigest(digest)
let activeRunIds = self.normalizedActiveRunIds(session.activeRunIds)
guard self.isRunning(session),
let runId = normalized(candidate.runId),
activeRunIds.contains(runId)
else { return sessions }
if let previous = session.observerDigest,
previous.runId == candidate.runId,
!self.isNewer(candidate, than: previous)
{
return sessions
}
session.observerDigest = candidate
var updated = sessions
updated[index] = session
return updated
}
/// Session snapshots own rollover and clearing. A projected digest may
/// advance a live one, while an active-run change immediately retires any
/// digest that no longer belongs to a server-reported run.
public static func applying(
sessionChange change: OpenClawChatSessionsChangedEvent,
to sessions: [OpenClawChatSessionEntry]) -> [OpenClawChatSessionEntry]?
{
guard let key = change.sessionKey else { return sessions }
guard let index = sessions.firstIndex(where: { $0.key == key }) else { return nil }
var session = sessions[index]
if let updatedAt = change.updatedAt {
session.updatedAt = updatedAt
}
if let lastReadAt = change.lastReadAt {
session.lastReadAt = lastReadAt
}
if change.agentStatusPresent {
session.agentStatus = change.agentStatus
}
if change.statusPresent {
session.status = change.status
}
if change.lastRunErrorPresent {
session.lastRunError = change.lastRunError
}
if let hasActiveRun = change.hasActiveRun {
session.hasActiveRun = hasActiveRun
}
if let activeRunIds = change.activeRunIds {
session.activeRunIds = activeRunIds
}
if let startedAt = change.startedAt {
session.startedAt = startedAt
}
if let endedAt = change.endedAt {
session.endedAt = endedAt
}
let activeRunIds = self.normalizedActiveRunIds(session.activeRunIds)
if self.isRunning(session),
self.normalized(session.observerDigest?.runId).map(activeRunIds.contains) != true
{
session.observerDigest = nil
}
if change.observerDigestPresent {
if let projected = change.observerDigest {
let matchesActiveRun = !self.isRunning(session) ||
self.normalized(projected.runId).map(activeRunIds.contains) == true
if matchesActiveRun {
if let previous = session.observerDigest,
previous.runId == projected.runId
{
if self.isNewer(projected, than: previous) {
session.observerDigest = projected
}
} else {
session.observerDigest = projected
}
}
} else {
session.observerDigest = nil
}
}
var updated = sessions
updated[index] = session
return updated
}
private static func visibleObserverDigest(
for session: OpenClawChatSessionEntry) -> OpenClawChatSessionObserverDigest?
{
guard let digest = session.observerDigest else { return nil }
if self.isRunning(session) {
let activeRunIds = self.normalizedActiveRunIds(session.activeRunIds)
guard let runId = normalized(digest.runId),
activeRunIds.contains(runId)
else { return nil }
return digest
}
let health = digest.health.lowercased()
guard health == "done" || health == "failed",
(session.lastReadAt ?? 0) < digest.updatedAt
else { return nil }
return digest
}
private static func activeAgentStatus(
_ status: OpenClawChatSessionAgentStatus?,
now: Double) -> OpenClawChatSessionAgentStatus?
{
guard let status,
status.expiresAt > now,
!status.note.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
else { return nil }
return status
}
private static func unreadFailureReason(for session: OpenClawChatSessionEntry) -> String? {
let status = session.status?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard status == "failed" || status == "timeout" else { return nil }
let failureAt = session.endedAt ?? session.updatedAt ?? 0
guard (session.lastReadAt ?? 0) < failureAt else { return nil }
return self.normalized(session.lastRunError)
}
private static func isRunning(_ session: OpenClawChatSessionEntry) -> Bool {
session.hasActiveRun == true ||
session.status?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "running"
}
private static func isNewer(
_ candidate: OpenClawChatSessionObserverDigest,
than previous: OpenClawChatSessionObserverDigest) -> Bool
{
candidate.revision > previous.revision ||
(candidate.revision == previous.revision && candidate.updatedAt > previous.updatedAt)
}
private static func normalized(_ value: String?) -> String? {
let normalized = value?.trimmingCharacters(in: .whitespacesAndNewlines)
return normalized?.isEmpty == false ? normalized : nil
}
private static func normalizedActiveRunIds(_ runIds: [String]?) -> Set<String> {
Set((runIds ?? []).compactMap(self.normalized))
}
public static func canDeleteSession(key: String, mainSessionKey: String) -> Bool {
let normalized = key.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let normalizedMain = mainSessionKey.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
@@ -1,4 +1,42 @@
import Foundation
import OpenClawProtocol
public struct OpenClawChatSessionAgentStatus: Codable, Sendable, Hashable {
public let note: String
public let expiresAt: Double
public let attention: String?
}
public struct OpenClawChatSessionObserverDigest: Codable, Sendable, Hashable {
public let runId: String?
public let revision: Int
public let updatedAt: Double
public let headline: String
public let health: String
public init(
runId: String? = nil,
revision: Int,
updatedAt: Double,
headline: String,
health: String)
{
self.runId = runId
self.revision = revision
self.updatedAt = updatedAt
self.headline = headline
self.health = health
}
public init(_ digest: SessionObserverDigest) {
self.init(
runId: digest.runid,
revision: digest.revision,
updatedAt: Double(digest.updatedat),
headline: digest.headline,
health: digest.health.rawValue)
}
}
public struct OpenClawChatThinkingLevelOption: Codable, Identifiable, Sendable, Hashable {
public let id: String
@@ -321,6 +359,8 @@ public struct OpenClawChatSessionEntry: Codable, Identifiable, Sendable, Hashabl
public var archived: Bool?
public var archivedAt: Double?
public var unread: Bool?
public var agentStatus: OpenClawChatSessionAgentStatus?
public var observerDigest: OpenClawChatSessionObserverDigest?
public var surface: String?
public var subject: String?
public var room: String?
@@ -335,7 +375,9 @@ public struct OpenClawChatSessionEntry: Codable, Identifiable, Sendable, Hashabl
public var spawnedBy: String?
public var childSessions: [String]?
public var status: String?
public var lastRunError: String?
public var hasActiveRun: Bool?
public var activeRunIds: [String]?
public var hasActiveSubagentRun: Bool?
public var worktree: OpenClawChatSessionWorktree?
public var startedAt: Double?
@@ -393,6 +435,8 @@ public struct OpenClawChatSessionEntry: Codable, Identifiable, Sendable, Hashabl
archived: Bool? = nil,
archivedAt: Double? = nil,
unread: Bool? = nil,
agentStatus: OpenClawChatSessionAgentStatus? = nil,
observerDigest: OpenClawChatSessionObserverDigest? = nil,
lastReadAt: Double? = nil,
lastInteractionAt: Double? = nil,
lastActivityAt: Double? = nil,
@@ -400,7 +444,9 @@ public struct OpenClawChatSessionEntry: Codable, Identifiable, Sendable, Hashabl
spawnedBy: String? = nil,
childSessions: [String]? = nil,
status: String? = nil,
lastRunError: String? = nil,
hasActiveRun: Bool? = nil,
activeRunIds: [String]? = nil,
hasActiveSubagentRun: Bool? = nil,
worktree: OpenClawChatSessionWorktree? = nil,
fastMode: OpenClawChatFastMode? = nil,
@@ -420,6 +466,8 @@ public struct OpenClawChatSessionEntry: Codable, Identifiable, Sendable, Hashabl
self.archived = archived
self.archivedAt = archivedAt
self.unread = unread
self.agentStatus = agentStatus
self.observerDigest = observerDigest
self.surface = surface
self.subject = subject
self.room = room
@@ -433,7 +481,9 @@ public struct OpenClawChatSessionEntry: Codable, Identifiable, Sendable, Hashabl
self.spawnedBy = spawnedBy
self.childSessions = childSessions
self.status = status
self.lastRunError = lastRunError
self.hasActiveRun = hasActiveRun
self.activeRunIds = activeRunIds
self.hasActiveSubagentRun = hasActiveSubagentRun
self.worktree = worktree
self.startedAt = startedAt
@@ -5,6 +5,7 @@ public enum OpenClawChatTransportEvent: Sendable {
case health(ok: Bool)
case tick
case sessionsChanged(OpenClawChatSessionsChangedEvent)
case sessionObserver(SessionObserverDigest)
case chat(OpenClawChatEventPayload)
case sessionMessage(OpenClawSessionMessageEventPayload)
case agent(OpenClawAgentEventPayload)
@@ -30,11 +31,98 @@ public struct OpenClawChatSessionsChangedEvent: Codable, Sendable, Equatable {
public let sessionKey: String?
public let agentId: String?
public let reason: String
public let updatedAt: Double?
public let lastReadAt: Double?
public let agentStatus: OpenClawChatSessionAgentStatus?
public let observerDigest: OpenClawChatSessionObserverDigest?
public let status: String?
public let lastRunError: String?
public let hasActiveRun: Bool?
public let activeRunIds: [String]?
public let startedAt: Double?
public let endedAt: Double?
let agentStatusPresent: Bool
let observerDigestPresent: Bool
let statusPresent: Bool
let lastRunErrorPresent: Bool
public init(sessionKey: String?, agentId: String? = nil, reason: String) {
public init(
sessionKey: String?,
agentId: String? = nil,
reason: String,
updatedAt: Double? = nil,
lastReadAt: Double? = nil,
agentStatus: OpenClawChatSessionAgentStatus? = nil,
observerDigest: OpenClawChatSessionObserverDigest? = nil,
status: String? = nil,
lastRunError: String? = nil,
hasActiveRun: Bool? = nil,
activeRunIds: [String]? = nil,
startedAt: Double? = nil,
endedAt: Double? = nil,
agentStatusPresent: Bool? = nil,
observerDigestPresent: Bool? = nil,
statusPresent: Bool? = nil,
lastRunErrorPresent: Bool? = nil)
{
self.sessionKey = sessionKey
self.agentId = agentId
self.reason = reason
self.updatedAt = updatedAt
self.lastReadAt = lastReadAt
self.agentStatus = agentStatus
self.observerDigest = observerDigest
self.status = status
self.lastRunError = lastRunError
self.hasActiveRun = hasActiveRun
self.activeRunIds = activeRunIds
self.startedAt = startedAt
self.endedAt = endedAt
self.agentStatusPresent = agentStatusPresent ?? (agentStatus != nil)
self.observerDigestPresent = observerDigestPresent ?? (observerDigest != nil)
self.statusPresent = statusPresent ?? (status != nil)
self.lastRunErrorPresent = lastRunErrorPresent ?? (lastRunError != nil)
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.sessionKey = try container.decodeIfPresent(String.self, forKey: .sessionKey)
self.agentId = try container.decodeIfPresent(String.self, forKey: .agentId)
self.reason = try container.decode(String.self, forKey: .reason)
self.updatedAt = try container.decodeIfPresent(Double.self, forKey: .updatedAt)
self.lastReadAt = try container.decodeIfPresent(Double.self, forKey: .lastReadAt)
self.agentStatus = try container.decodeIfPresent(
OpenClawChatSessionAgentStatus.self,
forKey: .agentStatus)
self.observerDigest = try container.decodeIfPresent(
OpenClawChatSessionObserverDigest.self,
forKey: .observerDigest)
self.status = try container.decodeIfPresent(String.self, forKey: .status)
self.lastRunError = try container.decodeIfPresent(String.self, forKey: .lastRunError)
self.hasActiveRun = try container.decodeIfPresent(Bool.self, forKey: .hasActiveRun)
self.activeRunIds = try container.decodeIfPresent([String].self, forKey: .activeRunIds)
self.startedAt = try container.decodeIfPresent(Double.self, forKey: .startedAt)
self.endedAt = try container.decodeIfPresent(Double.self, forKey: .endedAt)
self.agentStatusPresent = container.contains(.agentStatus)
self.observerDigestPresent = container.contains(.observerDigest)
self.statusPresent = container.contains(.status)
self.lastRunErrorPresent = container.contains(.lastRunError)
}
private enum CodingKeys: String, CodingKey {
case sessionKey
case agentId
case reason
case updatedAt
case lastReadAt
case agentStatus
case observerDigest
case status
case lastRunError
case hasActiveRun
case activeRunIds
case startedAt
case endedAt
}
}
@@ -33,6 +33,15 @@ extension OpenClawChatViewModel {
let context = self.currentSessionSnapshot()
Task { await self.pollHealthIfNeeded(force: false, sessionSnapshot: context) }
case let .sessionsChanged(change):
let projectedSessions = ChatSessionSidebarModel.applying(
sessionChange: change,
to: self.sessions)
if let projectedSessions {
self.sessions = projectedSessions
} else if change.reason != "patch", change.reason != "command-metadata" {
let context = self.currentSessionSnapshot()
Task { await self.fetchSessions(limit: 50, sessionSnapshot: context) }
}
// Group-catalog mutations from any client arrive as reason "groups"
// (mirrors web ui/src/lib/sessions); bump the revision so views keyed
// on it refetch. Rename/delete also rewrite member sessions' category
@@ -74,6 +83,10 @@ extension OpenClawChatViewModel {
guard change.reason == "patch" || change.reason == "command-metadata" else { return }
let context = self.currentSessionSnapshot()
Task { await self.fetchSessions(limit: 50, sessionSnapshot: context) }
case let .sessionObserver(digest):
self.sessions = ChatSessionSidebarModel.applying(
observerDigest: digest,
to: self.sessions)
case let .chat(chat):
self.handleChatEvent(chat)
case let .sessionMessage(message):
@@ -388,8 +388,96 @@ struct ChatGatewayPayloadCodecTests {
#expect(payload.runId == "run-1")
#expect(payload.sessionKey == "main")
#expect(payload.state == "final")
let observer = EventFrame(
type: "event",
event: "session.observer",
payload: AnyCodable([
"sessionKey": AnyCodable("main"),
"runId": AnyCodable("run-1"),
"revision": AnyCodable(2),
"updatedAt": AnyCodable(300),
"headline": AnyCodable("Wrapping up"),
"health": AnyCodable("wrapping-up"),
]))
guard case let .sessionObserver(digest) = OpenClawChatGatewayPayloadCodec.event(from: observer)
else {
Issue.record("expected sessionObserver")
return
}
#expect(digest.sessionkey == "main")
#expect(digest.runid == "run-1")
#expect(digest.revision == 2)
#expect(OpenClawChatGatewayPayloadCodec.event(from: EventFrame(
type: "event",
event: "unknown")) == nil)
}
@Test func `session change decoding distinguishes absent fields from explicit clears`() {
func decode(_ fields: [String: AnyCodable]) throws -> OpenClawChatSessionsChangedEvent {
let frame = EventFrame(
type: "event",
event: "sessions.changed",
payload: AnyCodable(fields))
guard case let .sessionsChanged(change) = OpenClawChatGatewayPayloadCodec.event(from: frame)
else {
throw CancellationError()
}
return change
}
let partial = try? decode([
"sessionKey": AnyCodable("main"),
"reason": AnyCodable("message"),
"updatedAt": AnyCodable(200),
])
#expect(partial?.agentStatusPresent == false)
#expect(partial?.observerDigestPresent == false)
#expect(partial?.statusPresent == false)
#expect(partial?.lastRunErrorPresent == false)
let cleared = try? decode([
"sessionKey": AnyCodable("main"),
"reason": AnyCodable("patch"),
"agentStatus": AnyCodable(NSNull()),
"observerDigest": AnyCodable(NSNull()),
"status": AnyCodable(NSNull()),
"lastRunError": AnyCodable(NSNull()),
])
#expect(cleared?.agentStatusPresent == true)
#expect(cleared?.observerDigestPresent == true)
#expect(cleared?.statusPresent == true)
#expect(cleared?.lastRunErrorPresent == true)
}
@Test func `session change remains codable without exposing presence flags`() throws {
let event = OpenClawChatSessionsChangedEvent(
sessionKey: "agent:main:work",
agentId: "main",
reason: "run-progress",
updatedAt: 200,
lastReadAt: 100,
agentStatus: .init(note: "Reviewing", expiresAt: 500, attention: "hand"),
observerDigest: .init(
runId: "run-1",
revision: 2,
updatedAt: 200,
headline: "On track",
health: "on-track"),
status: "running",
lastRunError: "Previous warning",
hasActiveRun: true,
activeRunIds: ["run-1"],
startedAt: 50,
endedAt: nil)
let data = try JSONEncoder().encode(event)
let object = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
#expect(object["agentStatusPresent"] == nil)
#expect(object["observerDigestPresent"] == nil)
#expect(object["statusPresent"] == nil)
#expect(object["lastRunErrorPresent"] == nil)
#expect(try JSONDecoder().decode(OpenClawChatSessionsChangedEvent.self, from: data) == event)
}
}
@@ -1,4 +1,5 @@
import Foundation
import OpenClawProtocol
import Testing
@testable import OpenClawChatUI
@@ -11,12 +12,18 @@ struct ChatSessionSidebarModelTests {
pinned: Bool? = nil,
archived: Bool? = nil,
unread: Bool? = nil,
lastReadAt: Double? = nil,
category: String? = nil,
parentSessionKey: String? = nil,
spawnedBy: String? = nil,
childSessions: [String]? = nil,
status: String? = nil,
lastRunError: String? = nil,
hasActiveRun: Bool? = nil,
activeRunIds: [String]? = nil,
agentStatus: OpenClawChatSessionAgentStatus? = nil,
observerDigest: OpenClawChatSessionObserverDigest? = nil,
endedAt: Double? = nil,
hasActiveSubagentRun: Bool? = nil) -> OpenClawChatSessionEntry
{
OpenClawChatSessionEntry(
@@ -43,12 +50,18 @@ struct ChatSessionSidebarModelTests {
pinned: pinned,
archived: archived,
unread: unread,
agentStatus: agentStatus,
observerDigest: observerDigest,
lastReadAt: lastReadAt,
parentSessionKey: parentSessionKey,
spawnedBy: spawnedBy,
childSessions: childSessions,
status: status,
lastRunError: lastRunError,
hasActiveRun: hasActiveRun,
hasActiveSubagentRun: hasActiveSubagentRun)
activeRunIds: activeRunIds,
hasActiveSubagentRun: hasActiveSubagentRun,
endedAt: endedAt)
}
@Test func `pinned sessions get their own section, rest sorted by recency`() {
@@ -429,4 +442,238 @@ struct ChatSessionSidebarModelTests {
self.entry(key: "agent:main:active", hasActiveSubagentRun: true),
mainSessionKey: "agent:main:main"))
}
@Test func `observer events require the server run and advance monotonically`() throws {
let running = self.entry(
key: "agent:main:work",
status: "running",
hasActiveRun: true,
activeRunIds: [" run-1 "])
let revision2 = SessionObserverDigest(
sessionkey: running.key,
runid: "run-1",
revision: 2,
updatedat: 200,
headline: "Second",
health: .onTrack)
var sessions = ChatSessionSidebarModel.applying(observerDigest: revision2, to: [running])
sessions = ChatSessionSidebarModel.applying(
observerDigest: SessionObserverDigest(
sessionkey: running.key,
runid: "run-1",
revision: 1,
updatedat: 300,
headline: "Stale",
health: .grinding),
to: sessions)
sessions = ChatSessionSidebarModel.applying(
observerDigest: SessionObserverDigest(
sessionkey: running.key,
runid: "run-old",
revision: 3,
updatedat: 400,
headline: "Wrong run",
health: .stuck),
to: sessions)
#expect(sessions[0].observerDigest?.headline == "Second")
#expect(ChatSessionSidebarModel.subtitle(for: sessions[0], workSubtitle: "Work") == "Second")
sessions = try #require(ChatSessionSidebarModel.applying(
sessionChange: .init(
sessionKey: running.key,
reason: "run-progress",
observerDigest: .init(
runId: "run-1",
revision: 3,
updatedAt: 500,
headline: "Projected",
health: "wrapping-up"),
status: "running",
hasActiveRun: true,
activeRunIds: ["run-1"]),
to: sessions))
#expect(sessions[0].observerDigest?.headline == "Projected")
}
@Test func `run rollover clears a stale digest before the replacement event`() throws {
let existing = self.entry(
key: "agent:main:work",
status: "running",
hasActiveRun: true,
activeRunIds: ["run-1"],
observerDigest: .init(
runId: "run-1",
revision: 8,
updatedAt: 800,
headline: "Old run",
health: "on-track"))
let rolled = try #require(ChatSessionSidebarModel.applying(
sessionChange: .init(
sessionKey: existing.key,
reason: "run-start",
status: "running",
hasActiveRun: true,
activeRunIds: ["run-2"]),
to: [existing]))
#expect(rolled[0].observerDigest == nil)
let accepted = ChatSessionSidebarModel.applying(
observerDigest: SessionObserverDigest(
sessionkey: existing.key,
runid: "run-2",
revision: 1,
updatedat: 900,
headline: "New run",
health: .onTrack),
to: rolled)
#expect(accepted[0].observerDigest?.headline == "New run")
}
@Test func `unknown session change requests an authoritative refetch`() {
let change = OpenClawChatSessionsChangedEvent(
sessionKey: "agent:main:new",
reason: "created",
updatedAt: 200)
#expect(ChatSessionSidebarModel.applying(
sessionChange: change,
to: [self.entry(key: "agent:main:existing")]) == nil)
}
@Test func `partial session change preserves observer and status metadata`() throws {
let agentStatus = OpenClawChatSessionAgentStatus(
note: "Waiting on review",
expiresAt: 5000,
attention: "hand")
let observerDigest = OpenClawChatSessionObserverDigest(
runId: "run-1",
revision: 3,
updatedAt: 300,
headline: "Reviewing",
health: "waiting-on-user")
let existing = self.entry(
key: "agent:main:work",
updatedAt: 100,
status: "running",
lastRunError: "Previous warning",
hasActiveRun: true,
activeRunIds: ["run-1"],
agentStatus: agentStatus,
observerDigest: observerDigest)
let updated = try #require(ChatSessionSidebarModel.applying(
sessionChange: .init(
sessionKey: existing.key,
reason: "message",
updatedAt: 400),
to: [existing]))[0]
#expect(updated.updatedAt == 400)
#expect(updated.agentStatus == agentStatus)
#expect(updated.observerDigest == observerDigest)
#expect(updated.status == "running")
#expect(updated.lastRunError == "Previous warning")
let cleared = try #require(ChatSessionSidebarModel.applying(
sessionChange: .init(
sessionKey: existing.key,
reason: "clear",
agentStatusPresent: true,
observerDigestPresent: true,
statusPresent: true,
lastRunErrorPresent: true),
to: [existing]))[0]
#expect(cleared.agentStatus == nil)
#expect(cleared.observerDigest == nil)
#expect(cleared.status == nil)
#expect(cleared.lastRunError == nil)
}
@Test func `subtitle precedence keeps attention and status above observer digest`() {
let digest = OpenClawChatSessionObserverDigest(
runId: "run-1",
revision: 1,
updatedAt: 200,
headline: "Observer",
health: "on-track")
let agent = OpenClawChatSessionAgentStatus(
note: "Agent note",
expiresAt: 10000,
attention: nil)
let session = self.entry(
key: "work",
updatedAt: 500,
lastReadAt: 100,
status: "failed",
lastRunError: "Needs approval",
hasActiveRun: true,
activeRunIds: ["run-1"],
agentStatus: agent,
observerDigest: digest,
endedAt: 500)
#expect(ChatSessionSidebarModel.subtitle(
for: session,
workSubtitle: "Work",
now: 1000) == "Needs approval")
#expect(ChatSessionSidebarModel.subtitle(
for: self.entry(
key: "work",
status: "running",
hasActiveRun: true,
activeRunIds: ["run-1"],
agentStatus: agent,
observerDigest: digest),
workSubtitle: "Work",
now: 1000) == "Agent note")
}
@Test func `idle final digest remains until its timestamp is read`() {
let digest = OpenClawChatSessionObserverDigest(
revision: 3,
updatedAt: 2000,
headline: "Finished with warnings",
health: "done")
let unread = self.entry(key: "work", lastReadAt: 1999, observerDigest: digest)
let read = self.entry(key: "work", lastReadAt: 2000, observerDigest: digest)
#expect(ChatSessionSidebarModel.subtitle(for: unread, workSubtitle: "Work") == "Finished with warnings")
#expect(ChatSessionSidebarModel.subtitle(for: read, workSubtitle: "Work") == "Work")
}
@Test func `runless terminal digest is idle only and cleared by a later run`() throws {
let digest = OpenClawChatSessionObserverDigest(
revision: 4,
updatedAt: 2000,
headline: "Finished",
health: "done")
var session = self.entry(
key: "agent:main:work",
lastReadAt: 1000,
status: "done",
observerDigest: digest)
#expect(ChatSessionSidebarModel.subtitle(for: session, workSubtitle: "Work") == "Finished")
session.status = "running"
session.hasActiveRun = true
session.activeRunIds = ["run-2"]
#expect(ChatSessionSidebarModel.subtitle(for: session, workSubtitle: "Work") == "Work")
session.status = "done"
session.hasActiveRun = false
session.activeRunIds = []
#expect(ChatSessionSidebarModel.subtitle(for: session, workSubtitle: "Work") == "Finished")
let rolled = try #require(ChatSessionSidebarModel.applying(
sessionChange: .init(
sessionKey: session.key,
reason: "run-start",
status: "running",
hasActiveRun: true,
activeRunIds: ["run-2"]),
to: [session]))
#expect(rolled[0].observerDigest == nil)
}
}
@@ -0,0 +1,124 @@
import { describe, expect, it } from "vitest";
import {
readQaScenarioById,
readQaScenarioExecutionConfig,
validateQaScenarioExecutionConfig,
} from "./scenario-catalog.js";
type CatalogScenario = ReturnType<typeof readQaScenarioById>;
type FlowCatalogScenario = CatalogScenario & {
execution: Extract<CatalogScenario["execution"], { kind: "flow" }>;
};
function requireFlowScenario(scenario: CatalogScenario): FlowCatalogScenario {
expect(scenario.execution.kind).toBe("flow");
if (scenario.execution.kind !== "flow") {
throw new Error(`expected ${scenario.id} to be a flow scenario`);
}
return scenario as FlowCatalogScenario;
}
describe("qa scenario catalog channel contracts", () => {
const agentRuntime = "agent-runtime";
const memory = "session-memory";
it("routes native command session targeting through Crabline Telegram", () => {
const scenario = readQaScenarioById("native-command-session-target");
const config = readQaScenarioExecutionConfig("native-command-session-target") as
| {
requiredProviderMode?: string;
}
| undefined;
expect(scenario.execution.channel).toBe("telegram");
expect(config?.requiredProviderMode).toBe("mock-openai");
});
it("keeps channel-owned scenarios independent from the driver implementation", () => {
const channelByScenarioId = new Map([
["slack-restart-resume", "slack"],
["whatsapp-restart-resume", "whatsapp"],
["whatsapp-access-control-dm-disabled", "whatsapp"],
["whatsapp-access-control-dm-open", "whatsapp"],
["whatsapp-access-control-group-disabled", "whatsapp"],
["whatsapp-access-control-group-open", "whatsapp"],
["whatsapp-pairing-block", "whatsapp"],
["matrix-allowlist-hot-reload", "matrix"],
]);
for (const [scenarioId, channel] of channelByScenarioId) {
expect(readQaScenarioById(scenarioId).execution.channel, scenarioId).toBe(channel);
}
});
it("isolates scenarios that own asynchronous transport state", () => {
const channelBaseline = requireFlowScenario(readQaScenarioById("channel-chat-baseline"));
const subagentFanout = requireFlowScenario(readQaScenarioById("subagent-fanout-synthesis"));
expect(channelBaseline.execution.suiteIsolation).toBe("isolated");
expect(subagentFanout.execution.suiteIsolation).toBe("isolated");
});
it("settles subagent completions before reading the SQLite session store", () => {
const scenario = requireFlowScenario(readQaScenarioById("subagent-fanout-synthesis"));
const flow = JSON.stringify(scenario.execution.flow);
const completionWaits = [...flow.matchAll(/expectedChildCompletionMarkers/gu)].map(
(match) => match.index,
);
const storeReads = [...flow.matchAll(/readRawQaSessionStore/gu)].map((match) => match.index);
expect(completionWaits).toHaveLength(2);
expect(storeReads).toHaveLength(2);
expect(completionWaits.every((wait, index) => wait < (storeReads[index] ?? -1))).toBe(true);
});
it("adds a dreaming shadow trial report scenario", () => {
const scenario = readQaScenarioById("dreaming-shadow-trial-report");
const config = readQaScenarioExecutionConfig("dreaming-shadow-trial-report") as
| {
prompt?: string;
reportName?: string;
expectedReportAll?: string[];
forbiddenReplyNeedles?: string[];
seededMemory?: string;
}
| undefined;
const flow = JSON.stringify(scenario.execution.flow);
expect(scenario.coverage?.primary).toEqual([`${memory}.memory-files-dreaming`]);
expect(scenario.coverage?.secondary).toEqual([
`${memory}.memory-files-promotion`,
`${memory}.memory-files-artifact-safety`,
]);
expect(config?.expectedReportAll).toContain("verdict: helpful");
expect(config?.expectedReportAll).toContain("exact verification commands and remaining risk");
expect(config?.expectedReportAll).toContain("omits the exact command and remaining risk");
expect(config?.expectedReportAll).toContain("calls out the remaining review risk");
expect(config?.forbiddenReplyNeedles).toContain("candidate was promoted to MEMORY.md");
expect(flow).toContain("plannedToolName === 'write'");
expect(flow).toContain("readIndices[1] < firstWrite");
expect(flow).toContain("String(memoryAfter) === config.seededMemory");
});
it("enables Telegram previews for channel streaming evidence", () => {
const scenario = readQaScenarioById("channel-message-flows");
expect(scenario.coverage?.primary).toEqual([`${agentRuntime}.streaming-replies`]);
expect(scenario.coverage?.secondary).toEqual([`${agentRuntime}.streaming-replies-delivery`]);
expect(scenario.gatewayConfigPatch).toMatchObject({
channels: { telegram: { streaming: { mode: "partial" } } },
});
});
it("rejects malformed string matcher lists before running a flow", () => {
expect(() =>
validateQaScenarioExecutionConfig({
gracefulFallbackAny: [{ confirmed: "the hidden fact is present" }],
}),
).toThrow(/gracefulFallbackAny entries must be strings/);
});
it("returns undefined execution config for an unknown scenario id", () => {
expect(readQaScenarioExecutionConfig("missing-scenario-id")).toBeUndefined();
});
});
@@ -10,7 +10,6 @@ import {
readQaScenarioById,
readQaScenarioExecutionConfig,
readQaScenarioPack,
validateQaScenarioExecutionConfig,
} from "./scenario-catalog.js";
import {
flowContainsCall,
@@ -856,104 +855,4 @@ describe("qa scenario catalog", () => {
},
});
});
it("routes native command session targeting through Crabline Telegram", () => {
const scenario = readQaScenarioById("native-command-session-target");
const config = readQaScenarioExecutionConfig("native-command-session-target") as
| {
requiredProviderMode?: string;
}
| undefined;
expect(scenario.execution.channel).toBe("telegram");
expect(config?.requiredProviderMode).toBe("mock-openai");
});
it("keeps channel-owned scenarios independent from the driver implementation", () => {
const channelByScenarioId = new Map([
["slack-restart-resume", "slack"],
["whatsapp-restart-resume", "whatsapp"],
["whatsapp-access-control-dm-disabled", "whatsapp"],
["whatsapp-access-control-dm-open", "whatsapp"],
["whatsapp-access-control-group-disabled", "whatsapp"],
["whatsapp-access-control-group-open", "whatsapp"],
["whatsapp-pairing-block", "whatsapp"],
["matrix-allowlist-hot-reload", "matrix"],
]);
for (const [scenarioId, channel] of channelByScenarioId) {
expect(readQaScenarioById(scenarioId).execution.channel, scenarioId).toBe(channel);
}
});
it("isolates scenarios that own asynchronous transport state", () => {
const channelBaseline = requireFlowScenario(readQaScenarioById("channel-chat-baseline"));
const subagentFanout = requireFlowScenario(readQaScenarioById("subagent-fanout-synthesis"));
expect(channelBaseline.execution.suiteIsolation).toBe("isolated");
expect(subagentFanout.execution.suiteIsolation).toBe("isolated");
});
it("settles subagent completions before reading the SQLite session store", () => {
const scenario = requireFlowScenario(readQaScenarioById("subagent-fanout-synthesis"));
const flow = JSON.stringify(scenario.execution.flow);
const completionWaits = [...flow.matchAll(/expectedChildCompletionMarkers/gu)].map(
(match) => match.index,
);
const storeReads = [...flow.matchAll(/readRawQaSessionStore/gu)].map((match) => match.index);
expect(completionWaits).toHaveLength(2);
expect(storeReads).toHaveLength(2);
expect(completionWaits.every((wait, index) => wait < (storeReads[index] ?? -1))).toBe(true);
});
it("adds a dreaming shadow trial report scenario", () => {
const scenario = readQaScenarioById("dreaming-shadow-trial-report");
const config = readQaScenarioExecutionConfig("dreaming-shadow-trial-report") as
| {
prompt?: string;
reportName?: string;
expectedReportAll?: string[];
forbiddenReplyNeedles?: string[];
seededMemory?: string;
}
| undefined;
const flow = JSON.stringify(scenario.execution.flow);
expect(scenario.coverage?.primary).toEqual([`${memory}.memory-files-dreaming`]);
expect(scenario.coverage?.secondary).toEqual([
`${memory}.memory-files-promotion`,
`${memory}.memory-files-artifact-safety`,
]);
expect(config?.expectedReportAll).toContain("verdict: helpful");
expect(config?.expectedReportAll).toContain("exact verification commands and remaining risk");
expect(config?.expectedReportAll).toContain("omits the exact command and remaining risk");
expect(config?.expectedReportAll).toContain("calls out the remaining review risk");
expect(config?.forbiddenReplyNeedles).toContain("candidate was promoted to MEMORY.md");
expect(flow).toContain("plannedToolName === 'write'");
expect(flow).toContain("readIndices[1] < firstWrite");
expect(flow).toContain("String(memoryAfter) === config.seededMemory");
});
it("enables Telegram previews for channel streaming evidence", () => {
const scenario = readQaScenarioById("channel-message-flows");
expect(scenario.coverage?.primary).toEqual([`${agentRuntime}.streaming-replies`]);
expect(scenario.coverage?.secondary).toEqual([`${agentRuntime}.streaming-replies-delivery`]);
expect(scenario.gatewayConfigPatch).toMatchObject({
channels: { telegram: { streaming: { mode: "partial" } } },
});
});
it("rejects malformed string matcher lists before running a flow", () => {
expect(() =>
validateQaScenarioExecutionConfig({
gracefulFallbackAny: [{ confirmed: "the hidden fact is present" }],
}),
).toThrow(/gracefulFallbackAny entries must be strings/);
});
it("returns undefined execution config for an unknown scenario id", () => {
expect(readQaScenarioExecutionConfig("missing-scenario-id")).toBeUndefined();
});
});
@@ -1,7 +1,6 @@
{
"$comment": "Gateway events each mobile client intentionally does not handle yet, with a one-line reason. Consumed by scripts/check-protocol-event-coverage.mjs (pnpm check:protocol-coverage). Adding a gateway event without a client handler requires either handling it or adding an entry here; the check also fails when an entry goes stale (event removed or now handled).",
"ios": {
"session.observer": "Web Control UI ships the observer HUD first; native session-status surfaces adopt the digest in a follow-up.",
"session.operation": "Chat UI derives run state from chat/agent events; no session.operation consumer yet.",
"session.tool": "Session tool stream is not rendered by the iOS chat surface yet.",
"task.suggestion": "Task suggestion cards are a Control UI-only surface; iOS does not render them.",
@@ -27,7 +26,6 @@
"session.approval": "Native approval review uses exec.approval push/nudge delivery; the session-scoped approval stream is a Control UI chat surface."
},
"android": {
"session.observer": "Web Control UI ships the observer HUD first; native session-status surfaces adopt the digest in a follow-up.",
"session.operation": "Chat UI derives run state from chat/agent events; no session.operation consumer yet.",
"session.tool": "Session tool stream is not rendered by the Android chat surface yet.",
"task.suggestion": "Task suggestion cards are a Control UI-only surface; Android does not render them.",