mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
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:
committed by
GitHub
parent
e10d004257
commit
0b080b9c2e
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user