diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index e79ce447e134..7dc44fd431f6 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -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", diff --git a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt index 5b0d3540779f..7d51928b2e23 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt @@ -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(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(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, + digest: SessionObserverDigest, +): List { + 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?, + 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 diff --git a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatModels.kt b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatModels.kt index 17f2eb2b5c85..69af3058d871 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatModels.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatModels.kt @@ -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? = 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, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt index ad35e761ab39..6c2f636a630a 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt @@ -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, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt index 75f9702be705..3a9d0535f2d6 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt @@ -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) ?: "", ) } diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/SessionObserverDigestTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/SessionObserverDigestTest.kt new file mode 100644 index 000000000000..a5b095be2c16 --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/SessionObserverDigestTest.kt @@ -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, + ) +} diff --git a/apps/ios/Sources/RootSidebar.swift b/apps/ios/Sources/RootSidebar.swift index 4fd5b73a0481..262f6102042b 100644 --- a/apps/ios/Sources/RootSidebar.swift +++ b/apps/ios/Sources/RootSidebar.swift @@ -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) diff --git a/apps/ios/Sources/RootSidebarModel.swift b/apps/ios/Sources/RootSidebarModel.swift index a924bf6c6827..42cb2bbc3f06 100644 --- a/apps/ios/Sources/RootSidebarModel.swift +++ b/apps/ios/Sources/RootSidebarModel.swift @@ -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, + 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 } diff --git a/apps/ios/Sources/RootTabs.swift b/apps/ios/Sources/RootTabs.swift index d1864d8a4f74..878572e3cae5 100644 --- a/apps/ios/Sources/RootTabs.swift +++ b/apps/ios/Sources/RootTabs.swift @@ -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) + } } } diff --git a/apps/ios/Tests/RootTabsPresentationTests.swift b/apps/ios/Tests/RootTabsPresentationTests.swift index 4953b0ee3bf6..a5d66d427f62 100644 --- a/apps/ios/Tests/RootTabsPresentationTests.swift +++ b/apps/ios/Tests/RootTabsPresentationTests.swift @@ -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.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) } diff --git a/apps/macos/Tests/OpenClawIPCTests/MacSessionObserverDigestTests.swift b/apps/macos/Tests/OpenClawIPCTests/MacSessionObserverDigestTests.swift new file mode 100644 index 000000000000..eabf10fbb007 --- /dev/null +++ b/apps/macos/Tests/OpenClawIPCTests/MacSessionObserverDigestTests.swift @@ -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") + } +} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatGatewayPayloadCodec.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatGatewayPayloadCodec.swift index 8cae4575cd9b..6ec152fce86a 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatGatewayPayloadCodec.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatGatewayPayloadCodec.swift @@ -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": diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessionSidebar.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessionSidebar.swift index f579dfceec14..4a31e402c907 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessionSidebar.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessionSidebar.swift @@ -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 { diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessionSidebarModel.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessionSidebarModel.swift index 115e5f1911f9..a812058f9e1e 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessionSidebarModel.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessionSidebarModel.swift @@ -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 { + 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() diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessions.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessions.swift index ff3d13ce7a7d..2e395f1dff08 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessions.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessions.swift @@ -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 diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTransport.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTransport.swift index 8d3afab3c096..0381784d91b1 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTransport.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTransport.swift @@ -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 } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+TransportEvents.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+TransportEvents.swift index 890ddea840b8..747016a5a13d 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+TransportEvents.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+TransportEvents.swift @@ -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): diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatGatewayRequestTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatGatewayRequestTests.swift index e9260088d6fc..1ba6dd3348c5 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatGatewayRequestTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatGatewayRequestTests.swift @@ -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) + } } diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatSessionSidebarModelTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatSessionSidebarModelTests.swift index 4f277eaf4d0d..2861d03a88cc 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatSessionSidebarModelTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatSessionSidebarModelTests.swift @@ -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) + } } diff --git a/extensions/qa-lab/src/scenario-catalog-channels.test.ts b/extensions/qa-lab/src/scenario-catalog-channels.test.ts new file mode 100644 index 000000000000..6fa3eb7c517f --- /dev/null +++ b/extensions/qa-lab/src/scenario-catalog-channels.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "vitest"; +import { + readQaScenarioById, + readQaScenarioExecutionConfig, + validateQaScenarioExecutionConfig, +} from "./scenario-catalog.js"; + +type CatalogScenario = ReturnType; +type FlowCatalogScenario = CatalogScenario & { + execution: Extract; +}; + +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(); + }); +}); diff --git a/extensions/qa-lab/src/scenario-catalog.test.ts b/extensions/qa-lab/src/scenario-catalog.test.ts index f6cc1c16f83d..869ec935c737 100644 --- a/extensions/qa-lab/src/scenario-catalog.test.ts +++ b/extensions/qa-lab/src/scenario-catalog.test.ts @@ -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(); - }); }); diff --git a/scripts/protocol-event-coverage.allowlist.json b/scripts/protocol-event-coverage.allowlist.json index 1697b4ebfc8a..65d573b65f1a 100644 --- a/scripts/protocol-event-coverage.allowlist.json +++ b/scripts/protocol-event-coverage.allowlist.json @@ -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.",