mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-10 00:11:19 +00:00
fix(ios): keep offline chat sends on their original route (#100942)
This commit is contained in:
parent
68487e4c1e
commit
06789ca4c2
42 changed files with 5837 additions and 952 deletions
|
|
@ -8251,7 +8251,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 110,
|
||||
"line": 114,
|
||||
"path": "apps/ios/Sources/Design/ChatProTab.swift",
|
||||
"source": "Unable to Export Transcript",
|
||||
"surface": "apple",
|
||||
|
|
@ -8259,7 +8259,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 114,
|
||||
"line": 118,
|
||||
"path": "apps/ios/Sources/Design/ChatProTab.swift",
|
||||
"source": "OK",
|
||||
"surface": "apple",
|
||||
|
|
@ -8267,7 +8267,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 118,
|
||||
"line": 122,
|
||||
"path": "apps/ios/Sources/Design/ChatProTab.swift",
|
||||
"source": "OpenClaw could not prepare the Markdown file.",
|
||||
"surface": "apple",
|
||||
|
|
@ -8275,7 +8275,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 138,
|
||||
"line": 143,
|
||||
"path": "apps/ios/Sources/Design/ChatProTab.swift",
|
||||
"source": "What would you like to work on?",
|
||||
"surface": "apple",
|
||||
|
|
@ -8283,7 +8283,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 149,
|
||||
"line": 154,
|
||||
"path": "apps/ios/Sources/Design/ChatProTab.swift",
|
||||
"source": "The session attaches once the gateway is ready.",
|
||||
"surface": "apple",
|
||||
|
|
@ -8291,7 +8291,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-modifier",
|
||||
"line": 238,
|
||||
"line": 253,
|
||||
"path": "apps/ios/Sources/Design/ChatProTab.swift",
|
||||
"source": "Opens Settings / Gateway",
|
||||
"surface": "apple",
|
||||
|
|
@ -8299,7 +8299,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 263,
|
||||
"line": 278,
|
||||
"path": "apps/ios/Sources/Design/ChatProTab.swift",
|
||||
"source": "New Chat",
|
||||
"surface": "apple",
|
||||
|
|
@ -8307,7 +8307,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 276,
|
||||
"line": 291,
|
||||
"path": "apps/ios/Sources/Design/ChatProTab.swift",
|
||||
"source": "New Chat in Worktree",
|
||||
"surface": "apple",
|
||||
|
|
@ -8315,7 +8315,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 291,
|
||||
"line": 306,
|
||||
"path": "apps/ios/Sources/Design/ChatProTab.swift",
|
||||
"source": "Export Transcript",
|
||||
"surface": "apple",
|
||||
|
|
@ -8323,7 +8323,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-modifier",
|
||||
"line": 301,
|
||||
"line": 316,
|
||||
"path": "apps/ios/Sources/Design/ChatProTab.swift",
|
||||
"source": "Chat actions",
|
||||
"surface": "apple",
|
||||
|
|
@ -8331,7 +8331,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 354,
|
||||
"line": 369,
|
||||
"path": "apps/ios/Sources/Design/ChatProTab.swift",
|
||||
"source": "Connected",
|
||||
"surface": "apple",
|
||||
|
|
@ -8339,31 +8339,39 @@
|
|||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 354,
|
||||
"line": 369,
|
||||
"path": "apps/ios/Sources/Design/ChatProTab.swift",
|
||||
"source": "Unavailable",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.04934af85e3b9d6e"
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 365,
|
||||
"path": "apps/ios/Sources/Design/ChatProTab.swift",
|
||||
"source": "Connect to a gateway",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.889fe76e02b8ad5d"
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 365,
|
||||
"kind": "ui-localized-call",
|
||||
"line": 381,
|
||||
"path": "apps/ios/Sources/Design/ChatProTab.swift",
|
||||
"source": "Message \\(self.agentDisplayName)...",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.e8173c7d24457256"
|
||||
"id": "native.apple.4dcedefea4b49a1c"
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 419,
|
||||
"line": 384,
|
||||
"path": "apps/ios/Sources/Design/ChatProTab.swift",
|
||||
"source": "Message \\(self.agentDisplayName); sends when connected",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.84767ec18054524b"
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 386,
|
||||
"path": "apps/ios/Sources/Design/ChatProTab.swift",
|
||||
"source": "Connect to a gateway",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.11b20bef7e51ab69"
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 454,
|
||||
"path": "apps/ios/Sources/Design/ChatProTab.swift",
|
||||
"source": "Check OpenClaw status",
|
||||
"surface": "apple",
|
||||
|
|
@ -8371,7 +8379,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 420,
|
||||
"line": 455,
|
||||
"path": "apps/ios/Sources/Design/ChatProTab.swift",
|
||||
"source": "Summarize the current OpenClaw status and tell me what needs attention.",
|
||||
"surface": "apple",
|
||||
|
|
@ -8379,7 +8387,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 423,
|
||||
"line": 458,
|
||||
"path": "apps/ios/Sources/Design/ChatProTab.swift",
|
||||
"source": "What can I control here?",
|
||||
"surface": "apple",
|
||||
|
|
@ -8387,7 +8395,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 424,
|
||||
"line": 459,
|
||||
"path": "apps/ios/Sources/Design/ChatProTab.swift",
|
||||
"source": "Show me which phone controls and device capabilities are available right now.",
|
||||
"surface": "apple",
|
||||
|
|
@ -8395,7 +8403,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 427,
|
||||
"line": 462,
|
||||
"path": "apps/ios/Sources/Design/ChatProTab.swift",
|
||||
"source": "Help me start voice chat",
|
||||
"surface": "apple",
|
||||
|
|
@ -8403,7 +8411,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 428,
|
||||
"line": 463,
|
||||
"path": "apps/ios/Sources/Design/ChatProTab.swift",
|
||||
"source": "Help me start a realtime voice session from this phone.",
|
||||
"surface": "apple",
|
||||
|
|
@ -12443,7 +12451,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 2757,
|
||||
"line": 2840,
|
||||
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
|
||||
"source": "Action required",
|
||||
"surface": "apple",
|
||||
|
|
@ -12451,7 +12459,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 2757,
|
||||
"line": 2840,
|
||||
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
|
||||
"source": "Approval needed",
|
||||
"surface": "apple",
|
||||
|
|
@ -12459,7 +12467,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 3315,
|
||||
"line": 3398,
|
||||
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
|
||||
"source": "Connecting…",
|
||||
"surface": "apple",
|
||||
|
|
@ -12467,7 +12475,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 3315,
|
||||
"line": 3398,
|
||||
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
|
||||
"source": "Reconnecting…",
|
||||
"surface": "apple",
|
||||
|
|
@ -12475,7 +12483,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 3319,
|
||||
"line": 3402,
|
||||
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
|
||||
"source": "Connecting...",
|
||||
"surface": "apple",
|
||||
|
|
@ -12483,7 +12491,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 3319,
|
||||
"line": 3402,
|
||||
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
|
||||
"source": "Reconnecting...",
|
||||
"surface": "apple",
|
||||
|
|
@ -12491,7 +12499,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 3610,
|
||||
"line": 3694,
|
||||
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
|
||||
"source": "Connected",
|
||||
"surface": "apple",
|
||||
|
|
@ -12499,7 +12507,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 3610,
|
||||
"line": 3694,
|
||||
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
|
||||
"source": "Offline",
|
||||
"surface": "apple",
|
||||
|
|
@ -21523,7 +21531,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 197,
|
||||
"line": 198,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
|
||||
"source": "Thinking",
|
||||
"surface": "apple",
|
||||
|
|
@ -21531,7 +21539,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 218,
|
||||
"line": 219,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
|
||||
"source": "Model",
|
||||
"surface": "apple",
|
||||
|
|
@ -21539,7 +21547,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 235,
|
||||
"line": 236,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
|
||||
"source": "Pinned",
|
||||
"surface": "apple",
|
||||
|
|
@ -21547,7 +21555,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 243,
|
||||
"line": 244,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
|
||||
"source": "Recent",
|
||||
"surface": "apple",
|
||||
|
|
@ -21555,7 +21563,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 251,
|
||||
"line": 252,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
|
||||
"source": "Models",
|
||||
"surface": "apple",
|
||||
|
|
@ -21563,7 +21571,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 280,
|
||||
"line": 281,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
|
||||
"source": "Pin model",
|
||||
"surface": "apple",
|
||||
|
|
@ -21571,7 +21579,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 280,
|
||||
"line": 281,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
|
||||
"source": "Unpin model",
|
||||
"surface": "apple",
|
||||
|
|
@ -21579,7 +21587,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 284,
|
||||
"line": 285,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
|
||||
"source": "Session",
|
||||
"surface": "apple",
|
||||
|
|
@ -21587,7 +21595,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-modifier",
|
||||
"line": 348,
|
||||
"line": 349,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
|
||||
"source": "Add Image",
|
||||
"surface": "apple",
|
||||
|
|
@ -21595,7 +21603,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-modifier",
|
||||
"line": 349,
|
||||
"line": 350,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
|
||||
"source": "Attachments",
|
||||
"surface": "apple",
|
||||
|
|
@ -21603,7 +21611,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 519,
|
||||
"line": 520,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
|
||||
"source": "Talk",
|
||||
"surface": "apple",
|
||||
|
|
@ -21611,7 +21619,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 565,
|
||||
"line": 566,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
|
||||
"source": "Start realtime chat",
|
||||
"surface": "apple",
|
||||
|
|
@ -21619,7 +21627,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 565,
|
||||
"line": 566,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
|
||||
"source": "Stop realtime chat",
|
||||
"surface": "apple",
|
||||
|
|
@ -21627,7 +21635,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 598,
|
||||
"line": 599,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
|
||||
"source": "Start",
|
||||
"surface": "apple",
|
||||
|
|
@ -21635,7 +21643,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 598,
|
||||
"line": 599,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
|
||||
"source": "Stop",
|
||||
"surface": "apple",
|
||||
|
|
@ -21643,7 +21651,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 708,
|
||||
"line": 709,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
|
||||
"source": "Loading commands",
|
||||
"surface": "apple",
|
||||
|
|
@ -21651,7 +21659,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 717,
|
||||
"line": 718,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
|
||||
"source": "Commands unavailable",
|
||||
"surface": "apple",
|
||||
|
|
@ -21659,7 +21667,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 726,
|
||||
"line": 727,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
|
||||
"source": "Retry",
|
||||
"surface": "apple",
|
||||
|
|
@ -21667,7 +21675,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 735,
|
||||
"line": 736,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
|
||||
"source": "No matching commands",
|
||||
"surface": "apple",
|
||||
|
|
@ -21675,7 +21683,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-modifier",
|
||||
"line": 867,
|
||||
"line": 868,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
|
||||
"source": "Stop response",
|
||||
"surface": "apple",
|
||||
|
|
@ -21683,7 +21691,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-modifier",
|
||||
"line": 892,
|
||||
"line": 893,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
|
||||
"source": "Send message",
|
||||
"surface": "apple",
|
||||
|
|
@ -21691,7 +21699,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-modifier",
|
||||
"line": 907,
|
||||
"line": 908,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
|
||||
"source": "Refresh",
|
||||
"surface": "apple",
|
||||
|
|
@ -21699,7 +21707,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 1015,
|
||||
"line": 1018,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
|
||||
"source": "Connecting...",
|
||||
"surface": "apple",
|
||||
|
|
@ -21707,7 +21715,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 1015,
|
||||
"line": 1018,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
|
||||
"source": "Gateway connected",
|
||||
"surface": "apple",
|
||||
|
|
@ -21765,13 +21773,29 @@
|
|||
"kind": "conditional-branch",
|
||||
"line": 644,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
|
||||
"source": "Confirming…",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.38722ea25417ad4f"
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 646,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
|
||||
"source": "Delivery unknown",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.78682085b14232f8"
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 648,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
|
||||
"source": "Not sent",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.2888a268bfa49e9e"
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 662,
|
||||
"line": 670,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
|
||||
"source": "Queued, sends when reconnected",
|
||||
"surface": "apple",
|
||||
|
|
@ -21779,7 +21803,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 664,
|
||||
"line": 672,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
|
||||
"source": "Sending",
|
||||
"surface": "apple",
|
||||
|
|
@ -21787,7 +21811,23 @@
|
|||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 666,
|
||||
"line": 674,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
|
||||
"source": "Sent, waiting for chat history confirmation",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.bfe7aab77abe9a2c"
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 676,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
|
||||
"source": "Delivery unconfirmed, touch and hold to retry or delete",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.7eb6eca416d33bb4"
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 678,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
|
||||
"source": "Not sent, touch and hold to retry or delete",
|
||||
"surface": "apple",
|
||||
|
|
@ -21795,7 +21835,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 761,
|
||||
"line": 773,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
|
||||
"source": "Running tools…",
|
||||
"surface": "apple",
|
||||
|
|
@ -21803,7 +21843,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 769,
|
||||
"line": 781,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
|
||||
"source": "\\(display.emoji) \\(display.label)",
|
||||
"surface": "apple",
|
||||
|
|
@ -21819,7 +21859,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 416,
|
||||
"line": 420,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
|
||||
"source": "Retry Send",
|
||||
"surface": "apple",
|
||||
|
|
@ -21827,7 +21867,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 431,
|
||||
"line": 434,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
|
||||
"source": "Delete",
|
||||
"surface": "apple",
|
||||
|
|
@ -21835,7 +21875,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 485,
|
||||
"line": 488,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
|
||||
"source": "Jump to latest",
|
||||
"surface": "apple",
|
||||
|
|
@ -21843,7 +21883,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-modifier",
|
||||
"line": 496,
|
||||
"line": 499,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
|
||||
"source": "Jump to latest reply",
|
||||
"surface": "apple",
|
||||
|
|
@ -21851,7 +21891,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 516,
|
||||
"line": 519,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
|
||||
"source": "Refresh",
|
||||
"surface": "apple",
|
||||
|
|
@ -21859,7 +21899,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 941,
|
||||
"line": 944,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
|
||||
"source": "Loading chat",
|
||||
"surface": "apple",
|
||||
|
|
@ -21867,7 +21907,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-modifier",
|
||||
"line": 1021,
|
||||
"line": 1024,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
|
||||
"source": "Dismiss",
|
||||
"surface": "apple",
|
||||
|
|
@ -21881,6 +21921,22 @@
|
|||
"surface": "apple",
|
||||
"id": "native.apple.82c2287cd78da56f"
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 1350,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift",
|
||||
"source": "delivery unconfirmed",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.2b45abdc56b2caed"
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 1350,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift",
|
||||
"source": "queued after route change",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.722f1f90b97e8e45"
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 235,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,11 @@ struct IOSGatewayChatTransport: OpenClawChatTransport {
|
|||
static let compactionRequestTimeoutSeconds = 0
|
||||
private let gateway: GatewayNodeSession
|
||||
private let globalAgentId: String?
|
||||
private let outboxGatewayID: String?
|
||||
|
||||
var outboxRequiresSessionRoutingContract: Bool {
|
||||
true
|
||||
}
|
||||
|
||||
private struct CreateSessionParams: Codable {
|
||||
var key: String
|
||||
|
|
@ -87,6 +92,7 @@ struct IOSGatewayChatTransport: OpenClawChatTransport {
|
|||
private struct ChatSendParams: Codable {
|
||||
var sessionKey: String
|
||||
var agentId: String?
|
||||
var expectedSessionRoutingContract: String?
|
||||
var message: String
|
||||
var thinking: String
|
||||
var attachments: [OpenClawChatAttachmentPayload]?
|
||||
|
|
@ -126,10 +132,73 @@ struct IOSGatewayChatTransport: OpenClawChatTransport {
|
|||
}
|
||||
}
|
||||
|
||||
init(gateway: GatewayNodeSession, globalAgentId: String? = nil) {
|
||||
init(
|
||||
gateway: GatewayNodeSession,
|
||||
globalAgentId: String? = nil,
|
||||
outboxGatewayID: String? = nil)
|
||||
{
|
||||
self.gateway = gateway
|
||||
let normalized = globalAgentId?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
self.globalAgentId = normalized?.isEmpty == false ? normalized : nil
|
||||
let normalizedGatewayID = outboxGatewayID?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.outboxGatewayID = normalizedGatewayID?.isEmpty == false ? normalizedGatewayID : nil
|
||||
}
|
||||
|
||||
func acquireOutboxRouteLease() async -> OpenClawChatTransportRouteLeaseResult {
|
||||
guard let outboxGatewayID,
|
||||
let route = await gateway.currentRoute(ifGatewayID: outboxGatewayID)
|
||||
else { return .unavailable(reason: nil) }
|
||||
guard let supportsRoutingContract = await gateway.supportsServerCapability(
|
||||
.chatSendRoutingContract,
|
||||
ifCurrentRoute: route)
|
||||
else { return .unavailable(reason: nil) }
|
||||
guard supportsRoutingContract else {
|
||||
return .unavailable(reason: OpenClawChatTransportUpgradeMessage.routingContract)
|
||||
}
|
||||
let transport = self
|
||||
guard let routingContract = try? await transport.sessionRoutingContract(ifCurrentRoute: route)
|
||||
else { return .unavailable(reason: nil) }
|
||||
return .available(OpenClawChatTransportRouteLease(
|
||||
sendTargetedMessage: { sessionKey, agentID, message, thinking, idempotencyKey, attachments in
|
||||
try await transport.sendMessage(
|
||||
sessionKey: sessionKey,
|
||||
agentID: agentID,
|
||||
expectedSessionRoutingContract: routingContract,
|
||||
message: message,
|
||||
thinking: thinking,
|
||||
idempotencyKey: idempotencyKey,
|
||||
attachments: attachments,
|
||||
ifCurrentRoute: route,
|
||||
distinguishPreDispatchRouteChange: true)
|
||||
},
|
||||
requestTargetedHistory: { sessionKey, agentID in
|
||||
try await transport.requestHistory(
|
||||
sessionKey: sessionKey,
|
||||
agentID: agentID,
|
||||
ifCurrentRoute: route)
|
||||
},
|
||||
sessionRoutingContract: routingContract))
|
||||
}
|
||||
|
||||
private func sessionRoutingContract(
|
||||
ifCurrentRoute route: GatewayNodeSessionRoute) async throws -> String
|
||||
{
|
||||
let data = try await gateway.request(
|
||||
method: "agents.list",
|
||||
paramsJSON: "{}",
|
||||
timeoutSeconds: 15,
|
||||
ifCurrentRoute: route)
|
||||
return try Self.decodeSessionRoutingContract(data)
|
||||
}
|
||||
|
||||
static func decodeSessionRoutingContract(_ data: Data) throws -> String {
|
||||
let result = try JSONDecoder().decode(AgentsListResult.self, from: data)
|
||||
guard let contract = OpenClawChatSessionRoutingContract.make(
|
||||
scope: result.scope.value as? String,
|
||||
mainKey: result.mainkey,
|
||||
defaultAgentID: result.defaultid)
|
||||
else { throw CancellationError() }
|
||||
return contract
|
||||
}
|
||||
|
||||
static func agentWaitRequestTimeoutSeconds(timeoutMs: Int) -> Int {
|
||||
|
|
@ -173,6 +242,7 @@ struct IOSGatewayChatTransport: OpenClawChatTransport {
|
|||
static func makeChatSendParamsJSON(
|
||||
sessionKey: String,
|
||||
agentId: String? = nil,
|
||||
expectedSessionRoutingContract: String? = nil,
|
||||
message: String,
|
||||
thinking: String,
|
||||
idempotencyKey: String,
|
||||
|
|
@ -181,6 +251,7 @@ struct IOSGatewayChatTransport: OpenClawChatTransport {
|
|||
let params = ChatSendParams(
|
||||
sessionKey: sessionKey,
|
||||
agentId: agentId,
|
||||
expectedSessionRoutingContract: expectedSessionRoutingContract,
|
||||
message: message,
|
||||
thinking: thinking,
|
||||
attachments: attachments.isEmpty ? nil : attachments,
|
||||
|
|
@ -304,27 +375,71 @@ struct IOSGatewayChatTransport: OpenClawChatTransport {
|
|||
: nil
|
||||
}
|
||||
|
||||
struct SessionTarget: Equatable {
|
||||
var sessionKey: String
|
||||
var agentID: String?
|
||||
}
|
||||
|
||||
static func sessionTarget(
|
||||
for rawSessionKey: String,
|
||||
selectedAgentID: String?,
|
||||
overrideAgentID: String? = nil) -> SessionTarget
|
||||
{
|
||||
let sessionKey = rawSessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let selected = selectedAgentID?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
let override = overrideAgentID?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
if self.agentID(fromSessionKey: sessionKey) != nil {
|
||||
return SessionTarget(sessionKey: sessionKey, agentID: override)
|
||||
}
|
||||
if sessionKey.lowercased().hasPrefix("agent:") {
|
||||
return SessionTarget(sessionKey: sessionKey, agentID: nil)
|
||||
}
|
||||
if sessionKey.lowercased() == "unknown" {
|
||||
return SessionTarget(sessionKey: sessionKey, agentID: nil)
|
||||
}
|
||||
let targetAgentID = override ?? (selected?.isEmpty == false ? selected : nil)
|
||||
if sessionKey.lowercased() == "global" {
|
||||
return SessionTarget(sessionKey: sessionKey, agentID: targetAgentID)
|
||||
}
|
||||
if let targetAgentID {
|
||||
return SessionTarget(sessionKey: "agent:\(targetAgentID):\(sessionKey)", agentID: nil)
|
||||
}
|
||||
return SessionTarget(sessionKey: sessionKey, agentID: nil)
|
||||
}
|
||||
|
||||
private func sessionTarget(
|
||||
for sessionKey: String,
|
||||
overrideAgentID: String? = nil) -> SessionTarget
|
||||
{
|
||||
Self.sessionTarget(
|
||||
for: sessionKey,
|
||||
selectedAgentID: self.globalAgentId,
|
||||
overrideAgentID: overrideAgentID)
|
||||
}
|
||||
|
||||
func createSession(
|
||||
key: String,
|
||||
label: String?,
|
||||
parentSessionKey: String?,
|
||||
worktree: Bool?) async throws -> OpenClawChatCreateSessionResponse
|
||||
{
|
||||
let target = self.sessionTarget(for: key)
|
||||
let parentTarget = parentSessionKey.map { self.sessionTarget(for: $0) }
|
||||
let json = try Self.makeCreateSessionParamsJSON(
|
||||
key: key,
|
||||
agentId: Self.agentID(fromSessionKey: key) ??
|
||||
parentSessionKey.flatMap { self.selectedGlobalAgentId(for: $0) },
|
||||
key: target.sessionKey,
|
||||
agentId: target.agentID ?? parentTarget?.agentID,
|
||||
label: label,
|
||||
parentSessionKey: parentSessionKey,
|
||||
parentSessionKey: parentTarget?.sessionKey,
|
||||
worktree: worktree)
|
||||
let res = try await self.gateway.request(method: "sessions.create", paramsJSON: json, timeoutSeconds: 15)
|
||||
return try JSONDecoder().decode(OpenClawChatCreateSessionResponse.self, from: res)
|
||||
}
|
||||
|
||||
func abortRun(sessionKey: String, runId: String) async throws {
|
||||
let target = self.sessionTarget(for: sessionKey)
|
||||
let json = try Self.makeRunParamsJSON(
|
||||
sessionKey: sessionKey,
|
||||
agentId: self.selectedGlobalAgentId(for: sessionKey),
|
||||
sessionKey: target.sessionKey,
|
||||
agentId: target.agentID,
|
||||
runId: runId)
|
||||
_ = try await self.gateway.request(method: "chat.abort", paramsJSON: json, timeoutSeconds: 10)
|
||||
}
|
||||
|
|
@ -348,9 +463,10 @@ struct IOSGatewayChatTransport: OpenClawChatTransport {
|
|||
}
|
||||
|
||||
func setSessionModel(sessionKey: String, model: String?) async throws {
|
||||
let target = self.sessionTarget(for: sessionKey)
|
||||
let json = try Self.makeSessionPatchModelParamsJSON(
|
||||
sessionKey: sessionKey,
|
||||
agentId: self.selectedGlobalAgentId(for: sessionKey),
|
||||
sessionKey: target.sessionKey,
|
||||
agentId: target.agentID,
|
||||
model: model)
|
||||
_ = try await self.gateway.request(method: "sessions.patch", paramsJSON: json, timeoutSeconds: 15)
|
||||
}
|
||||
|
|
@ -395,9 +511,8 @@ struct IOSGatewayChatTransport: OpenClawChatTransport {
|
|||
var key: String
|
||||
var agentId: String?
|
||||
}
|
||||
let data = try JSONEncoder().encode(Params(
|
||||
key: sessionKey,
|
||||
agentId: self.selectedGlobalAgentId(for: sessionKey)))
|
||||
let target = self.sessionTarget(for: sessionKey)
|
||||
let data = try JSONEncoder().encode(Params(key: target.sessionKey, agentId: target.agentID))
|
||||
let json = String(data: data, encoding: .utf8)
|
||||
_ = try await self.gateway.request(
|
||||
method: "sessions.messages.subscribe",
|
||||
|
|
@ -406,17 +521,15 @@ struct IOSGatewayChatTransport: OpenClawChatTransport {
|
|||
}
|
||||
|
||||
func resetSession(sessionKey: String) async throws {
|
||||
let json = try Self.makeSessionKeyParamsJSON(
|
||||
sessionKey,
|
||||
agentId: self.selectedGlobalAgentId(for: sessionKey))
|
||||
let target = self.sessionTarget(for: sessionKey)
|
||||
let json = try Self.makeSessionKeyParamsJSON(target.sessionKey, agentId: target.agentID)
|
||||
_ = try await self.gateway.request(method: "sessions.reset", paramsJSON: json, timeoutSeconds: 10)
|
||||
}
|
||||
|
||||
func compactSession(sessionKey: String) async throws {
|
||||
let json = try Self.makeSessionKeyParamsJSON(
|
||||
sessionKey,
|
||||
agentId: self.selectedGlobalAgentId(for: sessionKey))
|
||||
let response = try await self.gateway.request(
|
||||
let target = self.sessionTarget(for: sessionKey)
|
||||
let json = try Self.makeSessionKeyParamsJSON(target.sessionKey, agentId: target.agentID)
|
||||
let response = try await gateway.request(
|
||||
method: "sessions.compact",
|
||||
paramsJSON: json,
|
||||
timeoutSeconds: Self.compactionRequestTimeoutSeconds)
|
||||
|
|
@ -424,17 +537,19 @@ struct IOSGatewayChatTransport: OpenClawChatTransport {
|
|||
}
|
||||
|
||||
func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload {
|
||||
try await self.requestHistory(sessionKey: sessionKey, ifCurrentRoute: nil)
|
||||
try await self.requestHistory(sessionKey: sessionKey, agentID: nil, ifCurrentRoute: nil)
|
||||
}
|
||||
|
||||
func requestHistory(
|
||||
sessionKey: String,
|
||||
agentID: String? = nil,
|
||||
ifCurrentRoute expectedRoute: GatewayNodeSessionRoute?) async throws -> OpenClawChatHistoryPayload
|
||||
{
|
||||
let target = self.sessionTarget(for: sessionKey, overrideAgentID: agentID)
|
||||
let json = try Self.makeHistoryParamsJSON(
|
||||
sessionKey: sessionKey,
|
||||
agentId: self.selectedGlobalAgentId(for: sessionKey))
|
||||
let res = try await self.gateway.request(
|
||||
sessionKey: target.sessionKey,
|
||||
agentId: target.agentID)
|
||||
let res = try await gateway.request(
|
||||
method: "chat.history",
|
||||
paramsJSON: json,
|
||||
timeoutSeconds: 15,
|
||||
|
|
@ -449,8 +564,8 @@ struct IOSGatewayChatTransport: OpenClawChatTransport {
|
|||
func listCommands(sessionKey: String) async throws -> [OpenClawChatCommandChoice] {
|
||||
let json = try Self.makeCommandsListParamsJSON(
|
||||
sessionKey: sessionKey,
|
||||
agentId: self.selectedGlobalAgentId(for: sessionKey))
|
||||
let res = try await self.gateway.request(method: "commands.list", paramsJSON: json, timeoutSeconds: 15)
|
||||
agentId: Self.agentID(fromSessionKey: sessionKey) ?? self.globalAgentId)
|
||||
let res = try await gateway.request(method: "commands.list", paramsJSON: json, timeoutSeconds: 15)
|
||||
let decoded = try JSONDecoder().decode(CommandsListResult.self, from: res)
|
||||
return decoded.commands.map(Self.mapCommandChoice)
|
||||
}
|
||||
|
|
@ -464,6 +579,7 @@ struct IOSGatewayChatTransport: OpenClawChatTransport {
|
|||
{
|
||||
try await self.sendMessage(
|
||||
sessionKey: sessionKey,
|
||||
agentID: nil,
|
||||
message: message,
|
||||
thinking: thinking,
|
||||
idempotencyKey: idempotencyKey,
|
||||
|
|
@ -473,35 +589,83 @@ struct IOSGatewayChatTransport: OpenClawChatTransport {
|
|||
|
||||
func sendMessage(
|
||||
sessionKey: String,
|
||||
agentID: String?,
|
||||
expectedSessionRoutingContract: String?,
|
||||
message: String,
|
||||
thinking: String,
|
||||
idempotencyKey: String,
|
||||
attachments: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse
|
||||
{
|
||||
let route: GatewayNodeSessionRoute? = if let outboxGatewayID {
|
||||
await self.gateway.currentRoute(ifGatewayID: outboxGatewayID)
|
||||
} else {
|
||||
await self.gateway.currentRoute()
|
||||
}
|
||||
guard let route,
|
||||
let supportsRoutingContract = await gateway.supportsServerCapability(
|
||||
.chatSendRoutingContract,
|
||||
ifCurrentRoute: route)
|
||||
else { throw OpenClawChatTransportSendError.notDispatched }
|
||||
// Durable replay requires the atomic server guard and is blocked in
|
||||
// acquireOutboxRouteLease. Keep ordinary live chat compatible with
|
||||
// older gateways by retaining the captured route but omitting the
|
||||
// unsupported request field.
|
||||
let guardedContract = OpenClawChatSessionRoutingContract.expectedValue(
|
||||
expectedSessionRoutingContract,
|
||||
serverSupportsGuard: supportsRoutingContract)
|
||||
return try await self.sendMessage(
|
||||
sessionKey: sessionKey,
|
||||
agentID: agentID,
|
||||
expectedSessionRoutingContract: guardedContract,
|
||||
message: message,
|
||||
thinking: thinking,
|
||||
idempotencyKey: idempotencyKey,
|
||||
attachments: attachments,
|
||||
ifCurrentRoute: route,
|
||||
distinguishPreDispatchRouteChange: true)
|
||||
}
|
||||
|
||||
func sendMessage(
|
||||
sessionKey: String,
|
||||
agentID: String? = nil,
|
||||
expectedSessionRoutingContract: String? = nil,
|
||||
message: String,
|
||||
thinking: String,
|
||||
idempotencyKey: String,
|
||||
attachments: [OpenClawChatAttachmentPayload],
|
||||
ifCurrentRoute expectedRoute: GatewayNodeSessionRoute?) async throws -> OpenClawChatSendResponse
|
||||
ifCurrentRoute expectedRoute: GatewayNodeSessionRoute?,
|
||||
distinguishPreDispatchRouteChange: Bool = false) async throws -> OpenClawChatSendResponse
|
||||
{
|
||||
let target = self.sessionTarget(for: sessionKey, overrideAgentID: agentID)
|
||||
let startLogMessage =
|
||||
"chat.send start sessionKey=\(sessionKey) "
|
||||
"chat.send start sessionKey=\(target.sessionKey) "
|
||||
+ "len=\(message.count) attachments=\(attachments.count)"
|
||||
Self.logger.info(
|
||||
"\(startLogMessage, privacy: .public)")
|
||||
GatewayDiagnostics.log(startLogMessage)
|
||||
let json = try Self.makeChatSendParamsJSON(
|
||||
sessionKey: sessionKey,
|
||||
agentId: self.selectedGlobalAgentId(for: sessionKey),
|
||||
sessionKey: target.sessionKey,
|
||||
agentId: target.agentID,
|
||||
expectedSessionRoutingContract: expectedSessionRoutingContract,
|
||||
message: message,
|
||||
thinking: thinking,
|
||||
idempotencyKey: idempotencyKey,
|
||||
attachments: attachments)
|
||||
do {
|
||||
let res = try await self.gateway.request(
|
||||
let res = try await gateway.request(
|
||||
method: "chat.send",
|
||||
paramsJSON: json,
|
||||
timeoutSeconds: 35,
|
||||
ifCurrentRoute: expectedRoute)
|
||||
ifCurrentRoute: expectedRoute,
|
||||
distinguishPreDispatchRouteChange: distinguishPreDispatchRouteChange)
|
||||
let decoded = try JSONDecoder().decode(OpenClawChatSendResponse.self, from: res)
|
||||
Self.logger.info("chat.send ok runId=\(decoded.runId, privacy: .public)")
|
||||
GatewayDiagnostics.log("chat.send ok runId=\(decoded.runId) status=\(decoded.status)")
|
||||
return decoded
|
||||
} catch is GatewayNodeSessionRequestError {
|
||||
Self.logger.info("chat.send skipped because the captured route changed before dispatch")
|
||||
GatewayDiagnostics.log("chat.send skipped before dispatch: route changed")
|
||||
throw OpenClawChatTransportSendError.notDispatched
|
||||
} catch {
|
||||
Self.logger.error("chat.send failed \(error.localizedDescription, privacy: .public)")
|
||||
GatewayDiagnostics.log("chat.send failed error=\(error.localizedDescription)")
|
||||
|
|
@ -559,7 +723,7 @@ struct IOSGatewayChatTransport: OpenClawChatTransport {
|
|||
let json = try Self.makeAgentWaitParamsJSON(runId: runId, timeoutMs: timeoutMs)
|
||||
let requestTimeoutSeconds = Self.agentWaitRequestTimeoutSeconds(timeoutMs: timeoutMs)
|
||||
GatewayDiagnostics.log("agent.wait start runId=\(runId)")
|
||||
let res = try await self.gateway.request(
|
||||
let res = try await gateway.request(
|
||||
method: "agent.wait",
|
||||
paramsJSON: json,
|
||||
timeoutSeconds: requestTimeoutSeconds,
|
||||
|
|
@ -580,7 +744,7 @@ struct IOSGatewayChatTransport: OpenClawChatTransport {
|
|||
|
||||
func requestHealth(timeoutMs: Int) async throws -> Bool {
|
||||
let seconds = max(1, Int(ceil(Double(timeoutMs) / 1000.0)))
|
||||
let res = try await self.gateway.request(method: "health", paramsJSON: nil, timeoutSeconds: seconds)
|
||||
let res = try await gateway.request(method: "health", paramsJSON: nil, timeoutSeconds: seconds)
|
||||
return (try? JSONDecoder().decode(OpenClawGatewayHealthOK.self, from: res))?.ok ?? true
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,13 +10,13 @@ struct ChatProTab: View {
|
|||
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
@State private var viewModel: OpenClawChatViewModel?
|
||||
@State private var viewModelTransportModeID = ""
|
||||
@State private var viewModelOwnerID = ""
|
||||
@State private var transcriptShareItem: TranscriptShareItem?
|
||||
@State private var showsTranscriptExportError = false
|
||||
// Transport can start unscoped while the UI uses its "main" fallback.
|
||||
// Track the real agent separately so gateway metadata always rebuilds it.
|
||||
// Track the real agent so gateway metadata replaces the captured transport.
|
||||
@State private var viewModelTransportAgentID = ""
|
||||
@State private var viewModelAgentID = ""
|
||||
@State private var viewModelRoutingContract = ""
|
||||
let headerLeadingAction: OpenClawSidebarHeaderAction?
|
||||
let headerTitle: String?
|
||||
let showsAgentBadge: Bool
|
||||
|
|
@ -50,12 +50,13 @@ struct ChatProTab: View {
|
|||
}
|
||||
}
|
||||
.task {
|
||||
await self.appModel.restoreChatSessionRoutingIdentityIfNeeded()
|
||||
self.syncChatViewModel()
|
||||
}
|
||||
.onChange(of: self.appModel.chatSessionKey) { _, _ in
|
||||
self.syncChatViewModel()
|
||||
}
|
||||
.onChange(of: self.appModel.chatViewModelIdentityID) { _, _ in
|
||||
.onChange(of: self.appModel.chatViewModelOwnerID) { _, _ in
|
||||
self.syncChatViewModel()
|
||||
}
|
||||
.onChange(of: self.appModel.chatAgentId) { _, _ in
|
||||
|
|
@ -64,6 +65,9 @@ struct ChatProTab: View {
|
|||
.onChange(of: self.appModel.gatewayDefaultAgentId) { _, _ in
|
||||
self.syncChatViewModel()
|
||||
}
|
||||
.onChange(of: self.appModel.chatSessionRoutingContract) { _, _ in
|
||||
self.syncChatViewModel()
|
||||
}
|
||||
.onChange(of: self.appModel.isAppleReviewDemoModeEnabled) { _, _ in
|
||||
self.syncChatViewModel()
|
||||
self.viewModel?.refresh()
|
||||
|
|
@ -133,7 +137,8 @@ struct ChatProTab: View {
|
|||
assistantAvatarTint: OpenClawBrand.accent,
|
||||
showsAssistantAvatars: false,
|
||||
composerChrome: .clean,
|
||||
isComposerEnabled: self.gatewayConnected,
|
||||
isComposerEnabled: self.gatewayConnected || self.canQueueOffline,
|
||||
isAttachmentInputEnabled: self.gatewayConnected,
|
||||
messagePlaceholder: self.messagePlaceholder,
|
||||
emptyAssistantIntro: String(localized: "What would you like to work on?"),
|
||||
emptyAssistantPrompts: Self.emptyAssistantPrompts,
|
||||
|
|
@ -166,38 +171,48 @@ struct ChatProTab: View {
|
|||
let sessionKey = self.appModel.chatSessionKey
|
||||
// Includes the cache gateway identity so switching paired gateways
|
||||
// rebuilds the view model even while the transport mode stays the same.
|
||||
let transportModeID = self.appModel.chatViewModelIdentityID
|
||||
let transportAgentID = Self.transportAgentID(self.appModel.chatAgentId)
|
||||
let agentID = self.activeAgentID
|
||||
let ownerID = self.appModel.chatViewModelOwnerID
|
||||
let deliveryAgentID = self.appModel.chatDeliveryAgentId
|
||||
let transportAgentID = Self.transportAgentID(deliveryAgentID)
|
||||
let routingContract = self.appModel.chatSessionRoutingContract ?? ""
|
||||
guard let viewModel else {
|
||||
self.viewModelTransportModeID = transportModeID
|
||||
self.viewModelOwnerID = ownerID
|
||||
self.viewModelTransportAgentID = transportAgentID
|
||||
self.viewModelAgentID = agentID
|
||||
self.viewModel = self.makeChatViewModel(sessionKey: sessionKey, activeAgentId: agentID)
|
||||
self.viewModelRoutingContract = routingContract
|
||||
self.viewModel = self.makeChatViewModel(sessionKey: sessionKey)
|
||||
return
|
||||
}
|
||||
if self.viewModelTransportModeID != transportModeID ||
|
||||
self.viewModelTransportAgentID != transportAgentID ||
|
||||
self.viewModelAgentID != agentID
|
||||
if Self.requiresViewModelRebuild(
|
||||
currentOwnerID: self.viewModelOwnerID,
|
||||
nextOwnerID: ownerID,
|
||||
currentTransportAgentID: self.viewModelTransportAgentID,
|
||||
nextTransportAgentID: transportAgentID)
|
||||
{
|
||||
self.viewModelTransportModeID = transportModeID
|
||||
self.viewModelOwnerID = ownerID
|
||||
self.viewModelTransportAgentID = transportAgentID
|
||||
self.viewModelAgentID = agentID
|
||||
self.viewModel = self.makeChatViewModel(sessionKey: sessionKey, activeAgentId: agentID)
|
||||
self.viewModelRoutingContract = routingContract
|
||||
self.viewModel = self.makeChatViewModel(sessionKey: sessionKey)
|
||||
return
|
||||
}
|
||||
if self.viewModelRoutingContract != routingContract {
|
||||
self.viewModelRoutingContract = routingContract
|
||||
viewModel.syncSessionRoutingContract(self.appModel.chatSessionRoutingContract)
|
||||
}
|
||||
guard viewModel.sessionKey != sessionKey else { return }
|
||||
viewModel.syncSession(to: sessionKey)
|
||||
}
|
||||
|
||||
private func makeChatViewModel(sessionKey: String, activeAgentId: String? = nil) -> OpenClawChatViewModel {
|
||||
private func makeChatViewModel(sessionKey: String) -> OpenClawChatViewModel {
|
||||
// One store instance backs both seams so the transcript cache and the
|
||||
// offline outbox share a single SQLite connection.
|
||||
let offlineStore = self.appModel.makeChatOfflineStore()
|
||||
return OpenClawChatViewModel(
|
||||
sessionKey: sessionKey,
|
||||
transport: self.appModel.makeChatTransport(),
|
||||
activeAgentId: activeAgentId,
|
||||
// Bind durable rows and their transport lease to the exact same
|
||||
// gateway owner even if app state switches between these calls.
|
||||
transport: self.appModel.makeChatTransport(outboxGatewayID: offlineStore?.gatewayID),
|
||||
activeAgentId: self.appModel.chatDeliveryAgentId,
|
||||
sessionRoutingContract: self.appModel.chatSessionRoutingContract,
|
||||
transcriptCache: offlineStore,
|
||||
outbox: offlineStore,
|
||||
onSessionChanged: { sessionKey in
|
||||
|
|
@ -362,7 +377,18 @@ struct ChatProTab: View {
|
|||
}
|
||||
|
||||
private var messagePlaceholder: String {
|
||||
self.gatewayConnected ? "Message \(self.agentDisplayName)..." : "Connect to a gateway"
|
||||
if self.gatewayConnected {
|
||||
return String(localized: "Message \(self.agentDisplayName)...")
|
||||
}
|
||||
if self.canQueueOffline {
|
||||
return String(localized: "Message \(self.agentDisplayName); sends when connected")
|
||||
}
|
||||
return String(localized: "Connect to a gateway")
|
||||
}
|
||||
|
||||
private var canQueueOffline: Bool {
|
||||
self.viewModel?.supportsOfflineTextOutbox == true &&
|
||||
self.appModel.hasVerifiedChatOfflineRoutingIdentity
|
||||
}
|
||||
|
||||
private var headerDisplayTitle: String {
|
||||
|
|
@ -413,6 +439,15 @@ struct ChatProTab: View {
|
|||
value?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? ""
|
||||
}
|
||||
|
||||
nonisolated static func requiresViewModelRebuild(
|
||||
currentOwnerID: String,
|
||||
nextOwnerID: String,
|
||||
currentTransportAgentID: String,
|
||||
nextTransportAgentID: String) -> Bool
|
||||
{
|
||||
currentOwnerID != nextOwnerID || currentTransportAgentID != nextTransportAgentID
|
||||
}
|
||||
|
||||
nonisolated static let emptyAssistantPrompts: [OpenClawChatView.StarterPrompt] = [
|
||||
OpenClawChatView.StarterPrompt(
|
||||
id: "summarize-status",
|
||||
|
|
|
|||
|
|
@ -210,6 +210,7 @@ final class NodeAppModel {
|
|||
}
|
||||
|
||||
private var mainSessionBaseKey: String = "main"
|
||||
private var gatewaySessionScope: String?
|
||||
private var focusedChatSessionKey: String?
|
||||
// Two-part unread guard mirroring Android: the opened key survives read
|
||||
// confirmations so later unread episodes on the same open chat re-acknowledge;
|
||||
|
|
@ -329,7 +330,7 @@ final class NodeAppModel {
|
|||
return self.isOperatorGatewayConnected ? "operator" : "offline"
|
||||
}
|
||||
|
||||
func makeChatTransport() -> any OpenClawChatTransport {
|
||||
func makeChatTransport(outboxGatewayID: String? = nil) -> any OpenClawChatTransport {
|
||||
if self.isScreenshotFixtureModeEnabled {
|
||||
return LocalFixtureChatTransport(fixture: .appScreenshots)
|
||||
}
|
||||
|
|
@ -338,7 +339,8 @@ final class NodeAppModel {
|
|||
}
|
||||
return IOSGatewayChatTransport(
|
||||
gateway: self.operatorSession,
|
||||
globalAgentId: self.chatAgentId)
|
||||
globalAgentId: self.chatDeliveryAgentId,
|
||||
outboxGatewayID: outboxGatewayID)
|
||||
}
|
||||
|
||||
/// Gateway identity the transcript cache is scoped to: the active
|
||||
|
|
@ -349,6 +351,7 @@ final class NodeAppModel {
|
|||
var chatTranscriptCacheGatewayID: String? {
|
||||
guard !self.isLocalGatewayFixtureEnabled else { return nil }
|
||||
let stableID = self.activeGatewayConnectConfig?.effectiveStableID
|
||||
?? self.connectedGatewayID
|
||||
?? GatewaySettingsStore.loadLastGatewayConnection()?.stableID
|
||||
guard let stableID, !stableID.isEmpty else { return nil }
|
||||
return stableID
|
||||
|
|
@ -362,6 +365,14 @@ final class NodeAppModel {
|
|||
"\(self.chatTransportModeID)|\(self.chatTranscriptCacheGatewayID ?? "")|\(self.chatTranscriptCacheGeneration)"
|
||||
}
|
||||
|
||||
/// Stable owner key for the long-lived chat view model. Connectivity still
|
||||
/// changes `chatViewModelIdentityID` for session-list refreshes, but must
|
||||
/// not rebuild Chat and discard an offline draft on the same gateway.
|
||||
var chatViewModelOwnerID: String {
|
||||
let modeID = self.isLocalGatewayFixtureEnabled ? self.chatTransportModeID : "gateway"
|
||||
return "\(modeID)|\(self.chatTranscriptCacheGatewayID ?? "")|\(self.chatTranscriptCacheGeneration)"
|
||||
}
|
||||
|
||||
private var chatTranscriptCacheGeneration = 0
|
||||
|
||||
/// Offline transcript cache plus durable command outbox, both scoped to
|
||||
|
|
@ -379,6 +390,28 @@ final class NodeAppModel {
|
|||
return cache
|
||||
}
|
||||
|
||||
var hasVerifiedChatOfflineRoutingIdentity: Bool {
|
||||
self.chatTranscriptCacheGatewayID != nil &&
|
||||
self.chatDeliveryAgentId != nil &&
|
||||
self.chatSessionRoutingContract != nil
|
||||
}
|
||||
|
||||
func restoreChatSessionRoutingIdentityIfNeeded() async {
|
||||
guard !self.isLocalGatewayFixtureEnabled,
|
||||
self.chatSessionRoutingContract == nil,
|
||||
let store = self.makeChatOfflineStore(),
|
||||
let identity = await store.loadSessionRoutingIdentity(),
|
||||
self.chatTranscriptCacheGatewayID == store.gatewayID,
|
||||
self.chatSessionRoutingContract == nil
|
||||
else { return }
|
||||
self.selectedAgentId = GatewaySettingsStore.loadGatewaySelectedAgentId(stableID: store.gatewayID)
|
||||
self.gatewaySessionScope = identity.scope
|
||||
self.mainSessionBaseKey = identity.mainSessionKey
|
||||
self.gatewayDefaultAgentId = identity.defaultAgentID
|
||||
self.talkMode.updateMainSessionKey(self.mainSessionKey)
|
||||
self.homeCanvasRevision &+= 1
|
||||
}
|
||||
|
||||
func loadCachedChatSessions() async -> [OpenClawChatSessionEntry] {
|
||||
guard let cache = self.makeChatOfflineStore() else { return [] }
|
||||
return await cache.loadSessions()
|
||||
|
|
@ -1006,14 +1039,23 @@ final class NodeAppModel {
|
|||
|
||||
private func refreshBrandingFromGateway(shouldApply: () -> Bool = { true }) async {
|
||||
do {
|
||||
let res = try await operatorGateway.request(method: "config.get", paramsJSON: "{}", timeoutSeconds: 8)
|
||||
guard let sourceGatewayID = self.chatTranscriptCacheGatewayID,
|
||||
let sourceRoute = await operatorGateway.currentRoute(ifGatewayID: sourceGatewayID)
|
||||
else { return }
|
||||
let res = try await operatorGateway.request(
|
||||
method: "config.get",
|
||||
paramsJSON: "{}",
|
||||
timeoutSeconds: 8,
|
||||
ifCurrentRoute: sourceRoute)
|
||||
guard let json = try JSONSerialization.jsonObject(with: res) as? [String: Any] else { return }
|
||||
guard let config = json["config"] as? [String: Any] else { return }
|
||||
let session = config["session"] as? [String: Any]
|
||||
let mainKey = SessionKey.normalizeMainKey(session?["mainKey"] as? String)
|
||||
guard shouldApply() else { return }
|
||||
let scope = (session?["scope"] as? String) ?? "per-sender"
|
||||
guard shouldApply(), self.chatTranscriptCacheGatewayID == sourceGatewayID else { return }
|
||||
await MainActor.run {
|
||||
self.mainSessionBaseKey = mainKey
|
||||
self.gatewaySessionScope = scope
|
||||
self.talkMode.updateMainSessionKey(self.mainSessionKey)
|
||||
self.homeCanvasRevision &+= 1
|
||||
}
|
||||
|
|
@ -1030,12 +1072,26 @@ final class NodeAppModel {
|
|||
|
||||
private func refreshAgentsFromGateway(shouldApply: () -> Bool = { true }) async {
|
||||
do {
|
||||
let res = try await operatorGateway.request(method: "agents.list", paramsJSON: "{}", timeoutSeconds: 8)
|
||||
guard let sourceGatewayID = self.chatTranscriptCacheGatewayID,
|
||||
let sourceStore = self.makeChatOfflineStore(),
|
||||
sourceStore.gatewayID == sourceGatewayID,
|
||||
let sourceRoute = await operatorGateway.currentRoute(ifGatewayID: sourceGatewayID)
|
||||
else { return }
|
||||
let res = try await operatorGateway.request(
|
||||
method: "agents.list",
|
||||
paramsJSON: "{}",
|
||||
timeoutSeconds: 8,
|
||||
ifCurrentRoute: sourceRoute)
|
||||
let decoded = try JSONDecoder().decode(AgentsListResult.self, from: res)
|
||||
guard shouldApply() else { return }
|
||||
let routingIdentity = OpenClawChatSessionRoutingIdentity(
|
||||
scope: decoded.scope.value as? String,
|
||||
mainSessionKey: decoded.mainkey,
|
||||
defaultAgentID: decoded.defaultid)
|
||||
guard shouldApply(), self.chatTranscriptCacheGatewayID == sourceGatewayID else { return }
|
||||
await MainActor.run {
|
||||
self.gatewayDefaultAgentId = decoded.defaultid
|
||||
self.gatewayAgents = decoded.agents
|
||||
self.gatewaySessionScope = decoded.scope.value as? String
|
||||
self.applyMainSessionKey(decoded.mainkey)
|
||||
|
||||
let selected = (self.selectedAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
|
@ -1046,6 +1102,9 @@ final class NodeAppModel {
|
|||
self.talkMode.updateMainSessionKey(self.mainSessionKey)
|
||||
self.homeCanvasRevision &+= 1
|
||||
}
|
||||
if let routingIdentity {
|
||||
await sourceStore.storeSessionRoutingIdentity(routingIdentity)
|
||||
}
|
||||
} catch {
|
||||
// Best-effort only.
|
||||
}
|
||||
|
|
@ -2344,6 +2403,26 @@ extension NodeAppModel {
|
|||
return self.selectedOrDefaultAgentId
|
||||
}
|
||||
|
||||
/// Verified routing owner for sends. Unlike `chatAgentId`, this has no
|
||||
/// display fallback: a cold offline start must wait for persisted or
|
||||
/// gateway-provided ownership before it can queue durable work.
|
||||
var chatDeliveryAgentId: String? {
|
||||
if let sessionAgentId = SessionKey.agentId(from: chatSessionKey) {
|
||||
return sessionAgentId.lowercased()
|
||||
}
|
||||
let selected = (self.selectedAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !selected.isEmpty { return selected.lowercased() }
|
||||
let defaultId = (self.gatewayDefaultAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return defaultId.isEmpty ? nil : defaultId.lowercased()
|
||||
}
|
||||
|
||||
var chatSessionRoutingContract: String? {
|
||||
OpenClawChatSessionRoutingContract.make(
|
||||
scope: self.gatewaySessionScope,
|
||||
mainKey: self.mainSessionBaseKey,
|
||||
defaultAgentID: self.gatewayDefaultAgentId)
|
||||
}
|
||||
|
||||
var chatAgentName: String {
|
||||
self.agentDisplayName(for: self.chatAgentId, fallback: "Main")
|
||||
}
|
||||
|
|
@ -2636,7 +2715,6 @@ extension NodeAppModel {
|
|||
self.gatewayConnected = false
|
||||
setOperatorConnected(false)
|
||||
self.talkMode.updateGatewayConnected(false)
|
||||
self.mainSessionBaseKey = "main"
|
||||
self.talkMode.updateMainSessionKey(self.mainSessionKey)
|
||||
ShareGatewayRelaySettings.clearConfig()
|
||||
showLocalCanvasOnDisconnect()
|
||||
|
|
@ -2684,12 +2762,17 @@ extension NodeAppModel {
|
|||
self.voiceWakeSyncTask?.cancel()
|
||||
self.voiceWakeSyncTask = nil
|
||||
LiveActivityManager.shared.endActivity(reason: "new_gateway_connect")
|
||||
self.mainSessionBaseKey = "main"
|
||||
self.gatewaySessionScope = nil
|
||||
self.gatewayDefaultAgentId = nil
|
||||
self.gatewayAgents = []
|
||||
self.selectedAgentId = GatewaySettingsStore.loadGatewaySelectedAgentId(stableID: stableID)
|
||||
self.focusedChatSessionKey = nil
|
||||
self.homeCanvasRevision &+= 1
|
||||
self.apnsLastRegisteredTokenHex = nil
|
||||
Task { [weak self] in
|
||||
await self?.restoreChatSessionRoutingIdentityIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
private func clearGatewayConnectionProblem() {
|
||||
|
|
@ -3476,7 +3559,8 @@ extension NodeAppModel {
|
|||
self.gatewayConnected = false
|
||||
self.setOperatorConnected(false)
|
||||
self.talkMode.updateGatewayConnected(false)
|
||||
self.mainSessionBaseKey = "main"
|
||||
// Retain the last verified routing contract for offline
|
||||
// capture; reconnect compares it with the live gateway before replay.
|
||||
self.talkMode.updateMainSessionKey(self.mainSessionKey)
|
||||
self.showLocalCanvasOnDisconnect()
|
||||
}
|
||||
|
|
@ -3682,6 +3766,7 @@ extension NodeAppModel {
|
|||
self.talkMode.setEnabled(false)
|
||||
self.talkMode.statusText = "Demo mode only"
|
||||
self.mainSessionBaseKey = "main"
|
||||
self.gatewaySessionScope = "per-sender"
|
||||
self.selectedAgentId = nil
|
||||
self.gatewayDefaultAgentId = "main"
|
||||
self.gatewayAgents = AppleReviewDemoMode.agents
|
||||
|
|
@ -3723,6 +3808,7 @@ extension NodeAppModel {
|
|||
self.setOperatorConnected(true)
|
||||
self.hasOperatorAdminScope = true
|
||||
self.mainSessionBaseKey = "main"
|
||||
self.gatewaySessionScope = "per-sender"
|
||||
self.selectedAgentId = nil
|
||||
self.gatewayDefaultAgentId = "main"
|
||||
self.gatewayAgents = ScreenshotFixtureMode.agents
|
||||
|
|
@ -3887,7 +3973,7 @@ extension NodeAppModel {
|
|||
|
||||
let routeGeneration = self.gatewayRouteGeneration
|
||||
guard let gatewayStableID = self.connectedGatewayID,
|
||||
let nodeRoute = await nodeGateway.currentRoute(),
|
||||
let nodeRoute = await self.nodeGateway.currentRoute(),
|
||||
shouldContinue(),
|
||||
self.isCurrentGatewayRoute(generation: routeGeneration, stableID: gatewayStableID)
|
||||
else { return }
|
||||
|
|
|
|||
|
|
@ -5,6 +5,20 @@ import UIKit
|
|||
@testable import OpenClawKit
|
||||
|
||||
@Suite(.serialized) struct GatewayConnectionControllerTests {
|
||||
@Test @MainActor func `chat owner survives reconnect while session refresh identity changes`() {
|
||||
let appModel = NodeAppModel()
|
||||
let disconnectedOwner = appModel.chatViewModelOwnerID
|
||||
let disconnectedRefreshIdentity = appModel.chatViewModelIdentityID
|
||||
|
||||
appModel._test_setOperatorConnected(true)
|
||||
#expect(appModel.chatViewModelOwnerID == disconnectedOwner)
|
||||
#expect(appModel.chatViewModelIdentityID != disconnectedRefreshIdentity)
|
||||
|
||||
appModel._test_setOperatorConnected(false)
|
||||
#expect(appModel.chatViewModelOwnerID == disconnectedOwner)
|
||||
#expect(appModel.chatViewModelIdentityID == disconnectedRefreshIdentity)
|
||||
}
|
||||
|
||||
@Test @MainActor func `resolved display name sets default when missing`() {
|
||||
let defaults = UserDefaults.standard
|
||||
let displayKey = "node.displayName"
|
||||
|
|
|
|||
|
|
@ -39,8 +39,44 @@ struct IOSGatewayChatTransportTests {
|
|||
#expect(completion.completed)
|
||||
}
|
||||
|
||||
@Test func `routing contract decodes gateway main semantics`() throws {
|
||||
let data = Data(#"{"defaultId":"Ops","mainKey":"Work","scope":"global","agents":[]}"#.utf8)
|
||||
#expect(try IOSGatewayChatTransport.decodeSessionRoutingContract(data) == "global|work|ops")
|
||||
}
|
||||
|
||||
@Test func `live routing guard permits an identity still loading`() {
|
||||
#expect(OpenClawChatSessionRoutingContract.expectedValue(
|
||||
nil,
|
||||
serverSupportsGuard: true) == nil)
|
||||
#expect(OpenClawChatSessionRoutingContract.expectedValue(
|
||||
" per-sender|main|reviewer ",
|
||||
serverSupportsGuard: true) == "per-sender|main|reviewer")
|
||||
#expect(OpenClawChatSessionRoutingContract.expectedValue(
|
||||
"per-sender|main|reviewer",
|
||||
serverSupportsGuard: false) == nil)
|
||||
}
|
||||
|
||||
@Test func `routing contract round trips a delimited legacy main key`() throws {
|
||||
let contract = try #require(OpenClawChatSessionRoutingContract.make(
|
||||
scope: "per-sender",
|
||||
mainKey: "team|primary",
|
||||
defaultAgentID: "main"))
|
||||
let components = try #require(OpenClawChatSessionRoutingContract.parse(contract))
|
||||
#expect(components.scope == "per-sender")
|
||||
#expect(components.mainKey == "team|primary")
|
||||
#expect(components.defaultAgentID == "main")
|
||||
}
|
||||
|
||||
@Test func `hello advertises guarded chat send capability`() throws {
|
||||
let data = Data(
|
||||
#"{"type":"hello-ok","protocol":4,"server":{"version":"test","connId":"test"},"features":{"methods":[],"events":[],"capabilities":["chat-send-routing-contract"]},"snapshot":{"presence":[],"health":{},"stateVersion":{"presence":0,"health":0},"uptimeMs":0},"auth":{},"policy":{}}"#
|
||||
.utf8)
|
||||
let hello = try JSONDecoder().decode(HelloOk.self, from: data)
|
||||
#expect(hello.supportsServerCapability(.chatSendRoutingContract))
|
||||
}
|
||||
|
||||
@Test func `list sessions params include global sessions but not unknown`() throws {
|
||||
let params = try self.object(from: IOSGatewayChatTransport.makeListSessionsParamsJSON(limit: 12))
|
||||
let params = try object(from: IOSGatewayChatTransport.makeListSessionsParamsJSON(limit: 12))
|
||||
#expect(params["includeGlobal"] as? Bool == true)
|
||||
#expect(params["includeUnknown"] as? Bool == false)
|
||||
#expect(params["limit"] as? Int == 12)
|
||||
|
|
@ -135,14 +171,14 @@ struct IOSGatewayChatTransportTests {
|
|||
}
|
||||
|
||||
@Test func `commands list params request text scope with args`() throws {
|
||||
let params = try self.object(from: IOSGatewayChatTransport.makeCommandsListParamsJSON())
|
||||
let params = try object(from: IOSGatewayChatTransport.makeCommandsListParamsJSON())
|
||||
#expect(params["scope"] as? String == "text")
|
||||
#expect(params["includeArgs"] as? Bool == true)
|
||||
#expect(params["agentId"] == nil)
|
||||
}
|
||||
|
||||
@Test func `commands list params include agent for agent scoped session`() throws {
|
||||
let params = try self.object(
|
||||
let params = try object(
|
||||
from: IOSGatewayChatTransport.makeCommandsListParamsJSON(sessionKey: "agent:reviewer:ios-main"))
|
||||
#expect(params["scope"] as? String == "text")
|
||||
#expect(params["includeArgs"] as? Bool == true)
|
||||
|
|
@ -150,7 +186,7 @@ struct IOSGatewayChatTransportTests {
|
|||
}
|
||||
|
||||
@Test func `commands list params use explicit agent for selected global session`() throws {
|
||||
let params = try self.object(
|
||||
let params = try object(
|
||||
from: IOSGatewayChatTransport.makeCommandsListParamsJSON(
|
||||
sessionKey: "global",
|
||||
agentId: "reviewer"))
|
||||
|
|
@ -158,7 +194,7 @@ struct IOSGatewayChatTransportTests {
|
|||
}
|
||||
|
||||
@Test func `create session params include selected global agent`() throws {
|
||||
let params = try self.object(
|
||||
let params = try object(
|
||||
from: IOSGatewayChatTransport.makeCreateSessionParamsJSON(
|
||||
key: "agent:reviewer:ios-new",
|
||||
agentId: "reviewer",
|
||||
|
|
@ -172,7 +208,7 @@ struct IOSGatewayChatTransportTests {
|
|||
}
|
||||
|
||||
@Test func `chat send params omit empty attachments and keep session fields`() throws {
|
||||
let params = try self.object(
|
||||
let params = try object(
|
||||
from: IOSGatewayChatTransport.makeChatSendParamsJSON(
|
||||
sessionKey: "agent:main",
|
||||
message: "hello",
|
||||
|
|
@ -188,16 +224,41 @@ struct IOSGatewayChatTransportTests {
|
|||
}
|
||||
|
||||
@Test func `chat send params include selected global agent`() throws {
|
||||
let params = try self.object(
|
||||
let params = try object(
|
||||
from: IOSGatewayChatTransport.makeChatSendParamsJSON(
|
||||
sessionKey: "global",
|
||||
agentId: "reviewer",
|
||||
expectedSessionRoutingContract: "per-sender|main|reviewer",
|
||||
message: "hello",
|
||||
thinking: "low",
|
||||
idempotencyKey: "send-1",
|
||||
attachments: []))
|
||||
#expect(params["sessionKey"] as? String == "global")
|
||||
#expect(params["agentId"] as? String == "reviewer")
|
||||
#expect(params["expectedSessionRoutingContract"] as? String == "per-sender|main|reviewer")
|
||||
}
|
||||
|
||||
@Test func `unscoped live routes use the selected agent`() {
|
||||
#expect(IOSGatewayChatTransport.sessionTarget(
|
||||
for: "Matrix:Channel:!MixedRoom:example.org",
|
||||
selectedAgentID: " Reviewer ") == .init(
|
||||
sessionKey: "agent:reviewer:Matrix:Channel:!MixedRoom:example.org",
|
||||
agentID: nil))
|
||||
#expect(IOSGatewayChatTransport.sessionTarget(
|
||||
for: "main",
|
||||
selectedAgentID: "main") == .init(sessionKey: "agent:main:main", agentID: nil))
|
||||
#expect(IOSGatewayChatTransport.sessionTarget(
|
||||
for: "agent:ops:main",
|
||||
selectedAgentID: "reviewer") == .init(sessionKey: "agent:ops:main", agentID: nil))
|
||||
#expect(IOSGatewayChatTransport.sessionTarget(
|
||||
for: "global",
|
||||
selectedAgentID: "reviewer") == .init(sessionKey: "global", agentID: "reviewer"))
|
||||
#expect(IOSGatewayChatTransport.sessionTarget(
|
||||
for: "unknown",
|
||||
selectedAgentID: "reviewer") == .init(sessionKey: "unknown", agentID: nil))
|
||||
#expect(IOSGatewayChatTransport.sessionTarget(
|
||||
for: "agent::main",
|
||||
selectedAgentID: "reviewer") == .init(sessionKey: "agent::main", agentID: nil))
|
||||
}
|
||||
|
||||
@Test func `requests fail fast when gateway not connected`() async {
|
||||
|
|
@ -219,6 +280,22 @@ struct IOSGatewayChatTransportTests {
|
|||
Issue.record("Expected sendMessage to throw when gateway not connected")
|
||||
} catch {}
|
||||
|
||||
do {
|
||||
_ = try await transport.sendMessage(
|
||||
sessionKey: "node-test",
|
||||
agentID: "main",
|
||||
expectedSessionRoutingContract: "per-sender|main|main",
|
||||
message: "hello",
|
||||
thinking: "low",
|
||||
idempotencyKey: "guarded-idempotency",
|
||||
attachments: [])
|
||||
Issue.record("Expected guarded sendMessage to fail before dispatch")
|
||||
} catch is OpenClawChatTransportSendError {
|
||||
// Expected: a missing route never reached chat.send.
|
||||
} catch {
|
||||
Issue.record("Expected a typed pre-dispatch failure, got \(error)")
|
||||
}
|
||||
|
||||
do {
|
||||
_ = try await transport.requestHealth(timeoutMs: 250)
|
||||
Issue.record("Expected requestHealth to throw when gateway not connected")
|
||||
|
|
|
|||
|
|
@ -344,6 +344,21 @@ private actor WatchSnapshotSendGate {
|
|||
@Test @MainActor func `chat session key defaults to main base`() {
|
||||
let appModel = NodeAppModel()
|
||||
#expect(appModel.chatSessionKey == "main")
|
||||
#expect(appModel.chatDeliveryAgentId == nil)
|
||||
}
|
||||
|
||||
@Test @MainActor func `chat delivery owner requires persisted or gateway ownership`() {
|
||||
let appModel = NodeAppModel()
|
||||
#expect(appModel.chatDeliveryAgentId == nil)
|
||||
|
||||
appModel.gatewayDefaultAgentId = " Agent-A "
|
||||
#expect(appModel.chatDeliveryAgentId == "agent-a")
|
||||
|
||||
appModel.setSelectedAgentId(" Agent-B ")
|
||||
#expect(appModel.chatDeliveryAgentId == "agent-b")
|
||||
|
||||
appModel.openChat(sessionKey: "agent:Agent-C:incident")
|
||||
#expect(appModel.chatDeliveryAgentId == "agent-c")
|
||||
}
|
||||
|
||||
@Test @MainActor func `init preserves saved talk mode preference`() {
|
||||
|
|
|
|||
|
|
@ -338,6 +338,24 @@ struct RootTabsPresentationTests {
|
|||
#expect(ChatProTab.transportAgentID(" Main ") == "main")
|
||||
}
|
||||
|
||||
@Test func `chat view model rebuilds only when its transport owner changes`() {
|
||||
#expect(!ChatProTab.requiresViewModelRebuild(
|
||||
currentOwnerID: "gateway-a",
|
||||
nextOwnerID: "gateway-a",
|
||||
currentTransportAgentID: "main",
|
||||
nextTransportAgentID: "main"))
|
||||
#expect(ChatProTab.requiresViewModelRebuild(
|
||||
currentOwnerID: "gateway-a",
|
||||
nextOwnerID: "gateway-b",
|
||||
currentTransportAgentID: "main",
|
||||
nextTransportAgentID: "main"))
|
||||
#expect(ChatProTab.requiresViewModelRebuild(
|
||||
currentOwnerID: "gateway-a",
|
||||
nextOwnerID: "gateway-a",
|
||||
currentTransportAgentID: "main",
|
||||
nextTransportAgentID: "work"))
|
||||
}
|
||||
|
||||
@Test func `agent routes can open gateway settings from header pill`() {
|
||||
let standalone = AgentProTab()
|
||||
let routed = AgentProTab(
|
||||
|
|
|
|||
|
|
@ -298,10 +298,12 @@ struct RootTabsSourceGuardTests {
|
|||
#expect(!aboutDestination.contains("SettingsDetailRow(\"Version\""))
|
||||
#expect(!aboutDestination.contains("SettingsDetailRow(\"Platform\""))
|
||||
#expect(!aboutDestination.contains("SettingsDetailRow(\"Model\""))
|
||||
#expect(diagnosticsDestination.contains("SettingsDetailRow(\"Device\", value: DeviceInfoHelper.deviceFamily())"))
|
||||
#expect(diagnosticsDestination
|
||||
.contains("SettingsDetailRow(\"Device\", value: DeviceInfoHelper.deviceFamily())"))
|
||||
#expect(diagnosticsDestination
|
||||
.contains("SettingsDetailRow(\"Platform\", value: DeviceInfoHelper.platformStringForDisplay())"))
|
||||
#expect(diagnosticsDestination.contains("SettingsDetailRow(\"Model\", value: DeviceInfoHelper.modelIdentifier())"))
|
||||
#expect(diagnosticsDestination
|
||||
.contains("SettingsDetailRow(\"Model\", value: DeviceInfoHelper.modelIdentifier())"))
|
||||
}
|
||||
|
||||
@Test func `routed headers use shared adaptive layout`() throws {
|
||||
|
|
@ -630,7 +632,8 @@ struct RootTabsSourceGuardTests {
|
|||
#expect(activitySource.contains("struct IPadActivityScreen: View"))
|
||||
#expect(activitySource.contains("self.appModel.makeChatTransport()"))
|
||||
#expect(appModelSource.contains("return IOSGatewayChatTransport("))
|
||||
#expect(appModelSource.contains("globalAgentId: self.chatAgentId"))
|
||||
#expect(appModelSource.contains("globalAgentId: self.chatDeliveryAgentId"))
|
||||
#expect(!appModelSource.contains("defaultAgentId: self.gatewayDefaultAgentId"))
|
||||
#expect(activitySource.contains("IPadSidebarScreenChrome("))
|
||||
#expect(!taskSource.contains("struct IPadActivityScreen"))
|
||||
#expect(!taskSource.contains("import OpenClawChatUI"))
|
||||
|
|
@ -1237,10 +1240,13 @@ struct RootTabsSourceGuardTests {
|
|||
let appModelSource = try String(contentsOf: Self.nodeAppModelSourceURL(), encoding: .utf8)
|
||||
let transportSource = try String(contentsOf: Self.iOSGatewayChatTransportSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(chatSource.matches(of: /self\.appModel\.makeChatTransport\(\)/).count == 2)
|
||||
#expect(chatSource.contains("self.viewModelTransportAgentID != transportAgentID"))
|
||||
#expect(chatSource.contains(
|
||||
"self.appModel.makeChatTransport(outboxGatewayID: offlineStore?.gatewayID)"))
|
||||
#expect(chatSource.contains("activeAgentId: self.appModel.chatDeliveryAgentId"))
|
||||
#expect(chatSource.contains("Self.requiresViewModelRebuild("))
|
||||
#expect(chatSource.contains("viewModel.syncSessionRoutingContract"))
|
||||
#expect(appModelSource.contains("return IOSGatewayChatTransport("))
|
||||
#expect(appModelSource.contains("globalAgentId: self.chatAgentId"))
|
||||
#expect(appModelSource.contains("globalAgentId: self.chatDeliveryAgentId"))
|
||||
#expect(appModelSource.contains("ifCurrentRoute: operatorRoute"))
|
||||
#expect(transportSource.matches(of: /ifCurrentRoute: expectedRoute/).count == 3)
|
||||
#expect(channelsSource.contains("\"clickclack\": SettingsChannelFallbackMetadata"))
|
||||
|
|
|
|||
|
|
@ -9,6 +9,16 @@ import OpenClawChatUI
|
|||
/// classes (the iOS store applies `completeUntilFirstUserAuthentication`);
|
||||
/// at-rest protection here is the per-user container permissions plus FileVault.
|
||||
enum MacChatTranscriptCache {
|
||||
struct Context {
|
||||
let store: OpenClawChatSQLiteTranscriptCache
|
||||
let routingIdentity: OpenClawChatSessionRoutingIdentity?
|
||||
}
|
||||
|
||||
/// Every chat window for one gateway must share the same outbox actor.
|
||||
/// Otherwise a newly opened window can mistake another live window's
|
||||
/// claimed send for crash residue during its first recovery pass.
|
||||
@MainActor private static var storesByGatewayID: [String: OpenClawChatSQLiteTranscriptCache] = [:]
|
||||
|
||||
/// Stable identity of the gateway this app talks to, derivable offline
|
||||
/// (the cache pre-paints before any connection is up). Keys must not
|
||||
/// collide across gateways:
|
||||
|
|
@ -73,7 +83,8 @@ enum MacChatTranscriptCache {
|
|||
/// resolved, so foreign rows can never leak into another gateway's scope.
|
||||
/// Concrete return type: the same SQLite store also backs the offline
|
||||
/// command outbox, and callers wire both protocol facets from one instance.
|
||||
static func make() -> OpenClawChatSQLiteTranscriptCache? {
|
||||
@MainActor
|
||||
static func currentGatewayID() -> String? {
|
||||
let root = OpenClawConfigFile.loadDict()
|
||||
let mode = ConnectionModeResolver.resolve(root: root).mode
|
||||
let resolution = GatewayRemoteConfig.resolveTransportResolution(root: root)
|
||||
|
|
@ -85,19 +96,45 @@ enum MacChatTranscriptCache {
|
|||
let sshRemotePort = RemotePortTunnel.resolveRemotePortOverride(
|
||||
defaultRemotePort: defaultRemotePort,
|
||||
for: sshHost) ?? defaultRemotePort
|
||||
let gatewayID = self.gatewayID(
|
||||
return self.gatewayID(
|
||||
mode: mode,
|
||||
localStateDir: OpenClawConfigFile.stateDirURL(),
|
||||
remoteTransport: resolution.transport,
|
||||
directURL: resolution.directURL,
|
||||
sshTarget: sshTarget,
|
||||
sshRemotePort: sshRemotePort)
|
||||
guard let gatewayID else { return nil }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func make() -> OpenClawChatSQLiteTranscriptCache? {
|
||||
self.makeContext()?.store
|
||||
}
|
||||
|
||||
/// Loads the small process-stable routing fact before Chat constructs its
|
||||
/// view model, so cache partitioning and offline sends never bootstrap
|
||||
/// against a nil agent.
|
||||
@MainActor
|
||||
static func makeContext() -> Context? {
|
||||
guard let gatewayID = currentGatewayID() else { return nil }
|
||||
guard let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let databaseURL = base.appendingPathComponent("OpenClaw/chat-cache.sqlite", isDirectory: false)
|
||||
return OpenClawChatSQLiteTranscriptCache(databaseURL: databaseURL, gatewayID: gatewayID)
|
||||
return Context(
|
||||
store: self.store(databaseURL: databaseURL, gatewayID: gatewayID),
|
||||
routingIdentity: OpenClawChatSQLiteTranscriptCache.loadSessionRoutingIdentity(
|
||||
databaseURL: databaseURL,
|
||||
gatewayID: gatewayID))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func store(databaseURL: URL, gatewayID: String) -> OpenClawChatSQLiteTranscriptCache {
|
||||
if let store = storesByGatewayID[gatewayID] {
|
||||
return store
|
||||
}
|
||||
let store = OpenClawChatSQLiteTranscriptCache(databaseURL: databaseURL, gatewayID: gatewayID)
|
||||
self.storesByGatewayID[gatewayID] = store
|
||||
return store
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,6 +53,22 @@ actor GatewayConnection {
|
|||
|
||||
typealias Config = (url: URL, token: String?, password: String?)
|
||||
|
||||
struct Route {
|
||||
fileprivate let generation: UInt64
|
||||
fileprivate let url: URL
|
||||
fileprivate let token: String?
|
||||
fileprivate let password: String?
|
||||
|
||||
fileprivate func matches(_ config: Config) -> Bool {
|
||||
self.url == config.url && self.token == config.token && self.password == config.password
|
||||
}
|
||||
}
|
||||
|
||||
struct SessionRoutingIdentity: Equatable {
|
||||
let defaultAgentID: String
|
||||
let contract: String
|
||||
}
|
||||
|
||||
enum Method: String {
|
||||
case agent
|
||||
case status
|
||||
|
|
@ -108,6 +124,7 @@ actor GatewayConnection {
|
|||
private var configuredURL: URL?
|
||||
private var configuredToken: String?
|
||||
private var configuredPassword: String?
|
||||
private var routeGeneration: UInt64 = 0
|
||||
|
||||
private var subscribers: [UUID: AsyncStream<GatewayPush>.Continuation] = [:]
|
||||
private var lastSnapshot: HelloOk?
|
||||
|
|
@ -166,8 +183,8 @@ actor GatewayConnection {
|
|||
timeoutMs: Double? = nil,
|
||||
retryTransportFailures: Bool = true) async throws -> Data
|
||||
{
|
||||
let cfg = try await self.configProvider()
|
||||
await self.configure(url: cfg.url, token: cfg.token, password: cfg.password)
|
||||
let cfg = try await configProvider()
|
||||
await configure(url: cfg.url, token: cfg.token, password: cfg.password)
|
||||
guard let client else {
|
||||
throw NSError(domain: "Gateway", code: 0, userInfo: [NSLocalizedDescriptionKey: "gateway not configured"])
|
||||
}
|
||||
|
|
@ -233,8 +250,8 @@ actor GatewayConnection {
|
|||
for delayMs in [150, 400, 900] {
|
||||
try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
|
||||
do {
|
||||
let cfg = try await self.configProvider()
|
||||
await self.configure(url: cfg.url, token: cfg.token, password: cfg.password)
|
||||
let cfg = try await configProvider()
|
||||
await configure(url: cfg.url, token: cfg.token, password: cfg.password)
|
||||
guard let client = self.client else {
|
||||
throw NSError(
|
||||
domain: "Gateway",
|
||||
|
|
@ -254,6 +271,31 @@ actor GatewayConnection {
|
|||
}
|
||||
}
|
||||
|
||||
/// Route-bound requests never reconfigure or retry on another endpoint.
|
||||
/// Durable outbox rows must remain owned by the gateway that created them.
|
||||
func request(
|
||||
method: String,
|
||||
params: [String: AnyCodable]?,
|
||||
timeoutMs: Double? = nil,
|
||||
ifCurrentRoute route: Route,
|
||||
distinguishPreDispatchRouteChange: Bool = false) async throws -> Data
|
||||
{
|
||||
let cfg = try await configProvider()
|
||||
guard route.generation == self.routeGeneration,
|
||||
route.matches(cfg),
|
||||
self.configuredURL == route.url,
|
||||
self.configuredToken == route.token,
|
||||
self.configuredPassword == route.password,
|
||||
let client
|
||||
else {
|
||||
if distinguishPreDispatchRouteChange {
|
||||
throw OpenClawChatTransportSendError.notDispatched
|
||||
}
|
||||
throw CancellationError()
|
||||
}
|
||||
return try await client.request(method: method, params: params, timeoutMs: timeoutMs)
|
||||
}
|
||||
|
||||
func requestRaw(
|
||||
method: Method,
|
||||
params: [String: AnyCodable]? = nil,
|
||||
|
|
@ -275,7 +317,7 @@ actor GatewayConnection {
|
|||
params: [String: AnyCodable]? = nil,
|
||||
timeoutMs: Double? = nil) async throws -> T
|
||||
{
|
||||
let data = try await self.requestRaw(method: method, params: params, timeoutMs: timeoutMs)
|
||||
let data = try await requestRaw(method: method, params: params, timeoutMs: timeoutMs)
|
||||
do {
|
||||
return try self.decoder.decode(T.self, from: data)
|
||||
} catch {
|
||||
|
|
@ -293,8 +335,57 @@ actor GatewayConnection {
|
|||
|
||||
/// Ensure the underlying socket is configured (and replaced if config changed).
|
||||
func refresh() async throws {
|
||||
let cfg = try await self.configProvider()
|
||||
await self.configure(url: cfg.url, token: cfg.token, password: cfg.password)
|
||||
let cfg = try await configProvider()
|
||||
await configure(url: cfg.url, token: cfg.token, password: cfg.password)
|
||||
}
|
||||
|
||||
func captureRoute() async -> Route? {
|
||||
do {
|
||||
let cfg = try await configProvider()
|
||||
await configure(url: cfg.url, token: cfg.token, password: cfg.password)
|
||||
guard self.client != nil else { return nil }
|
||||
return Route(
|
||||
generation: self.routeGeneration,
|
||||
url: cfg.url,
|
||||
token: cfg.token,
|
||||
password: cfg.password)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func supportsServerCapability(
|
||||
_ capability: GatewayServerCapability,
|
||||
ifCurrentRoute route: Route) async -> Bool?
|
||||
{
|
||||
guard let cfg = try? await configProvider(),
|
||||
route.generation == self.routeGeneration,
|
||||
route.matches(cfg),
|
||||
configuredURL == route.url,
|
||||
configuredToken == route.token,
|
||||
configuredPassword == route.password,
|
||||
let snapshot = lastSnapshot
|
||||
else { return nil }
|
||||
return snapshot.supportsServerCapability(capability)
|
||||
}
|
||||
|
||||
func sessionRoutingIdentity(ifCurrentRoute route: Route) async throws -> SessionRoutingIdentity {
|
||||
let data = try await request(
|
||||
method: "agents.list",
|
||||
params: [:],
|
||||
timeoutMs: 15000,
|
||||
ifCurrentRoute: route)
|
||||
return try Self.decodeSessionRoutingIdentity(data)
|
||||
}
|
||||
|
||||
static func decodeSessionRoutingIdentity(_ data: Data) throws -> SessionRoutingIdentity {
|
||||
let result = try JSONDecoder().decode(AgentsListResult.self, from: data)
|
||||
guard let contract = OpenClawChatSessionRoutingContract.make(
|
||||
scope: result.scope.value as? String,
|
||||
mainKey: result.mainkey,
|
||||
defaultAgentID: result.defaultid)
|
||||
else { throw CancellationError() }
|
||||
return SessionRoutingIdentity(defaultAgentID: result.defaultid, contract: contract)
|
||||
}
|
||||
|
||||
func authSource() async -> GatewayAuthSource? {
|
||||
|
|
@ -303,17 +394,18 @@ actor GatewayConnection {
|
|||
}
|
||||
|
||||
func shutdown() async {
|
||||
self.routeGeneration &+= 1
|
||||
if let client {
|
||||
await client.shutdown()
|
||||
}
|
||||
self.client = nil
|
||||
client = nil
|
||||
self.configuredURL = nil
|
||||
self.configuredToken = nil
|
||||
self.lastSnapshot = nil
|
||||
}
|
||||
|
||||
func canvasPluginSurfaceUrl() async -> String? {
|
||||
guard let snapshot = self.lastSnapshot else { return nil }
|
||||
guard let snapshot = lastSnapshot else { return nil }
|
||||
let raw = snapshot.pluginsurfaceurls?["canvas"]?.value as? String
|
||||
let trimmed = raw?.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
|
|
@ -325,7 +417,7 @@ actor GatewayConnection {
|
|||
{
|
||||
return token
|
||||
}
|
||||
if let deviceToken = self.lastSnapshot?.auth["deviceToken"]?.value as? String {
|
||||
if let deviceToken = lastSnapshot?.auth["deviceToken"]?.value as? String {
|
||||
let trimmed = deviceToken.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty {
|
||||
return trimmed
|
||||
|
|
@ -347,26 +439,20 @@ actor GatewayConnection {
|
|||
}
|
||||
|
||||
func cachedMainSessionKey() -> String? {
|
||||
guard let snapshot = self.lastSnapshot else { return nil }
|
||||
guard let snapshot = lastSnapshot else { return nil }
|
||||
let trimmed = self.sessionDefaultString(snapshot.snapshot.sessiondefaults, key: "mainSessionKey")
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
func cachedDefaultAgentId() -> String? {
|
||||
guard let snapshot = self.lastSnapshot else { return nil }
|
||||
let trimmed = self.sessionDefaultString(snapshot.snapshot.sessiondefaults, key: "defaultAgentId")
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
func cachedGatewayVersion() -> String? {
|
||||
guard let snapshot = self.lastSnapshot else { return nil }
|
||||
guard let snapshot = lastSnapshot else { return nil }
|
||||
let raw = snapshot.server["version"]?.value as? String
|
||||
let trimmed = raw?.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
func snapshotPaths() -> (configPath: String?, stateDir: String?) {
|
||||
guard let snapshot = self.lastSnapshot else { return (nil, nil) }
|
||||
guard let snapshot = lastSnapshot else { return (nil, nil) }
|
||||
let configPath = snapshot.snapshot.configpath?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let stateDir = snapshot.snapshot.statedir?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return (
|
||||
|
|
@ -396,7 +482,7 @@ actor GatewayConnection {
|
|||
private func broadcast(_ push: GatewayPush) {
|
||||
if case let .snapshot(snapshot) = push {
|
||||
self.lastSnapshot = snapshot
|
||||
if let mainSessionKey = self.cachedMainSessionKey() {
|
||||
if let mainSessionKey = cachedMainSessionKey() {
|
||||
Task { @MainActor in
|
||||
WorkActivityStore.shared.setMainSessionKey(mainSessionKey)
|
||||
}
|
||||
|
|
@ -410,7 +496,7 @@ actor GatewayConnection {
|
|||
private func canonicalizeSessionKey(_ raw: String) -> String {
|
||||
let trimmed = raw.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return trimmed }
|
||||
guard let defaults = self.lastSnapshot?.snapshot.sessiondefaults else { return trimmed }
|
||||
guard let defaults = lastSnapshot?.snapshot.sessiondefaults else { return trimmed }
|
||||
let mainSessionKey = self.sessionDefaultString(defaults, key: "mainSessionKey")
|
||||
guard !mainSessionKey.isEmpty else { return trimmed }
|
||||
let mainKey = self.sessionDefaultString(defaults, key: "mainKey")
|
||||
|
|
@ -426,16 +512,19 @@ actor GatewayConnection {
|
|||
}
|
||||
|
||||
private func configure(url: URL, token: String?, password: String?) async {
|
||||
if self.client != nil, self.configuredURL == url, self.configuredToken == token,
|
||||
if client != nil, self.configuredURL == url, self.configuredToken == token,
|
||||
self.configuredPassword == password
|
||||
{
|
||||
return
|
||||
}
|
||||
// Invalidate captured routes before suspension so no reentrant caller
|
||||
// can continue an old-gateway outbox flush during replacement.
|
||||
self.routeGeneration &+= 1
|
||||
if let client {
|
||||
await client.shutdown()
|
||||
}
|
||||
self.lastSnapshot = nil
|
||||
self.client = GatewayChannelActor(
|
||||
client = GatewayChannelActor(
|
||||
url: url,
|
||||
token: token,
|
||||
password: password,
|
||||
|
|
@ -483,11 +572,11 @@ extension GatewayConnection {
|
|||
}
|
||||
|
||||
func mainSessionKey(timeoutMs: Double = 15000) async -> String {
|
||||
if let cached = self.cachedMainSessionKey() {
|
||||
if let cached = cachedMainSessionKey() {
|
||||
return cached
|
||||
}
|
||||
do {
|
||||
let data = try await self.requestRaw(method: "config.get", params: nil, timeoutMs: timeoutMs)
|
||||
let data = try await requestRaw(method: "config.get", params: nil, timeoutMs: timeoutMs)
|
||||
return try Self.mainSessionKey(fromConfigGetData: data)
|
||||
} catch {
|
||||
return "main"
|
||||
|
|
@ -579,13 +668,13 @@ extension GatewayConnection {
|
|||
// MARK: - Health
|
||||
|
||||
func healthSnapshot(timeoutMs: Double? = nil) async throws -> HealthSnapshot {
|
||||
let data = try await self.requestRaw(method: .health, timeoutMs: timeoutMs)
|
||||
let data = try await requestRaw(method: .health, timeoutMs: timeoutMs)
|
||||
if let snap = decodeHealthSnapshot(from: data) { return snap }
|
||||
throw GatewayDecodingError(method: Method.health.rawValue, message: "failed to decode health snapshot")
|
||||
}
|
||||
|
||||
func healthOK(timeoutMs: Int = 8000) async throws -> Bool {
|
||||
let data = try await self.requestRaw(method: .health, timeoutMs: Double(timeoutMs))
|
||||
let data = try await requestRaw(method: .health, timeoutMs: Double(timeoutMs))
|
||||
return (try? self.decoder.decode(OpenClawGatewayHealthOK.self, from: data))?.ok ?? true
|
||||
}
|
||||
|
||||
|
|
@ -657,28 +746,42 @@ extension GatewayConnection {
|
|||
|
||||
func chatHistory(
|
||||
sessionKey: String,
|
||||
agentID: String? = nil,
|
||||
limit: Int? = nil,
|
||||
maxChars: Int? = nil,
|
||||
timeoutMs: Int? = nil) async throws -> OpenClawChatHistoryPayload
|
||||
timeoutMs: Int? = nil,
|
||||
ifCurrentRoute route: Route? = nil) async throws -> OpenClawChatHistoryPayload
|
||||
{
|
||||
let resolvedKey = self.canonicalizeSessionKey(sessionKey)
|
||||
var params: [String: AnyCodable] = ["sessionKey": AnyCodable(resolvedKey)]
|
||||
if let agentID = agentID?.trimmingCharacters(in: .whitespacesAndNewlines), !agentID.isEmpty {
|
||||
params["agentId"] = AnyCodable(agentID)
|
||||
}
|
||||
if let limit { params["limit"] = AnyCodable(limit) }
|
||||
if let maxChars { params["maxChars"] = AnyCodable(maxChars) }
|
||||
let timeout = timeoutMs.map { Double($0) }
|
||||
return try await self.requestDecoded(
|
||||
method: .chatHistory,
|
||||
params: params,
|
||||
timeoutMs: timeout)
|
||||
if let route {
|
||||
let data = try await request(
|
||||
method: Method.chatHistory.rawValue,
|
||||
params: params,
|
||||
timeoutMs: timeout,
|
||||
ifCurrentRoute: route)
|
||||
return try self.decoder.decode(OpenClawChatHistoryPayload.self, from: data)
|
||||
}
|
||||
return try await self.requestDecoded(method: .chatHistory, params: params, timeoutMs: timeout)
|
||||
}
|
||||
|
||||
func chatSend(
|
||||
sessionKey: String,
|
||||
agentID: String? = nil,
|
||||
expectedSessionRoutingContract: String? = nil,
|
||||
message: String,
|
||||
thinking: String?,
|
||||
idempotencyKey: String,
|
||||
attachments: [OpenClawChatAttachmentPayload],
|
||||
timeoutMs: Int = 30000) async throws -> OpenClawChatSendResponse
|
||||
timeoutMs: Int = 30000,
|
||||
ifCurrentRoute route: Route? = nil,
|
||||
distinguishPreDispatchRouteChange: Bool = false) async throws -> OpenClawChatSendResponse
|
||||
{
|
||||
let resolvedKey = self.canonicalizeSessionKey(sessionKey)
|
||||
var params: [String: AnyCodable] = [
|
||||
|
|
@ -687,6 +790,15 @@ extension GatewayConnection {
|
|||
"idempotencyKey": AnyCodable(idempotencyKey),
|
||||
"timeoutMs": AnyCodable(timeoutMs),
|
||||
]
|
||||
if let agentID = agentID?.trimmingCharacters(in: .whitespacesAndNewlines), !agentID.isEmpty {
|
||||
params["agentId"] = AnyCodable(agentID)
|
||||
}
|
||||
if let expectedSessionRoutingContract = expectedSessionRoutingContract?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!expectedSessionRoutingContract.isEmpty
|
||||
{
|
||||
params["expectedSessionRoutingContract"] = AnyCodable(expectedSessionRoutingContract)
|
||||
}
|
||||
if let thinking = thinking?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!thinking.isEmpty
|
||||
{
|
||||
|
|
@ -705,16 +817,22 @@ extension GatewayConnection {
|
|||
params["attachments"] = AnyCodable(encoded)
|
||||
}
|
||||
|
||||
return try await self.requestDecoded(
|
||||
method: .chatSend,
|
||||
params: params,
|
||||
timeoutMs: Double(timeoutMs))
|
||||
if let route {
|
||||
let data = try await request(
|
||||
method: Method.chatSend.rawValue,
|
||||
params: params,
|
||||
timeoutMs: Double(timeoutMs),
|
||||
ifCurrentRoute: route,
|
||||
distinguishPreDispatchRouteChange: distinguishPreDispatchRouteChange)
|
||||
return try self.decoder.decode(OpenClawChatSendResponse.self, from: data)
|
||||
}
|
||||
return try await self.requestDecoded(method: .chatSend, params: params, timeoutMs: Double(timeoutMs))
|
||||
}
|
||||
|
||||
func chatAbort(sessionKey: String, runId: String) async throws -> Bool {
|
||||
let resolvedKey = self.canonicalizeSessionKey(sessionKey)
|
||||
struct AbortResponse: Decodable { let ok: Bool?; let aborted: Bool? }
|
||||
let res: AbortResponse = try await self.requestDecoded(
|
||||
let res: AbortResponse = try await requestDecoded(
|
||||
method: .chatAbort,
|
||||
params: ["sessionKey": AnyCodable(resolvedKey), "runId": AnyCodable(runId)])
|
||||
return res.aborted ?? false
|
||||
|
|
@ -730,7 +848,7 @@ extension GatewayConnection {
|
|||
|
||||
func voiceWakeGetTriggers() async throws -> [String] {
|
||||
struct VoiceWakePayload: Decodable { let triggers: [String] }
|
||||
let payload: VoiceWakePayload = try await self.requestDecoded(method: .voicewakeGet)
|
||||
let payload: VoiceWakePayload = try await requestDecoded(method: .voicewakeGet)
|
||||
return payload.triggers
|
||||
}
|
||||
|
||||
|
|
@ -792,14 +910,14 @@ extension GatewayConnection {
|
|||
}
|
||||
|
||||
func cronList(includeDisabled: Bool = true) async throws -> [CronJob] {
|
||||
let data = try await self.requestRaw(
|
||||
let data = try await requestRaw(
|
||||
method: .cronList,
|
||||
params: ["includeDisabled": AnyCodable(includeDisabled)])
|
||||
return try Self.decodeCronListResponse(data)
|
||||
}
|
||||
|
||||
func cronRuns(jobId: String, limit: Int = 200) async throws -> [CronRunLogEntry] {
|
||||
let data = try await self.requestRaw(
|
||||
let data = try await requestRaw(
|
||||
method: .cronRuns,
|
||||
params: ["id": AnyCodable(jobId), "limit": AnyCodable(limit)])
|
||||
return try Self.decodeCronRunsResponse(data)
|
||||
|
|
|
|||
|
|
@ -18,6 +18,16 @@ private enum WebChatSwiftUILayout {
|
|||
}
|
||||
|
||||
struct MacGatewayChatTransport: OpenClawChatTransport {
|
||||
private let outboxGatewayID: String?
|
||||
|
||||
init(outboxGatewayID: String? = nil) {
|
||||
self.outboxGatewayID = outboxGatewayID
|
||||
}
|
||||
|
||||
var outboxRequiresSessionRoutingContract: Bool {
|
||||
true
|
||||
}
|
||||
|
||||
func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload {
|
||||
try await GatewayConnection.shared.chatHistory(sessionKey: sessionKey)
|
||||
}
|
||||
|
|
@ -119,6 +129,89 @@ struct MacGatewayChatTransport: OpenClawChatTransport {
|
|||
attachments: attachments)
|
||||
}
|
||||
|
||||
func sendMessage(
|
||||
sessionKey: String,
|
||||
agentID: String?,
|
||||
expectedSessionRoutingContract: String?,
|
||||
message: String,
|
||||
thinking: String,
|
||||
idempotencyKey: String,
|
||||
attachments: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse
|
||||
{
|
||||
if let outboxGatewayID {
|
||||
try await Self.requireGateway(outboxGatewayID)
|
||||
}
|
||||
guard let route = await GatewayConnection.shared.captureRoute(),
|
||||
let supportsRoutingContract = await GatewayConnection.shared.supportsServerCapability(
|
||||
.chatSendRoutingContract,
|
||||
ifCurrentRoute: route)
|
||||
else { throw OpenClawChatTransportSendError.notDispatched }
|
||||
// Outbox replay is capability-gated in acquireOutboxRouteLease. A
|
||||
// live send keeps its captured route on older gateways and omits the
|
||||
// unsupported atomic routing field.
|
||||
let guardedContract = OpenClawChatSessionRoutingContract.expectedValue(
|
||||
expectedSessionRoutingContract,
|
||||
serverSupportsGuard: supportsRoutingContract)
|
||||
return try await GatewayConnection.shared.chatSend(
|
||||
sessionKey: sessionKey,
|
||||
agentID: agentID,
|
||||
expectedSessionRoutingContract: guardedContract,
|
||||
message: message,
|
||||
thinking: thinking,
|
||||
idempotencyKey: idempotencyKey,
|
||||
attachments: attachments,
|
||||
ifCurrentRoute: route,
|
||||
distinguishPreDispatchRouteChange: true)
|
||||
}
|
||||
|
||||
func acquireOutboxRouteLease() async -> OpenClawChatTransportRouteLeaseResult {
|
||||
guard let outboxGatewayID else { return .unavailable(reason: nil) }
|
||||
let currentGatewayID = await MainActor.run { MacChatTranscriptCache.currentGatewayID() }
|
||||
guard currentGatewayID == outboxGatewayID,
|
||||
let route = await GatewayConnection.shared.captureRoute()
|
||||
else { return .unavailable(reason: nil) }
|
||||
guard let supportsRoutingContract = await GatewayConnection.shared.supportsServerCapability(
|
||||
.chatSendRoutingContract,
|
||||
ifCurrentRoute: route)
|
||||
else { return .unavailable(reason: nil) }
|
||||
guard supportsRoutingContract else {
|
||||
return .unavailable(reason: OpenClawChatTransportUpgradeMessage.routingContract)
|
||||
}
|
||||
guard let routingIdentity = try? await GatewayConnection.shared.sessionRoutingIdentity(
|
||||
ifCurrentRoute: route)
|
||||
else { return .unavailable(reason: nil) }
|
||||
let routingContract = routingIdentity.contract
|
||||
return .available(OpenClawChatTransportRouteLease(
|
||||
sendTargetedMessage: { sessionKey, agentID, message, thinking, idempotencyKey, attachments in
|
||||
try await Self.requireGateway(outboxGatewayID)
|
||||
return try await GatewayConnection.shared.chatSend(
|
||||
sessionKey: sessionKey,
|
||||
agentID: agentID,
|
||||
expectedSessionRoutingContract: routingContract,
|
||||
message: message,
|
||||
thinking: thinking,
|
||||
idempotencyKey: idempotencyKey,
|
||||
attachments: attachments,
|
||||
ifCurrentRoute: route,
|
||||
distinguishPreDispatchRouteChange: true)
|
||||
},
|
||||
requestTargetedHistory: { sessionKey, agentID in
|
||||
try await Self.requireGateway(outboxGatewayID)
|
||||
return try await GatewayConnection.shared.chatHistory(
|
||||
sessionKey: sessionKey,
|
||||
agentID: agentID,
|
||||
ifCurrentRoute: route)
|
||||
},
|
||||
sessionRoutingContract: routingContract))
|
||||
}
|
||||
|
||||
private static func requireGateway(_ gatewayID: String) async throws {
|
||||
let currentGatewayID = await MainActor.run { MacChatTranscriptCache.currentGatewayID() }
|
||||
guard currentGatewayID == gatewayID else {
|
||||
throw OpenClawChatTransportSendError.notDispatched
|
||||
}
|
||||
}
|
||||
|
||||
func requestHealth(timeoutMs: Int) async throws -> Bool {
|
||||
try await GatewayConnection.shared.healthOK(timeoutMs: timeoutMs)
|
||||
}
|
||||
|
|
@ -255,11 +348,14 @@ final class WebChatSwiftUIWindowController {
|
|||
// so binding the cache identity at construction stays correct. One
|
||||
// store instance backs both the transcript cache and the offline
|
||||
// command outbox.
|
||||
let store = MacChatTranscriptCache.make()
|
||||
let context = MacChatTranscriptCache.makeContext()
|
||||
let store = context?.store
|
||||
self.init(
|
||||
sessionKey: sessionKey,
|
||||
presentation: presentation,
|
||||
transport: MacGatewayChatTransport(),
|
||||
transport: MacGatewayChatTransport(outboxGatewayID: store?.gatewayID),
|
||||
initialActiveAgentID: context?.routingIdentity?.defaultAgentID,
|
||||
initialSessionRoutingContract: context?.routingIdentity?.contract,
|
||||
transcriptCache: store,
|
||||
outbox: store)
|
||||
}
|
||||
|
|
@ -268,6 +364,8 @@ final class WebChatSwiftUIWindowController {
|
|||
sessionKey: String,
|
||||
presentation: WebChatPresentation,
|
||||
transport: any OpenClawChatTransport,
|
||||
initialActiveAgentID: String? = nil,
|
||||
initialSessionRoutingContract: String? = nil,
|
||||
transcriptCache: (any OpenClawChatTranscriptCache)? = nil,
|
||||
outbox: (any OpenClawChatCommandOutbox)? = nil)
|
||||
{
|
||||
|
|
@ -276,6 +374,8 @@ final class WebChatSwiftUIWindowController {
|
|||
let vm = OpenClawChatViewModel(
|
||||
sessionKey: sessionKey,
|
||||
transport: transport,
|
||||
activeAgentId: initialActiveAgentID,
|
||||
sessionRoutingContract: initialSessionRoutingContract,
|
||||
transcriptCache: transcriptCache,
|
||||
outbox: outbox,
|
||||
initialThinkingLevel: Self.persistedThinkingLevel(),
|
||||
|
|
@ -287,8 +387,25 @@ final class WebChatSwiftUIWindowController {
|
|||
for await push in pushes {
|
||||
guard let vm else { return }
|
||||
guard case .snapshot = push else { continue }
|
||||
let activeAgentId = await GatewayConnection.shared.cachedDefaultAgentId()
|
||||
vm.syncActiveAgentId(activeAgentId)
|
||||
let route = await GatewayConnection.shared.captureRoute()
|
||||
let routingIdentity: GatewayConnection.SessionRoutingIdentity? = if let route {
|
||||
try? await GatewayConnection.shared.sessionRoutingIdentity(
|
||||
ifCurrentRoute: route)
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
if let routingIdentity {
|
||||
if let store = transcriptCache as? OpenClawChatSQLiteTranscriptCache,
|
||||
store.gatewayID == MacChatTranscriptCache.currentGatewayID(),
|
||||
let persistedIdentity = OpenClawChatSessionRoutingIdentity(
|
||||
contract: routingIdentity.contract)
|
||||
{
|
||||
await store.storeSessionRoutingIdentity(persistedIdentity)
|
||||
}
|
||||
vm.syncDeliveryIdentity(
|
||||
activeAgentId: routingIdentity.defaultAgentID,
|
||||
sessionRoutingContract: routingIdentity.contract)
|
||||
}
|
||||
}
|
||||
}
|
||||
let accent = Self.color(fromHex: AppStateStore.shared.seamColorHex)
|
||||
|
|
|
|||
|
|
@ -16,6 +16,16 @@ struct ChatTranscriptCacheIdentityTests {
|
|||
#expect(id == nil)
|
||||
}
|
||||
|
||||
@Test @MainActor func `windows share one outbox owner per gateway`() {
|
||||
let databaseURL = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("openclaw-cache-owner-\(UUID().uuidString).sqlite")
|
||||
let gatewayID = "gw-shared-\(UUID().uuidString)"
|
||||
let first = MacChatTranscriptCache.store(databaseURL: databaseURL, gatewayID: gatewayID)
|
||||
let second = MacChatTranscriptCache.store(databaseURL: databaseURL, gatewayID: gatewayID)
|
||||
|
||||
#expect(first === second)
|
||||
}
|
||||
|
||||
@Test func `local mode keys on state dir so profiles never collide`() {
|
||||
let defaultProfile = MacChatTranscriptCache.gatewayID(
|
||||
mode: .local,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import Foundation
|
||||
import OpenClawChatUI
|
||||
import OpenClawKit
|
||||
import os
|
||||
import Testing
|
||||
|
|
@ -58,7 +59,7 @@ struct GatewayConnectionTests {
|
|||
|
||||
@Test func `request reuses single web socket for same config`() async throws {
|
||||
let session = self.makeSession()
|
||||
let (conn, _) = try self.makeConnection(session: session)
|
||||
let (conn, _) = try makeConnection(session: session)
|
||||
|
||||
_ = try await conn.request(method: "status", params: nil)
|
||||
#expect(session.snapshotMakeCount() == 1)
|
||||
|
|
@ -70,7 +71,7 @@ struct GatewayConnectionTests {
|
|||
|
||||
@Test func `request reconfigures and cancels on token change`() async throws {
|
||||
let session = self.makeSession()
|
||||
let (conn, cfg) = try self.makeConnection(session: session, token: "a")
|
||||
let (conn, cfg) = try makeConnection(session: session, token: "a")
|
||||
|
||||
_ = try await conn.request(method: "status", params: nil)
|
||||
#expect(session.snapshotMakeCount() == 1)
|
||||
|
|
@ -81,9 +82,38 @@ struct GatewayConnectionTests {
|
|||
#expect(session.snapshotCancelCount() == 1)
|
||||
}
|
||||
|
||||
@Test func `captured route cancels instead of reconfiguring on token change`() async throws {
|
||||
let session = self.makeSession()
|
||||
let (conn, cfg) = try makeConnection(session: session, token: "a")
|
||||
|
||||
_ = try await conn.request(method: "status", params: nil)
|
||||
let route = try #require(await conn.captureRoute())
|
||||
cfg.setToken("b")
|
||||
|
||||
do {
|
||||
_ = try await conn.request(
|
||||
method: "status",
|
||||
params: nil,
|
||||
ifCurrentRoute: route)
|
||||
Issue.record("expected stale route cancellation")
|
||||
} catch is CancellationError {}
|
||||
|
||||
do {
|
||||
_ = try await conn.request(
|
||||
method: "status",
|
||||
params: nil,
|
||||
ifCurrentRoute: route,
|
||||
distinguishPreDispatchRouteChange: true)
|
||||
Issue.record("expected typed stale route rejection")
|
||||
} catch is OpenClawChatTransportSendError {}
|
||||
|
||||
#expect(session.snapshotMakeCount() == 1)
|
||||
#expect(session.snapshotCancelCount() == 0)
|
||||
}
|
||||
|
||||
@Test func `concurrent requests still use single web socket`() async throws {
|
||||
let session = self.makeSession(helloDelayMs: 150)
|
||||
let (conn, _) = try self.makeConnection(session: session)
|
||||
let (conn, _) = try makeConnection(session: session)
|
||||
|
||||
async let r1: Data = conn.request(method: "status", params: nil)
|
||||
async let r2: Data = conn.request(method: "status", params: nil)
|
||||
|
|
@ -101,7 +131,7 @@ struct GatewayConnectionTests {
|
|||
}
|
||||
})
|
||||
})
|
||||
let (conn, _) = try self.makeConnection(session: session)
|
||||
let (conn, _) = try makeConnection(session: session)
|
||||
|
||||
do {
|
||||
_ = try await conn.request(
|
||||
|
|
@ -118,7 +148,7 @@ struct GatewayConnectionTests {
|
|||
|
||||
@Test func `subscribe replays latest snapshot`() async throws {
|
||||
let session = self.makeSession()
|
||||
let (conn, _) = try self.makeConnection(session: session)
|
||||
let (conn, _) = try makeConnection(session: session)
|
||||
|
||||
_ = try await conn.request(method: "status", params: nil)
|
||||
|
||||
|
|
@ -135,7 +165,7 @@ struct GatewayConnectionTests {
|
|||
|
||||
@Test func `subscribe emits seq gap before event`() async throws {
|
||||
let session = self.makeSession()
|
||||
let (conn, _) = try self.makeConnection(session: session)
|
||||
let (conn, _) = try makeConnection(session: session)
|
||||
|
||||
let stream = await conn.subscribe(bufferingNewest: 10)
|
||||
var iterator = stream.makeAsyncIterator()
|
||||
|
|
|
|||
|
|
@ -29,11 +29,12 @@ private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable {
|
|||
{"type":"event","event":"connect.challenge","payload":{"nonce":"test-nonce"}}
|
||||
""")
|
||||
}
|
||||
if let request = self.latestUnrespondedRequest() {
|
||||
if let request = latestUnrespondedRequest() {
|
||||
self.respondedRequestIds.insert(request.id)
|
||||
if request.method == "connect" {
|
||||
return .string("""
|
||||
{"type":"res","id":"\(request.id)","ok":true,"payload":{"type":"hello","protocol":3,"server":{},"features":{},"snapshot":{"presence":[],"health":{},"stateVersion":{"presence":0,"health":0},"uptimeMs":0},"auth":{},"policy":{}}}
|
||||
{"type":"res","id":"\(request
|
||||
.id)","ok":true,"payload":{"type":"hello","protocol":3,"server":{},"features":{},"snapshot":{"presence":[],"health":{},"stateVersion":{"presence":0,"health":0},"uptimeMs":0},"auth":{},"policy":{}}}
|
||||
""")
|
||||
}
|
||||
return .string("""
|
||||
|
|
@ -181,7 +182,7 @@ private func makeTestGatewayConnection() -> (GatewayConnection, FakeWebSocketSes
|
|||
#expect(params?["voiceWakeTrigger"] as? String == "")
|
||||
}
|
||||
|
||||
@Test func `chat send omits thinking when inheriting session default`() async throws {
|
||||
@Test func `chat send carries routing precondition and omits inherited thinking`() async throws {
|
||||
let recorder = WebSocketMessageRecorder()
|
||||
let session = GatewayTestWebSocketSession(taskFactory: {
|
||||
GatewayTestWebSocketTask(sendHook: { task, message, sendIndex in
|
||||
|
|
@ -202,6 +203,7 @@ private func makeTestGatewayConnection() -> (GatewayConnection, FakeWebSocketSes
|
|||
|
||||
_ = try await connection.chatSend(
|
||||
sessionKey: "main",
|
||||
expectedSessionRoutingContract: "per-sender|main|main",
|
||||
message: "hello",
|
||||
thinking: nil,
|
||||
idempotencyKey: "chat-1",
|
||||
|
|
@ -226,6 +228,15 @@ private func makeTestGatewayConnection() -> (GatewayConnection, FakeWebSocketSes
|
|||
let json = try JSONSerialization.jsonObject(with: payloadData) as? [String: Any]
|
||||
let params = json?["params"] as? [String: Any]
|
||||
#expect(params?["thinking"] == nil)
|
||||
#expect(params?["expectedSessionRoutingContract"] as? String == "per-sender|main|main")
|
||||
}
|
||||
|
||||
@Test func `routing identity decodes agent and contract from one response`() throws {
|
||||
let data = Data(#"{"defaultId":"Work","mainKey":"Primary","scope":"global","agents":[]}"#.utf8)
|
||||
let identity = try GatewayConnection.decodeSessionRoutingIdentity(data)
|
||||
|
||||
#expect(identity.defaultAgentID == "Work")
|
||||
#expect(identity.contract == "global|primary|work")
|
||||
}
|
||||
|
||||
private static func messageData(_ message: URLSessionWebSocketTask.Message) -> Data? {
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ struct OpenClawChatComposer: View {
|
|||
let assistantAvatarTint: Color?
|
||||
let composerChrome: OpenClawChatView.ComposerChrome
|
||||
let isComposerEnabled: Bool
|
||||
let isAttachmentInputEnabled: Bool
|
||||
let messagePlaceholder: String?
|
||||
let talkControl: OpenClawChatTalkControl?
|
||||
|
||||
|
|
@ -314,7 +315,7 @@ struct OpenClawChatComposer: View {
|
|||
.accessibilityIdentifier("chat-attachment-picker")
|
||||
.buttonStyle(.plain)
|
||||
.controlSize(.small)
|
||||
.disabled(!self.isComposerEnabled)
|
||||
.disabled(!self.isAttachmentInputEnabled)
|
||||
} else {
|
||||
Button {
|
||||
self.pickFilesMac()
|
||||
|
|
@ -325,7 +326,7 @@ struct OpenClawChatComposer: View {
|
|||
.accessibilityLabel("Attachments")
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.disabled(!self.isComposerEnabled)
|
||||
.disabled(!self.isAttachmentInputEnabled)
|
||||
}
|
||||
#else
|
||||
if self.composerChrome == .clean {
|
||||
|
|
@ -337,7 +338,7 @@ struct OpenClawChatComposer: View {
|
|||
.accessibilityIdentifier("chat-attachment-picker")
|
||||
.buttonStyle(.plain)
|
||||
.controlSize(.small)
|
||||
.disabled(!self.isComposerEnabled)
|
||||
.disabled(!self.isAttachmentInputEnabled)
|
||||
.onChange(of: self.pickerItems) { _, newItems in
|
||||
Task { await self.loadPhotosPickerItems(newItems) }
|
||||
}
|
||||
|
|
@ -349,7 +350,7 @@ struct OpenClawChatComposer: View {
|
|||
.accessibilityLabel("Attachments")
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.disabled(!self.isComposerEnabled)
|
||||
.disabled(!self.isAttachmentInputEnabled)
|
||||
.onChange(of: self.pickerItems) { _, newItems in
|
||||
Task { await self.loadPhotosPickerItems(newItems) }
|
||||
}
|
||||
|
|
@ -643,7 +644,7 @@ struct OpenClawChatComposer: View {
|
|||
self.sendDraftIfEnabled()
|
||||
},
|
||||
onPasteImageAttachment: { data, fileName, mimeType in
|
||||
guard self.isComposerEnabled else { return }
|
||||
guard self.isAttachmentInputEnabled else { return }
|
||||
self.viewModel.addImageAttachment(data: data, fileName: fileName, mimeType: mimeType)
|
||||
})
|
||||
.frame(minHeight: self.textMinHeight, idealHeight: self.textMinHeight, maxHeight: self.textMaxHeight)
|
||||
|
|
@ -1008,7 +1009,9 @@ struct OpenClawChatComposer: View {
|
|||
}
|
||||
|
||||
private var canSendMessage: Bool {
|
||||
self.isComposerEnabled && self.viewModel.canSend
|
||||
self.isComposerEnabled
|
||||
&& self.viewModel.canSend
|
||||
&& (self.isAttachmentInputEnabled || self.viewModel.attachments.isEmpty)
|
||||
}
|
||||
|
||||
private var connectionStatusText: String {
|
||||
|
|
@ -1026,7 +1029,7 @@ struct OpenClawChatComposer: View {
|
|||
|
||||
#if os(macOS)
|
||||
private func pickFilesMac() {
|
||||
guard self.isComposerEnabled else { return }
|
||||
guard self.isAttachmentInputEnabled else { return }
|
||||
let panel = NSOpenPanel()
|
||||
panel.title = "Select image attachments"
|
||||
panel.allowsMultipleSelection = true
|
||||
|
|
@ -1039,7 +1042,7 @@ struct OpenClawChatComposer: View {
|
|||
}
|
||||
|
||||
private func handleDrop(_ providers: [NSItemProvider]) -> Bool {
|
||||
guard self.isComposerEnabled else { return false }
|
||||
guard self.isAttachmentInputEnabled else { return false }
|
||||
let fileProviders = providers.filter { $0.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) }
|
||||
guard !fileProviders.isEmpty else { return false }
|
||||
for item in fileProviders {
|
||||
|
|
@ -1056,7 +1059,7 @@ struct OpenClawChatComposer: View {
|
|||
}
|
||||
#else
|
||||
private func loadPhotosPickerItems(_ items: [PhotosPickerItem]) async {
|
||||
guard self.isComposerEnabled else {
|
||||
guard self.isAttachmentInputEnabled else {
|
||||
self.pickerItems = []
|
||||
return
|
||||
}
|
||||
|
|
@ -1077,7 +1080,7 @@ struct OpenClawChatComposer: View {
|
|||
#endif
|
||||
|
||||
private func sendDraftIfEnabled() {
|
||||
guard self.isComposerEnabled else { return }
|
||||
guard self.canSendMessage else { return }
|
||||
self.viewModel.send()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -641,6 +641,10 @@ struct ChatOutboxStatusLabel: View {
|
|||
"Queued"
|
||||
case .sending:
|
||||
"Sending…"
|
||||
case .confirming:
|
||||
"Confirming…"
|
||||
case let .failed(reason) where reason == OpenClawChatSQLiteTranscriptCache.outboxUnconfirmedError:
|
||||
"Delivery unknown"
|
||||
case .failed:
|
||||
"Not sent"
|
||||
}
|
||||
|
|
@ -652,6 +656,10 @@ struct ChatOutboxStatusLabel: View {
|
|||
"clock"
|
||||
case .sending:
|
||||
"arrow.up.circle"
|
||||
case .confirming:
|
||||
"checkmark.circle"
|
||||
case let .failed(reason) where reason == OpenClawChatSQLiteTranscriptCache.outboxUnconfirmedError:
|
||||
"questionmark.circle"
|
||||
case .failed:
|
||||
"exclamationmark.circle"
|
||||
}
|
||||
|
|
@ -663,6 +671,10 @@ struct ChatOutboxStatusLabel: View {
|
|||
"Queued, sends when reconnected"
|
||||
case .sending:
|
||||
"Sending"
|
||||
case .confirming:
|
||||
"Sent, waiting for chat history confirmation"
|
||||
case let .failed(reason) where reason == OpenClawChatSQLiteTranscriptCache.outboxUnconfirmedError:
|
||||
"Delivery unconfirmed, touch and hold to retry or delete"
|
||||
case .failed:
|
||||
"Not sent, touch and hold to retry or delete"
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -9,6 +9,97 @@ public enum OpenClawChatTransportEvent: Sendable {
|
|||
case seqGap
|
||||
}
|
||||
|
||||
/// One immutable transport route used by an entire outbox flush. Route-aware
|
||||
/// transports bind both sends and confirmation reads to the same connection;
|
||||
/// a gateway switch then cancels the old work instead of retargeting it.
|
||||
public struct OpenClawChatTransportRouteLease: Sendable {
|
||||
public typealias SendMessage = @Sendable (
|
||||
_ sessionKey: String,
|
||||
_ message: String,
|
||||
_ thinking: String,
|
||||
_ idempotencyKey: String,
|
||||
_ attachments: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse
|
||||
public typealias RequestHistory = @Sendable (String) async throws -> OpenClawChatHistoryPayload
|
||||
public typealias SendTargetedMessage = @Sendable (
|
||||
_ sessionKey: String,
|
||||
_ agentID: String?,
|
||||
_ message: String,
|
||||
_ thinking: String,
|
||||
_ idempotencyKey: String,
|
||||
_ attachments: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse
|
||||
public typealias RequestTargetedHistory = @Sendable (
|
||||
_ sessionKey: String,
|
||||
_ agentID: String?) async throws -> OpenClawChatHistoryPayload
|
||||
|
||||
private let sendTargetedMessageImpl: SendTargetedMessage
|
||||
private let requestTargetedHistoryImpl: RequestTargetedHistory
|
||||
public let sessionRoutingContract: String?
|
||||
|
||||
public init(
|
||||
sendMessage: @escaping SendMessage,
|
||||
requestHistory: @escaping RequestHistory,
|
||||
sessionRoutingContract: String? = nil)
|
||||
{
|
||||
self.sessionRoutingContract = sessionRoutingContract
|
||||
self.sendTargetedMessageImpl = { sessionKey, _, message, thinking, idempotencyKey, attachments in
|
||||
try await sendMessage(sessionKey, message, thinking, idempotencyKey, attachments)
|
||||
}
|
||||
self.requestTargetedHistoryImpl = { sessionKey, _ in
|
||||
try await requestHistory(sessionKey)
|
||||
}
|
||||
}
|
||||
|
||||
public init(
|
||||
sendTargetedMessage: @escaping SendTargetedMessage,
|
||||
requestTargetedHistory: @escaping RequestTargetedHistory,
|
||||
sessionRoutingContract: String? = nil)
|
||||
{
|
||||
self.sessionRoutingContract = sessionRoutingContract
|
||||
self.sendTargetedMessageImpl = sendTargetedMessage
|
||||
self.requestTargetedHistoryImpl = requestTargetedHistory
|
||||
}
|
||||
|
||||
public func sendMessage(
|
||||
sessionKey: String,
|
||||
agentID: String? = nil,
|
||||
message: String,
|
||||
thinking: String,
|
||||
idempotencyKey: String,
|
||||
attachments: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse
|
||||
{
|
||||
try await self.sendTargetedMessageImpl(
|
||||
sessionKey,
|
||||
agentID,
|
||||
message,
|
||||
thinking,
|
||||
idempotencyKey,
|
||||
attachments)
|
||||
}
|
||||
|
||||
public func requestHistory(
|
||||
sessionKey: String,
|
||||
agentID: String? = nil) async throws -> OpenClawChatHistoryPayload
|
||||
{
|
||||
try await self.requestTargetedHistoryImpl(sessionKey, agentID)
|
||||
}
|
||||
}
|
||||
|
||||
public enum OpenClawChatTransportRouteLeaseResult: Sendable {
|
||||
case available(OpenClawChatTransportRouteLease)
|
||||
case unavailable(reason: String?)
|
||||
}
|
||||
|
||||
/// The transport rejected a send before it reached its request channel. This
|
||||
/// is the only failure class safe for automatic outbox retry.
|
||||
public enum OpenClawChatTransportSendError: Error, Sendable {
|
||||
case notDispatched
|
||||
}
|
||||
|
||||
public enum OpenClawChatTransportUpgradeMessage {
|
||||
public static let routingContract =
|
||||
"Update the gateway before sending queued messages. This version requires safe delivery routing."
|
||||
}
|
||||
|
||||
public protocol OpenClawChatTransport: Sendable {
|
||||
func createSession(
|
||||
key: String,
|
||||
|
|
@ -26,6 +117,19 @@ public protocol OpenClawChatTransport: Sendable {
|
|||
thinking: String,
|
||||
idempotencyKey: String,
|
||||
attachments: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse
|
||||
func sendMessage(
|
||||
sessionKey: String,
|
||||
agentID: String?,
|
||||
expectedSessionRoutingContract: String?,
|
||||
message: String,
|
||||
thinking: String,
|
||||
idempotencyKey: String,
|
||||
attachments: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse
|
||||
|
||||
/// Captures the current route for a durable outbox flush. Implementations
|
||||
/// backed by a mutable gateway must override this with route-checked calls.
|
||||
func acquireOutboxRouteLease() async -> OpenClawChatTransportRouteLeaseResult
|
||||
var outboxRequiresSessionRoutingContract: Bool { get }
|
||||
|
||||
func abortRun(sessionKey: String, runId: String) async throws
|
||||
func listSessions(limit: Int?) async throws -> OpenClawChatSessionsListResponse
|
||||
|
|
@ -52,6 +156,43 @@ public protocol OpenClawChatTransport: Sendable {
|
|||
}
|
||||
|
||||
extension OpenClawChatTransport {
|
||||
public var outboxRequiresSessionRoutingContract: Bool {
|
||||
false
|
||||
}
|
||||
|
||||
public func acquireOutboxRouteLease() async -> OpenClawChatTransportRouteLeaseResult {
|
||||
let transport = self
|
||||
return .available(OpenClawChatTransportRouteLease(
|
||||
sendMessage: { sessionKey, message, thinking, idempotencyKey, attachments in
|
||||
try await transport.sendMessage(
|
||||
sessionKey: sessionKey,
|
||||
message: message,
|
||||
thinking: thinking,
|
||||
idempotencyKey: idempotencyKey,
|
||||
attachments: attachments)
|
||||
},
|
||||
requestHistory: { sessionKey in
|
||||
try await transport.requestHistory(sessionKey: sessionKey)
|
||||
}))
|
||||
}
|
||||
|
||||
public func sendMessage(
|
||||
sessionKey: String,
|
||||
agentID _: String?,
|
||||
expectedSessionRoutingContract _: String?,
|
||||
message: String,
|
||||
thinking: String,
|
||||
idempotencyKey: String,
|
||||
attachments: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse
|
||||
{
|
||||
try await self.sendMessage(
|
||||
sessionKey: sessionKey,
|
||||
message: message,
|
||||
thinking: thinking,
|
||||
idempotencyKey: idempotencyKey,
|
||||
attachments: attachments)
|
||||
}
|
||||
|
||||
public func createSession(
|
||||
key _: String,
|
||||
label _: String?,
|
||||
|
|
@ -165,3 +306,55 @@ extension OpenClawChatTransport {
|
|||
userInfo: [NSLocalizedDescriptionKey: "sessions.patch(thinkingLevel) not supported by this transport"])
|
||||
}
|
||||
}
|
||||
|
||||
public enum OpenClawChatSessionRoutingContract {
|
||||
public static let changedErrorReason = "session-routing-changed"
|
||||
|
||||
public struct Components: Equatable, Sendable {
|
||||
public let scope: String
|
||||
public let mainKey: String
|
||||
public let defaultAgentID: String
|
||||
}
|
||||
|
||||
/// Live sends may proceed before routing identity is available. Queued
|
||||
/// replay acquires a separate route lease and never uses a nil contract.
|
||||
public static func expectedValue(
|
||||
_ contract: String?,
|
||||
serverSupportsGuard: Bool) -> String?
|
||||
{
|
||||
guard serverSupportsGuard else { return nil }
|
||||
return self.normalize(contract)
|
||||
}
|
||||
|
||||
public static func make(
|
||||
scope: String?,
|
||||
mainKey: String?,
|
||||
defaultAgentID: String?) -> String?
|
||||
{
|
||||
let normalizedScope = self.normalize(scope)
|
||||
let normalizedMainKey = self.normalize(mainKey)
|
||||
let normalizedDefaultAgentID = self.normalize(defaultAgentID)
|
||||
guard let normalizedScope, let normalizedMainKey, let normalizedDefaultAgentID else { return nil }
|
||||
return "\(normalizedScope)|\(normalizedMainKey)|\(normalizedDefaultAgentID)"
|
||||
}
|
||||
|
||||
/// Scope and agent ids cannot contain `|`; parse from both ends so an
|
||||
/// older custom main key containing the delimiter still round-trips.
|
||||
public static func parse(_ contract: String?) -> Components? {
|
||||
guard let normalized = self.normalize(contract),
|
||||
let firstSeparator = normalized.firstIndex(of: "|"),
|
||||
let lastSeparator = normalized.lastIndex(of: "|"),
|
||||
firstSeparator != lastSeparator
|
||||
else { return nil }
|
||||
let scope = String(normalized[..<firstSeparator])
|
||||
let mainKey = String(normalized[normalized.index(after: firstSeparator)..<lastSeparator])
|
||||
let defaultAgentID = String(normalized[normalized.index(after: lastSeparator)...])
|
||||
guard !scope.isEmpty, !mainKey.isEmpty, !defaultAgentID.isEmpty else { return nil }
|
||||
return Components(scope: scope, mainKey: mainKey, defaultAgentID: defaultAgentID)
|
||||
}
|
||||
|
||||
private static func normalize(_ value: String?) -> String? {
|
||||
let normalized = value?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
return normalized?.isEmpty == false ? normalized : nil
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@ public struct OpenClawChatView: View {
|
|||
private let showsAssistantAvatars: Bool
|
||||
private let composerChrome: ComposerChrome
|
||||
private let isComposerEnabled: Bool
|
||||
private let isAttachmentInputEnabled: Bool
|
||||
private let messagePlaceholder: String?
|
||||
private let emptyAssistantIntro: String?
|
||||
private let emptyAssistantPrompts: [StarterPrompt]
|
||||
|
|
@ -143,6 +144,7 @@ public struct OpenClawChatView: View {
|
|||
showsAssistantAvatars: Bool = true,
|
||||
composerChrome: ComposerChrome = .full,
|
||||
isComposerEnabled: Bool = true,
|
||||
isAttachmentInputEnabled: Bool? = nil,
|
||||
messagePlaceholder: String? = nil,
|
||||
emptyAssistantIntro: String? = nil,
|
||||
emptyAssistantPrompts: [StarterPrompt] = [],
|
||||
|
|
@ -161,6 +163,7 @@ public struct OpenClawChatView: View {
|
|||
self.showsAssistantAvatars = showsAssistantAvatars
|
||||
self.composerChrome = composerChrome
|
||||
self.isComposerEnabled = isComposerEnabled
|
||||
self.isAttachmentInputEnabled = isAttachmentInputEnabled ?? isComposerEnabled
|
||||
self.messagePlaceholder = messagePlaceholder
|
||||
self.emptyAssistantIntro = emptyAssistantIntro
|
||||
self.emptyAssistantPrompts = emptyAssistantPrompts
|
||||
|
|
@ -223,6 +226,7 @@ public struct OpenClawChatView: View {
|
|||
assistantAvatarTint: self.assistantAvatarTint,
|
||||
composerChrome: self.composerChrome,
|
||||
isComposerEnabled: self.isComposerEnabled,
|
||||
isAttachmentInputEnabled: self.isAttachmentInputEnabled,
|
||||
messagePlaceholder: self.messagePlaceholder,
|
||||
talkControl: self.talkControl)
|
||||
}
|
||||
|
|
@ -420,10 +424,9 @@ public struct OpenClawChatView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
// No Delete while `.sending`: the transport call is already
|
||||
// in flight and cannot be prevented, so removing the bubble
|
||||
// would hide a message that may still reach the gateway.
|
||||
if outboxState != .sending {
|
||||
// Sending and acknowledged-but-unconfirmed rows may still
|
||||
// reach canonical history, so deletion would hide real work.
|
||||
if !outboxState.preventsDeletion {
|
||||
Button(role: .destructive) {
|
||||
self.viewModel.deleteOutboxMessage(msg.id)
|
||||
} label: {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import Foundation
|
||||
import OpenClawKit
|
||||
import OSLog
|
||||
|
||||
private let outboxLogger = Logger(subsystem: "ai.openclaw", category: "OpenClawChatOutbox")
|
||||
|
|
@ -7,20 +8,30 @@ private let outboxLogger = Logger(subsystem: "ai.openclaw", category: "OpenClawC
|
|||
public enum OpenClawChatOutboxMessageState: Equatable, Sendable {
|
||||
case queued
|
||||
case sending
|
||||
case confirming
|
||||
case failed(reason: String?)
|
||||
|
||||
public var isFailed: Bool {
|
||||
if case .failed = self { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
var preventsDeletion: Bool {
|
||||
self == .sending || self == .confirming
|
||||
}
|
||||
}
|
||||
|
||||
// Durable offline command outbox. Sends made while the gateway is unhealthy
|
||||
// are persisted (per gateway, alongside the transcript cache) and flushed
|
||||
// strictly in createdAt order when health recovers. Each command's client
|
||||
// UUID rides as the transport idempotency key, so at-least-once flushing plus
|
||||
// gateway dedupe keeps the transcript exact.
|
||||
/// Durable offline command outbox. Sends made while the gateway is unhealthy
|
||||
/// are persisted (per gateway, alongside the transcript cache) and flushed
|
||||
/// strictly in createdAt order when health recovers. A gateway ACK only moves
|
||||
/// a row to awaiting-confirmation; canonical history owns durable completion.
|
||||
extension OpenClawChatViewModel {
|
||||
struct OutboxDeliveryTarget: Hashable {
|
||||
let presentationSessionKey: String
|
||||
let deliverySessionKey: String
|
||||
let agentID: String?
|
||||
}
|
||||
|
||||
public func outboxState(for messageID: UUID) -> OpenClawChatOutboxMessageState? {
|
||||
self.outboxStatesByMessageID[messageID]
|
||||
}
|
||||
|
|
@ -29,31 +40,89 @@ extension OpenClawChatViewModel {
|
|||
/// (so even an expired row can send again), and flush if healthy.
|
||||
public func retryOutboxMessage(_ messageID: UUID) {
|
||||
guard let outbox, let commandID = self.outboxCommandIDsByMessageID[messageID] else { return }
|
||||
self.outboxStatesByMessageID[messageID] = .queued
|
||||
let session = self.currentSessionSnapshot()
|
||||
Task { [weak self] in
|
||||
await outbox.markCommandRetried(id: commandID)
|
||||
self?.flushOutboxIfNeeded()
|
||||
guard let self else { return }
|
||||
let agentID = self.outboxAgentID(for: session)
|
||||
if self.outboxRequiresAgentID(for: session), agentID == nil {
|
||||
self.errorText = "Select an agent before retrying this message."
|
||||
return
|
||||
}
|
||||
guard
|
||||
let deliverySessionKey = self.outboxDeliverySessionKey(for: session, agentID: agentID),
|
||||
let routingContract = self.outboxRoutingContract(for: session)
|
||||
else {
|
||||
self.errorText = "Reconnect to verify this message's delivery target before retrying."
|
||||
return
|
||||
}
|
||||
let result = await outbox.markCommandRetriedIfPresent(
|
||||
id: commandID,
|
||||
agentID: agentID,
|
||||
deliverySessionKey: deliverySessionKey,
|
||||
routingContract: routingContract)
|
||||
if result == .updated {
|
||||
// Durable work is gateway-global. Flush even when the visible
|
||||
// session changed while the SQLite update was suspended.
|
||||
self.flushOutboxIfNeeded()
|
||||
}
|
||||
guard self.isCurrentSession(session) else { return }
|
||||
switch result {
|
||||
case .updated:
|
||||
self.outboxStatesByMessageID[messageID] = .queued
|
||||
case .missing, .confirmed:
|
||||
self.clearOutboxState(forCommandID: commandID)
|
||||
case .unavailable:
|
||||
self.errorText = "Could not retry the queued message. Try again."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func deleteOutboxMessage(_ messageID: UUID) {
|
||||
guard let outbox, let commandID = self.outboxCommandIDsByMessageID[messageID] else { return }
|
||||
// Tombstone first, synchronously: an active flush checks this set
|
||||
// right before its transport call, so the deleted command cannot be
|
||||
// sent even if the row deletion below races the flush's claim.
|
||||
self.deletedOutboxCommandIDs.insert(commandID)
|
||||
self.cancelingOutboxCommandIDs.insert(commandID)
|
||||
self.outboxPresentationGeneration &+= 1
|
||||
Task { [weak self] in
|
||||
// Durable delete before the bubble disappears: if the process
|
||||
// dies in this window, both the row and the visible bubble
|
||||
// survive, so a user-deleted command can never silently
|
||||
// resurrect and send on the next launch.
|
||||
await outbox.deleteCommand(id: commandID)
|
||||
guard let self else { return }
|
||||
// Row is durably gone; the tombstone has done its job.
|
||||
self.deletedOutboxCommandIDs.remove(commandID)
|
||||
let result = await outbox.cancelCommand(id: commandID)
|
||||
guard result != .unavailable else {
|
||||
self.finishOutboxCancellation(commandID)
|
||||
self.errorText = "Could not delete the queued message. Try again."
|
||||
return
|
||||
}
|
||||
if result == .confirmed {
|
||||
self.finishOutboxCancellation(commandID)
|
||||
self.clearOutboxState(forCommandID: commandID)
|
||||
return
|
||||
}
|
||||
if result == .missing {
|
||||
let presentationGeneration = self.outboxPresentationGeneration
|
||||
let current = await outbox.loadCommands().first(where: { $0.id == commandID })
|
||||
guard presentationGeneration == self.outboxPresentationGeneration else {
|
||||
self.finishOutboxCancellation(commandID)
|
||||
return
|
||||
}
|
||||
if let current {
|
||||
// Another view model claimed the row first. Keep the bubble
|
||||
// and show its authoritative state; cancellation after claim
|
||||
// would promise deletion while the request can still land.
|
||||
self.finishOutboxCancellation(commandID)
|
||||
self.presentOutboxCommands([current])
|
||||
return
|
||||
}
|
||||
}
|
||||
if self.canonicalOutboxMessageKeys.contains("\(commandID):user") {
|
||||
self.finishOutboxCancellation(commandID)
|
||||
self.clearOutboxState(forCommandID: commandID)
|
||||
return
|
||||
}
|
||||
self.finishOutboxCancellation(commandID)
|
||||
self.outboxCommandIDsByMessageID.removeValue(forKey: messageID)
|
||||
self.outboxMessageIDsByCommandID.removeValue(forKey: commandID)
|
||||
self.outboxStatesByMessageID.removeValue(forKey: messageID)
|
||||
// `.missing` with no current row means another view already
|
||||
// canceled (or canonical history completed) this mapping. Never
|
||||
// leave its stale bubble looking like an ordinary sent message;
|
||||
// canonical history can re-add a genuinely delivered row.
|
||||
self.replaceMessages(self.messages.filter { $0.id != messageID })
|
||||
}
|
||||
}
|
||||
|
|
@ -63,11 +132,30 @@ extension OpenClawChatViewModel {
|
|||
/// Offline capture path used by performSend when the gateway is
|
||||
/// unhealthy: persist first, then render the queued bubble. A full queue
|
||||
/// refuses the enqueue and keeps the draft so no text is lost.
|
||||
func enqueueOutboxCommand(text: String, session: SessionSnapshot) async {
|
||||
func enqueueOutboxCommand(
|
||||
text: String,
|
||||
draftInput: String,
|
||||
session: SessionSnapshot) async
|
||||
{
|
||||
guard let outbox else { return }
|
||||
let agentID = self.outboxAgentID(for: session)
|
||||
if self.outboxRequiresAgentID(for: session), agentID == nil {
|
||||
self.errorText = "Select an agent before queueing this message."
|
||||
return
|
||||
}
|
||||
guard
|
||||
let deliverySessionKey = self.outboxDeliverySessionKey(for: session, agentID: agentID),
|
||||
let routingContract = self.outboxRoutingContract(for: session)
|
||||
else {
|
||||
self.errorText = "Reconnect to verify this message's delivery target before queueing."
|
||||
return
|
||||
}
|
||||
let command = OpenClawChatOutboxCommand(
|
||||
id: UUID().uuidString,
|
||||
sessionKey: session.key,
|
||||
deliverySessionKey: deliverySessionKey,
|
||||
routingContract: routingContract,
|
||||
agentID: agentID,
|
||||
text: text,
|
||||
thinking: self.effectiveThinkingLevelForSend,
|
||||
createdAt: Date().timeIntervalSince1970,
|
||||
|
|
@ -80,7 +168,7 @@ extension OpenClawChatViewModel {
|
|||
self.errorText = "Offline queue is full. Delete a queued message or reconnect to send."
|
||||
return
|
||||
}
|
||||
self.input = ""
|
||||
if self.input == draftInput { self.input = "" }
|
||||
self.errorText = nil
|
||||
self.presentOutboxCommands([command])
|
||||
// Health can recover between the send-gate check and the enqueue;
|
||||
|
|
@ -90,34 +178,44 @@ extension OpenClawChatViewModel {
|
|||
}
|
||||
}
|
||||
|
||||
/// Requeue path for a live text send that failed at the transport while
|
||||
/// `healthOK` was stale-true. Reuses the send's runId as the command ID so
|
||||
/// the optimistic bubble's "\(runId):user" key and the gateway's dedupe
|
||||
/// identity are preserved even if the failed send actually landed.
|
||||
/// Durable path for a failed live text send. Known pre-dispatch route
|
||||
/// changes remain queued; ambiguous results fail closed until canonical
|
||||
/// history or explicit retry resolves them.
|
||||
/// Returns false when the queue refuses (caller keeps the failure path).
|
||||
func requeueFailedLiveSend(
|
||||
func preserveFailedLiveSend(
|
||||
runId: String,
|
||||
text: String,
|
||||
thinking: String,
|
||||
messageID: UUID,
|
||||
session: SessionSnapshot) async -> Bool
|
||||
session: SessionSnapshot,
|
||||
deliveryIsAmbiguous: Bool) async -> Bool
|
||||
{
|
||||
guard let outbox else { return false }
|
||||
let agentID = self.outboxAgentID(for: session)
|
||||
guard !self.outboxRequiresAgentID(for: session) || agentID != nil,
|
||||
let deliverySessionKey = self.outboxDeliverySessionKey(for: session, agentID: agentID),
|
||||
let routingContract = self.outboxRoutingContract(for: session)
|
||||
else { return false }
|
||||
let command = OpenClawChatOutboxCommand(
|
||||
id: runId,
|
||||
sessionKey: session.key,
|
||||
deliverySessionKey: deliverySessionKey,
|
||||
routingContract: routingContract,
|
||||
agentID: agentID,
|
||||
text: text,
|
||||
thinking: thinking,
|
||||
createdAt: Date().timeIntervalSince1970,
|
||||
status: .queued,
|
||||
status: deliveryIsAmbiguous ? .failed : .queued,
|
||||
retryCount: 0,
|
||||
lastError: nil)
|
||||
lastError: deliveryIsAmbiguous
|
||||
? OpenClawChatSQLiteTranscriptCache.outboxUnconfirmedError
|
||||
: nil)
|
||||
guard await outbox.enqueueCommand(command) else { return false }
|
||||
guard self.isCurrentSession(session) else { return true }
|
||||
self.mapOutboxCommand(command, to: messageID)
|
||||
self.errorText = nil
|
||||
// Auto-retry immediately while health still reads healthy; either the
|
||||
// transport recovered or the command lands visibly in 'failed'.
|
||||
// Ambiguous rows only reconcile history; known-unsent rows may flush
|
||||
// automatically once the replacement route becomes healthy.
|
||||
self.flushOutboxIfNeeded()
|
||||
return true
|
||||
}
|
||||
|
|
@ -142,20 +240,98 @@ extension OpenClawChatViewModel {
|
|||
guard let outbox else { return }
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.recoverInterruptedOutboxSendsIfNeeded()
|
||||
let commands = await outbox.loadCommands()
|
||||
guard self.isCurrentSession(session) else { return }
|
||||
self.presentOutboxCommands(commands.filter { $0.sessionKey == session.key })
|
||||
// The FIFO send gate assumes a backlog until this point.
|
||||
self.hasRestoredOutboxMessages = true
|
||||
// Relaunching while already healthy never sees an unhealthy ->
|
||||
// healthy transition, so kick the flush here as well.
|
||||
if self.healthOK, commands.contains(where: { $0.status == .queued }) {
|
||||
self.flushOutboxIfNeeded()
|
||||
guard await self.recoverInterruptedOutboxSendsIfNeeded() else { return }
|
||||
while self.isCurrentSession(session) {
|
||||
let presentationGeneration = self.outboxPresentationGeneration
|
||||
guard let commands = await outbox.loadCommandsIfAvailable() else { return }
|
||||
guard self.isCurrentSession(session) else { return }
|
||||
if presentationGeneration != self.outboxPresentationGeneration {
|
||||
// A cross-view cancellation/confirmation invalidated this
|
||||
// snapshot. Reload so unrelated surviving rows still paint.
|
||||
continue
|
||||
}
|
||||
self.presentOutboxCommands(commands.filter { self.commandMatchesTarget($0, session: session) })
|
||||
// The FIFO send gate assumes a backlog until this point.
|
||||
self.hasRestoredOutboxMessages = true
|
||||
// Relaunching while already healthy never sees an unhealthy ->
|
||||
// healthy transition, so kick the flush here as well.
|
||||
if self.healthOK, commands.contains(where: { $0.status == .queued }) {
|
||||
self.flushOutboxIfNeeded()
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical history is the durable acceptance boundary. Any matching
|
||||
/// outbox row—including a delivery-unconfirmed row—is now safe to remove
|
||||
/// without replaying the user turn.
|
||||
func confirmOutboxCommands(in messages: [OpenClawChatMessage]) {
|
||||
self.observeCanonicalOutboxMessageKeys(in: messages)
|
||||
Task { [weak self] in
|
||||
await self?.confirmOutboxCommandsNow(in: messages)
|
||||
}
|
||||
}
|
||||
|
||||
func confirmOutboxCommandsNow(in messages: [OpenClawChatMessage]) async {
|
||||
self.observeCanonicalOutboxMessageKeys(in: messages)
|
||||
guard let outbox else { return }
|
||||
let confirmedKeys = Set(messages.compactMap { Self.normalizedIdempotencyKey($0.idempotencyKey) })
|
||||
guard !confirmedKeys.isEmpty else { return }
|
||||
let commands = await outbox.loadCommands().filter { command in
|
||||
// Command UUIDs are gateway-global. Match the durable identity,
|
||||
// not a presentation alias (`main` vs `agent:<id>:main`).
|
||||
confirmedKeys.contains(Self.outboxUserIdempotencyKey(command.id))
|
||||
}
|
||||
for command in commands {
|
||||
if let canonicalMessage = messages.first(where: {
|
||||
Self.normalizedIdempotencyKey($0.idempotencyKey) ==
|
||||
Self.outboxUserIdempotencyKey(command.id)
|
||||
}) {
|
||||
await self.persistCanonicalOutboxEvidence(canonicalMessage, for: command)
|
||||
}
|
||||
let result = await outbox.confirmCommand(id: command.id)
|
||||
if result != .unavailable {
|
||||
self.clearOutboxState(forCommandID: command.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func persistCanonicalOutboxEvidence(
|
||||
_ message: OpenClawChatMessage,
|
||||
for command: OpenClawChatOutboxCommand) async
|
||||
{
|
||||
guard let transcriptCache = transcriptCache as? any OpenClawChatCanonicalTranscriptMerging else { return }
|
||||
let sessionKey = command.sessionKey
|
||||
let cacheAgentID = Self.transcriptCacheAgentID(
|
||||
sessionKey: sessionKey,
|
||||
agentID: command.agentID)
|
||||
let messageKey = Self.outboxUserIdempotencyKey(command.id)
|
||||
let previous = self.pendingCacheWriteTask
|
||||
let task = Task.detached {
|
||||
await previous?.value
|
||||
await transcriptCache.mergeCanonicalTranscriptMessage(
|
||||
sessionKey: sessionKey,
|
||||
agentID: cacheAgentID,
|
||||
message: message,
|
||||
canonicalMessageIdempotencyKey: messageKey)
|
||||
}
|
||||
self.pendingCacheWriteTask = task
|
||||
await task.value
|
||||
}
|
||||
|
||||
private func observeCanonicalOutboxMessageKeys(in messages: [OpenClawChatMessage]) {
|
||||
let keys = Set(messages.compactMap(\.idempotencyKey))
|
||||
for key in keys.sorted() {
|
||||
self.canonicalOutboxMessageKeys.removeAll(where: { $0 == key })
|
||||
self.canonicalOutboxMessageKeys.append(key)
|
||||
}
|
||||
if self.canonicalOutboxMessageKeys.count > 512 {
|
||||
self.canonicalOutboxMessageKeys.removeFirst(self.canonicalOutboxMessageKeys.count - 512)
|
||||
}
|
||||
self.transcriptCache?.observeCanonicalMessageIdempotencyKeys(keys)
|
||||
}
|
||||
|
||||
/// Appends bubbles for commands in the current session, adopting rows
|
||||
/// that already carry the command's user idempotency key (cache pre-paint
|
||||
/// or an earlier restore), and refreshes their display states.
|
||||
|
|
@ -164,9 +340,7 @@ extension OpenClawChatViewModel {
|
|||
guard !commands.isEmpty else { return }
|
||||
var next = self.messages
|
||||
for command in commands.sorted(by: { $0.createdAt < $1.createdAt }) {
|
||||
// User-deleted commands awaiting durable removal must not be
|
||||
// re-presented or re-mapped by a concurrent restore/flush pass.
|
||||
if self.deletedOutboxCommandIDs.contains(command.id) { continue }
|
||||
if self.cancelingOutboxCommandIDs.contains(command.id) { continue }
|
||||
let key = Self.outboxUserIdempotencyKey(command.id)
|
||||
if let existing = next.first(where: { $0.idempotencyKey == key }) {
|
||||
self.mapOutboxCommand(command, to: existing.id)
|
||||
|
|
@ -266,50 +440,69 @@ extension OpenClawChatViewModel {
|
|||
|
||||
private func performOutboxFlush() async {
|
||||
guard let outbox else { return }
|
||||
await self.recoverInterruptedOutboxSendsIfNeeded()
|
||||
var flushedCurrentSession = false
|
||||
// One attempt per command per pass: if a delete/mark write ever fails
|
||||
// (broken store), the pass ends instead of re-sending in a hot loop.
|
||||
var attemptedCommandIDs = Set<String>()
|
||||
let presentationGeneration = self.outboxPresentationGeneration
|
||||
guard let initialCommands = await outbox.loadCommandsIfAvailable() else {
|
||||
self.applyTransportHealth(false)
|
||||
return
|
||||
}
|
||||
guard presentationGeneration == self.outboxPresentationGeneration else {
|
||||
self.isOutboxFlushRequestedWhileActive = true
|
||||
return
|
||||
}
|
||||
let visibleSession = self.currentSessionSnapshot()
|
||||
self.presentOutboxCommands(initialCommands.filter { self.commandMatchesTarget($0, session: visibleSession) })
|
||||
// Do not capability-gate ordinary live chat when no durable work
|
||||
// needs a replay lease (notably against older gateways).
|
||||
let hasRouteWork = initialCommands.contains { command in
|
||||
command.status == .queued ||
|
||||
command.status == .sending ||
|
||||
Self.needsOutboxDeliveryReconciliation(command)
|
||||
}
|
||||
guard hasRouteWork else { return }
|
||||
let routeResult = await self.transport.acquireOutboxRouteLease()
|
||||
guard case let .available(routeLease) = routeResult else {
|
||||
// The store owner no longer matches the active gateway route.
|
||||
// Leave every row queued; the replacement view model owns the
|
||||
// new gateway and a later matching reconnect can resume this one.
|
||||
if case let .unavailable(reason) = routeResult, let reason {
|
||||
self.errorText = reason
|
||||
}
|
||||
self.applyTransportHealth(false)
|
||||
return
|
||||
}
|
||||
guard await self.recoverInterruptedOutboxSendsIfNeeded() else {
|
||||
self.applyTransportHealth(false)
|
||||
return
|
||||
}
|
||||
var confirmationTargets: Set<OutboxDeliveryTarget> = []
|
||||
while self.healthOK {
|
||||
let presentationGeneration = self.outboxPresentationGeneration
|
||||
let commands = await outbox.loadCommands()
|
||||
self.presentOutboxCommands(commands.filter { $0.sessionKey == self.sessionKey })
|
||||
guard let next = commands.first(where: {
|
||||
$0.status == .queued && !attemptedCommandIDs.contains($0.id)
|
||||
}) else { break }
|
||||
attemptedCommandIDs.insert(next.id)
|
||||
// Delete-vs-flush race: the user may have removed this bubble
|
||||
// after the pass loaded its snapshot. The tombstone catches the
|
||||
// synchronous UI delete; the claiming UPDATE (zero rows changed
|
||||
// = row already gone) catches the DB-side delete. Either way the
|
||||
// command must not be sent. The tombstone is not consumed here:
|
||||
// it lives until the delete task confirms the row is durably
|
||||
// gone, so retries and later passes stay covered too.
|
||||
if self.deletedOutboxCommandIDs.contains(next.id) {
|
||||
self.clearOutboxState(forCommandID: next.id)
|
||||
if presentationGeneration != self.outboxPresentationGeneration {
|
||||
continue
|
||||
}
|
||||
confirmationTargets.formUnion(
|
||||
commands.lazy
|
||||
.filter(Self.needsOutboxDeliveryReconciliation)
|
||||
.map(Self.deliveryTarget))
|
||||
let visibleSession = self.currentSessionSnapshot()
|
||||
self.presentOutboxCommands(commands.filter { self.commandMatchesTarget($0, session: visibleSession) })
|
||||
guard let next = await outbox.claimNextCommand() else { break }
|
||||
if self.transport.outboxRequiresSessionRoutingContract,
|
||||
next.routingContract != routeLease.sessionRoutingContract
|
||||
{
|
||||
guard await self.parkOutboxCommandForChangedTarget(next, outbox: outbox) else { break }
|
||||
continue
|
||||
}
|
||||
// Same ordering contract as the live send path: a run must not
|
||||
// start on a stale model while a sessions.patch(model) for its
|
||||
// session is still in flight.
|
||||
await self.waitForPendingModelPatches(in: next.sessionKey)
|
||||
guard await outbox.markCommandSending(id: next.id) else {
|
||||
self.clearOutboxState(forCommandID: next.id)
|
||||
continue
|
||||
}
|
||||
// The claim awaited off the main actor: a user delete may have
|
||||
// landed during that suspension (tombstone set, row deletion in
|
||||
// flight). Recheck before the transport call; the delete task
|
||||
// owns the durable removal and drops the tombstone once the row
|
||||
// is gone, even though the claim re-marked it 'sending'.
|
||||
if self.deletedOutboxCommandIDs.contains(next.id) {
|
||||
self.clearOutboxState(forCommandID: next.id)
|
||||
continue
|
||||
}
|
||||
self.setOutboxState(.sending, forCommandID: next.id)
|
||||
do {
|
||||
let response = try await self.transport.sendMessage(
|
||||
sessionKey: next.sessionKey,
|
||||
let response = try await routeLease.sendMessage(
|
||||
sessionKey: next.deliverySessionKey,
|
||||
agentID: next.agentID,
|
||||
message: next.text,
|
||||
// Preserve the queued level when supported, but never send
|
||||
// an explicit unsupported level after the gate changes.
|
||||
|
|
@ -327,70 +520,138 @@ extension OpenClawChatViewModel {
|
|||
reason: "Run failed to start (\(response.status)).")
|
||||
if handled { continue } else { break }
|
||||
}
|
||||
// Ack: drop the durable row. The queued bubble stays and is
|
||||
// adopted by the durable session.message/history row via the
|
||||
// shared idempotency key, so no duplicate turn appears.
|
||||
//
|
||||
// Deliberately no pendingRuns adoption for background flushes:
|
||||
// the reply still lands via handleChatEvent's external-run
|
||||
// final branch (session-scoped, run-id independent),
|
||||
// handleSessionMessageEvent, and the post-drain history
|
||||
// refresh below. Run tracking (typing indicator, streaming,
|
||||
// timeouts) stays owned by interactive performSend.
|
||||
// Close the crash window between ack and the next canonical
|
||||
// history write-through: splice the sent turn into the
|
||||
// session's cached transcript before the outbox row goes
|
||||
// away, so a cold offline reopen still shows it. Await the
|
||||
// chained cache writes first so an in-flight older snapshot
|
||||
// cannot land after the splice and drop the turn.
|
||||
// chat.send ACK precedes durable user-turn persistence. Keep
|
||||
// the outbox row until history carries its idempotency key;
|
||||
// the cache splice makes the acknowledged turn visible while
|
||||
// canonical history catches up.
|
||||
await self.pendingCacheWriteTask?.value
|
||||
await self.spliceSentCommandIntoCachedTranscript(next)
|
||||
// From here the outbox row is the turn's last durable copy;
|
||||
// remember its key so a lagging history snapshot cannot
|
||||
// evict the visible row before confirming it.
|
||||
self.recentlySentOutboxUserKeys.insert(Self.outboxUserIdempotencyKey(next.id))
|
||||
await outbox.deleteCommand(id: next.id)
|
||||
self.clearOutboxState(forCommandID: next.id)
|
||||
self.outboxTransportFailureStreak = 0
|
||||
if next.sessionKey == self.sessionKey {
|
||||
flushedCurrentSession = true
|
||||
let confirmationUpdate = await outbox.markCommandAwaitingConfirmation(id: next.id)
|
||||
if confirmationUpdate == .unavailable {
|
||||
self.applyTransportHealth(false)
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
// Transport-level failure (unreachable, socket drop): a
|
||||
// connectivity blip, not a gateway verdict on the command.
|
||||
// Keep the row queued without burning a durable retry
|
||||
// attempt; the in-memory streak paces repeated throws up the
|
||||
// delay ladder instead of hammering the first rung forever.
|
||||
outboxLogger.error("outbox flush send failed \(error.localizedDescription, privacy: .public)")
|
||||
if confirmationUpdate == .updated {
|
||||
self.setOutboxState(.confirming, forCommandID: next.id)
|
||||
} else {
|
||||
// A concurrent canonical history/session.message
|
||||
// confirmation already removed the row.
|
||||
self.clearOutboxState(forCommandID: next.id)
|
||||
}
|
||||
confirmationTargets.insert(Self.deliveryTarget(for: next))
|
||||
} catch is OpenClawChatTransportSendError {
|
||||
// The transport proved this payload never reached its request
|
||||
// channel, so it is safe to retry automatically.
|
||||
await outbox.markCommandQueued(
|
||||
id: next.id,
|
||||
retryCount: next.retryCount,
|
||||
lastError: error.localizedDescription)
|
||||
lastError: nil)
|
||||
self.setOutboxState(.queued, forCommandID: next.id)
|
||||
self.outboxTransportFailureStreak += 1
|
||||
if self.outboxTransportFailureStreak > self.outboxRetryDelaysMs.count {
|
||||
// Ladder exhausted: the transport is not actually usable
|
||||
// despite healthOK. Drop health so the reconnect/poll
|
||||
// machinery owns pacing; the next genuine healthy
|
||||
// transition re-flushes and the row stays queued.
|
||||
self.healthOK = false
|
||||
break
|
||||
self.applyTransportHealth(false)
|
||||
break
|
||||
} catch is CancellationError {
|
||||
// Cancellation while the request is suspended does not prove
|
||||
// that the gateway rejected it. Never replay automatically.
|
||||
let update = await self.parkOutboxCommandWithUnconfirmedDelivery(next, outbox: outbox)
|
||||
if update == .updated {
|
||||
confirmationTargets.insert(Self.deliveryTarget(for: next))
|
||||
}
|
||||
// Strict createdAt ordering: never skip ahead of a command
|
||||
// that is still deliverable.
|
||||
self.scheduleOutboxRetry(afterAttempts: self.outboxTransportFailureStreak)
|
||||
self.applyTransportHealth(false)
|
||||
break
|
||||
} catch let error as GatewayResponseError {
|
||||
if error.detailsReason == OpenClawChatSessionRoutingContract.changedErrorReason {
|
||||
guard await self.parkOutboxCommandForChangedTarget(next, outbox: outbox) else { break }
|
||||
continue
|
||||
}
|
||||
// A response error proves the gateway rejected the request;
|
||||
// unlike a socket/timeout failure, replay cannot duplicate an
|
||||
// accepted run and should consume the normal retry budget.
|
||||
let handled = await self.recordOutboxRejection(
|
||||
of: next,
|
||||
outbox: outbox,
|
||||
reason: error.localizedDescription)
|
||||
if handled { continue } else { break }
|
||||
} catch {
|
||||
// A socket error or timeout is not a gateway rejection: the
|
||||
// request may have landed before its ACK was lost. Preserve
|
||||
// it for history reconciliation or explicit user retry.
|
||||
outboxLogger.error("outbox flush send failed \(error.localizedDescription, privacy: .public)")
|
||||
let update = await self.parkOutboxCommandWithUnconfirmedDelivery(next, outbox: outbox)
|
||||
if update == .updated {
|
||||
confirmationTargets.insert(Self.deliveryTarget(for: next))
|
||||
}
|
||||
self.applyTransportHealth(false)
|
||||
break
|
||||
}
|
||||
}
|
||||
// Tombstones are NOT cleared here: each lives until its delete task
|
||||
// confirms the row is durably gone (process death in that window
|
||||
// leaves both row and tombstone-protected bubble intact). The set
|
||||
// stays bounded by in-flight user deletes.
|
||||
if flushedCurrentSession {
|
||||
await self.refreshHistoryAfterOutboxFlush()
|
||||
if !confirmationTargets.isEmpty {
|
||||
await self.refreshHistoriesAfterOutboxFlush(
|
||||
targets: confirmationTargets,
|
||||
routeLease: routeLease)
|
||||
}
|
||||
}
|
||||
|
||||
private static func needsOutboxDeliveryReconciliation(_ command: OpenClawChatOutboxCommand) -> Bool {
|
||||
command.status == .awaitingConfirmation ||
|
||||
(command.status == .failed &&
|
||||
command.lastError == OpenClawChatSQLiteTranscriptCache.outboxUnconfirmedError)
|
||||
}
|
||||
|
||||
private static func deliveryTarget(for command: OpenClawChatOutboxCommand) -> OutboxDeliveryTarget {
|
||||
OutboxDeliveryTarget(
|
||||
presentationSessionKey: command.sessionKey,
|
||||
deliverySessionKey: command.deliverySessionKey,
|
||||
agentID: command.agentID)
|
||||
}
|
||||
|
||||
private func parkOutboxCommandWithUnconfirmedDelivery(
|
||||
_ command: OpenClawChatOutboxCommand,
|
||||
outbox: any OpenClawChatCommandOutbox) async -> OpenClawChatOutboxUpdateResult
|
||||
{
|
||||
let update = await outbox.markCommandFailedIfPresent(
|
||||
id: command.id,
|
||||
retryCount: command.retryCount,
|
||||
lastError: OpenClawChatSQLiteTranscriptCache.outboxUnconfirmedError)
|
||||
switch update {
|
||||
case .updated:
|
||||
self.setOutboxState(
|
||||
.failed(reason: OpenClawChatSQLiteTranscriptCache.outboxUnconfirmedError),
|
||||
forCommandID: command.id)
|
||||
case .missing, .confirmed:
|
||||
self.clearOutboxState(forCommandID: command.id)
|
||||
case .unavailable:
|
||||
self.applyTransportHealth(false)
|
||||
}
|
||||
return update
|
||||
}
|
||||
|
||||
private func parkOutboxCommandForChangedTarget(
|
||||
_ command: OpenClawChatOutboxCommand,
|
||||
outbox: any OpenClawChatCommandOutbox) async -> Bool
|
||||
{
|
||||
let update = await outbox.markCommandFailedIfPresent(
|
||||
id: command.id,
|
||||
retryCount: command.retryCount,
|
||||
lastError: OpenClawChatSQLiteTranscriptCache.outboxChangedTargetError)
|
||||
guard update != .unavailable else {
|
||||
self.applyTransportHealth(false)
|
||||
return false
|
||||
}
|
||||
if update == .updated {
|
||||
let reason = "Gateway session routing changed; review and retry this message."
|
||||
self.setOutboxState(.failed(reason: reason), forCommandID: command.id)
|
||||
} else {
|
||||
self.clearOutboxState(forCommandID: command.id)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/// Gateway rejections ("error"/"timeout" send acks) burn a retry attempt
|
||||
/// and become terminally 'failed' after `maxOutboxSendAttempts`. Returns
|
||||
/// true when the flush pass may continue with younger commands.
|
||||
|
|
@ -402,8 +663,21 @@ extension OpenClawChatViewModel {
|
|||
outboxLogger.error("outbox flush send rejected \(reason, privacy: .public)")
|
||||
let attempts = command.retryCount + 1
|
||||
if attempts >= Self.maxOutboxSendAttempts {
|
||||
await outbox.markCommandFailed(id: command.id, retryCount: attempts, lastError: reason)
|
||||
self.setOutboxState(.failed(reason: reason), forCommandID: command.id)
|
||||
let update = await outbox.markCommandFailedIfPresent(
|
||||
id: command.id,
|
||||
retryCount: attempts,
|
||||
lastError: reason)
|
||||
guard update != .unavailable else {
|
||||
self.applyTransportHealth(false)
|
||||
return false
|
||||
}
|
||||
if update == .updated {
|
||||
self.setOutboxState(.failed(reason: reason), forCommandID: command.id)
|
||||
} else {
|
||||
// Canonical history may have removed the claimed row while
|
||||
// the rejection was in flight.
|
||||
self.clearOutboxState(forCommandID: command.id)
|
||||
}
|
||||
// Terminal failure needs user action; let younger commands
|
||||
// flush instead of blocking behind it forever.
|
||||
return true
|
||||
|
|
@ -440,21 +714,90 @@ extension OpenClawChatViewModel {
|
|||
private func spliceSentCommandIntoCachedTranscript(_ command: OpenClawChatOutboxCommand) async {
|
||||
guard let transcriptCache else { return }
|
||||
let key = Self.outboxUserIdempotencyKey(command.id)
|
||||
var cached = await transcriptCache.loadTranscript(sessionKey: command.sessionKey)
|
||||
let cacheAgentID = Self.transcriptCacheAgentID(
|
||||
sessionKey: command.sessionKey,
|
||||
agentID: command.agentID)
|
||||
var cached = await transcriptCache.loadTranscript(
|
||||
sessionKey: command.sessionKey,
|
||||
agentID: cacheAgentID)
|
||||
guard !cached.contains(where: { $0.idempotencyKey == key }) else { return }
|
||||
cached.append(Self.outboxUserMessage(for: command))
|
||||
await transcriptCache.storeTranscript(sessionKey: command.sessionKey, messages: cached)
|
||||
await transcriptCache.storeTranscript(
|
||||
sessionKey: command.sessionKey,
|
||||
agentID: cacheAgentID,
|
||||
messages: cached)
|
||||
}
|
||||
|
||||
private func recoverInterruptedOutboxSendsIfNeeded() async {
|
||||
guard let outbox, !self.hasRecoveredInterruptedOutboxSends else { return }
|
||||
// Burn the once-per-launch gate only when the store was reachable:
|
||||
// with Complete file protection the database is legitimately
|
||||
// unavailable while the device is locked, and skipping recovery then
|
||||
// would leave crashed 'sending' rows stuck forever after unlock.
|
||||
if await outbox.recoverInterruptedSends() {
|
||||
self.hasRecoveredInterruptedOutboxSends = true
|
||||
private func recoverInterruptedOutboxSendsIfNeeded() async -> Bool {
|
||||
guard let outbox else { return false }
|
||||
// The store owns the once-per-process gate so overlapping/replacement
|
||||
// view models cannot reset another active sender's claim.
|
||||
return await outbox.recoverInterruptedSends()
|
||||
}
|
||||
|
||||
private func outboxAgentID(for session: SessionSnapshot) -> String? {
|
||||
guard self.transport.outboxRequiresSessionRoutingContract else { return nil }
|
||||
if session.key.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "unknown" {
|
||||
return nil
|
||||
}
|
||||
let normalized = session.deliveryAgentID?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
return normalized?.isEmpty == false ? normalized : nil
|
||||
}
|
||||
|
||||
private func outboxRequiresAgentID(for session: SessionSnapshot) -> Bool {
|
||||
guard self.transport.outboxRequiresSessionRoutingContract else { return false }
|
||||
return session.key.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() != "unknown"
|
||||
}
|
||||
|
||||
private func outboxRoutingContract(for session: SessionSnapshot) -> String? {
|
||||
if !self.transport.outboxRequiresSessionRoutingContract {
|
||||
return OpenClawChatOutboxCommand.legacyUnboundRoutingContract
|
||||
}
|
||||
let normalized = session.sessionRoutingContract?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return normalized?.isEmpty == false ? normalized : nil
|
||||
}
|
||||
|
||||
/// Resolve once, before persistence. Re-resolving a presentation alias
|
||||
/// after reconnect could deliver to a newly selected/default agent.
|
||||
private func outboxDeliverySessionKey(
|
||||
for session: SessionSnapshot,
|
||||
agentID: String?) -> String?
|
||||
{
|
||||
let raw = session.key.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !raw.isEmpty else { return nil }
|
||||
guard self.transport.outboxRequiresSessionRoutingContract else { return raw }
|
||||
if raw.lowercased() == "unknown" { return raw }
|
||||
guard let agentID else { return nil }
|
||||
let normalized = raw.lowercased()
|
||||
if normalized == "global" { return "global" }
|
||||
if Self.agentID(fromSessionKey: raw) != nil { return raw }
|
||||
// A malformed ownership prefix must fail closed, not become a nested
|
||||
// key such as agent:<id>:agent::main.
|
||||
guard !normalized.hasPrefix("agent:") else { return nil }
|
||||
// The gateway owns structural normalization and preserves opaque
|
||||
// Matrix/Signal peer IDs. Keep the request key byte-for-byte here.
|
||||
return "agent:\(agentID):\(raw)"
|
||||
}
|
||||
|
||||
private func commandMatchesTarget(
|
||||
_ command: OpenClawChatOutboxCommand,
|
||||
session: SessionSnapshot) -> Bool
|
||||
{
|
||||
guard command.sessionKey == session.key else { return false }
|
||||
// Failed rows never auto-send. Keep them reachable on their original
|
||||
// presentation alias after an owner change for explicit retry/delete.
|
||||
if command.status == .failed { return true }
|
||||
// Migrated v2 aliases have no owner and are parked as failed. Show
|
||||
// them so explicit retry can adopt the currently selected agent.
|
||||
guard let commandAgentID = command.agentID else { return true }
|
||||
guard let currentAgentID = self.outboxAgentID(for: session) else {
|
||||
// Cold offline launch has not recovered gateway ownership yet.
|
||||
// Keep the durable turn visible; the route lease verifies its
|
||||
// captured owner and contract before any later delivery.
|
||||
return true
|
||||
}
|
||||
return commandAgentID == currentAgentID
|
||||
}
|
||||
|
||||
private func setOutboxState(_ state: OpenClawChatOutboxMessageState, forCommandID commandID: String) {
|
||||
|
|
@ -468,6 +811,33 @@ extension OpenClawChatViewModel {
|
|||
self.outboxStatesByMessageID.removeValue(forKey: messageID)
|
||||
}
|
||||
|
||||
func handleOutboxChange(_ change: OpenClawChatOutboxChange) {
|
||||
// Invalidates every command snapshot that started loading before the
|
||||
// store mutation, including snapshots owned by another view model.
|
||||
self.outboxPresentationGeneration &+= 1
|
||||
switch change {
|
||||
case let .canceled(commandID):
|
||||
// The initiating view owns its async result so canonical proof
|
||||
// observed before that continuation can still preserve the row.
|
||||
// Other views have no local cancellation task and apply the event.
|
||||
guard !self.cancelingOutboxCommandIDs.contains(commandID) else { return }
|
||||
guard let messageID = self.outboxMessageIDsByCommandID[commandID] else { return }
|
||||
self.clearOutboxState(forCommandID: commandID)
|
||||
self.replaceMessages(self.messages.filter { $0.id != messageID })
|
||||
case let .confirmed(commandID):
|
||||
// Canonical history owns the message row; only its outbox badge
|
||||
// and command mapping disappear.
|
||||
self.clearOutboxState(forCommandID: commandID)
|
||||
}
|
||||
}
|
||||
|
||||
private func finishOutboxCancellation(_ commandID: String) {
|
||||
// Loads started while cancellation was in flight carry the previous
|
||||
// generation and cannot re-present their stale command snapshot.
|
||||
self.outboxPresentationGeneration &+= 1
|
||||
self.cancelingOutboxCommandIDs.remove(commandID)
|
||||
}
|
||||
|
||||
private static func outboxDisplayState(for command: OpenClawChatOutboxCommand)
|
||||
-> OpenClawChatOutboxMessageState
|
||||
{
|
||||
|
|
@ -476,6 +846,8 @@ extension OpenClawChatViewModel {
|
|||
.queued
|
||||
case .sending:
|
||||
.sending
|
||||
case .awaitingConfirmation:
|
||||
.confirming
|
||||
case .failed:
|
||||
.failed(reason: command.lastError)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ extension OpenClawChatViewModel {
|
|||
public var sessionChoices: [OpenClawChatSessionEntry] {
|
||||
let now = Date().timeIntervalSince1970 * 1000
|
||||
let cutoff = now - (24 * 60 * 60 * 1000)
|
||||
let sorted = self.sessions.sorted { ($0.updatedAt ?? 0) > ($1.updatedAt ?? 0) }
|
||||
let mainSessionKey = self.resolvedMainSessionKey
|
||||
let sorted = sessions.sorted { ($0.updatedAt ?? 0) > ($1.updatedAt ?? 0) }
|
||||
let mainSessionKey = resolvedMainSessionKey
|
||||
|
||||
var result: [OpenClawChatSessionEntry] = []
|
||||
var included = Set<String>()
|
||||
|
|
@ -15,23 +15,23 @@ extension OpenClawChatViewModel {
|
|||
result.append(main)
|
||||
included.insert(main.key)
|
||||
} else {
|
||||
result.append(self.placeholderSession(key: mainSessionKey))
|
||||
result.append(placeholderSession(key: mainSessionKey))
|
||||
included.insert(mainSessionKey)
|
||||
}
|
||||
|
||||
for entry in sorted {
|
||||
guard !included.contains(entry.key) else { continue }
|
||||
guard entry.key == self.sessionKey || !Self.isHiddenInternalSession(entry.key) else { continue }
|
||||
guard entry.key == sessionKey || !Self.isHiddenInternalSession(entry.key) else { continue }
|
||||
guard (entry.updatedAt ?? 0) >= cutoff else { continue }
|
||||
result.append(entry)
|
||||
included.insert(entry.key)
|
||||
}
|
||||
|
||||
if !included.contains(self.sessionKey) {
|
||||
if !included.contains(sessionKey) {
|
||||
if let current = sorted.first(where: { $0.key == self.sessionKey }) {
|
||||
result.append(current)
|
||||
} else {
|
||||
result.append(self.placeholderSession(key: self.sessionKey))
|
||||
result.append(placeholderSession(key: sessionKey))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -72,8 +72,20 @@ extension OpenClawChatViewModel {
|
|||
let incomingNormalized = incoming.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
let currentNormalized = current.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
if incomingNormalized == currentNormalized {
|
||||
if incomingNormalized == "global" {
|
||||
return Self.matchesGlobalAgent(agentId: agentId, activeAgentId: activeAgentId)
|
||||
if Self.agentIDFromSessionKey(currentNormalized) == nil {
|
||||
// `global` is always agent-ambiguous. Ordinary exact keys can
|
||||
// arrive before bootstrap publishes the active agent; accept
|
||||
// them until there is ownership metadata to compare.
|
||||
if currentNormalized != "global",
|
||||
self.normalizedAgentId(activeAgentId) == nil
|
||||
{
|
||||
return true
|
||||
}
|
||||
return Self.matchesAliasAgent(
|
||||
incomingKey: incomingNormalized,
|
||||
agentId: agentId,
|
||||
currentKey: currentNormalized,
|
||||
activeAgentId: activeAgentId)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
|
@ -84,10 +96,19 @@ extension OpenClawChatViewModel {
|
|||
current: currentNormalized,
|
||||
mainSessionKey: mainNormalized)
|
||||
{
|
||||
if incomingNormalized == "global" || currentNormalized == "global" || mainNormalized == "global" {
|
||||
return Self.matchesGlobalAgent(agentId: agentId, activeAgentId: activeAgentId)
|
||||
}
|
||||
return true
|
||||
return Self.matchesAliasAgent(
|
||||
incomingKey: incomingNormalized,
|
||||
agentId: agentId,
|
||||
currentKey: currentNormalized,
|
||||
activeAgentId: activeAgentId,
|
||||
allowIncomingOwnerWhenCurrentUnknown: true)
|
||||
}
|
||||
if Self.matchesSelectedAgentWrapper(incoming: incomingNormalized, current: currentNormalized) {
|
||||
return Self.matchesAliasAgent(
|
||||
incomingKey: incomingNormalized,
|
||||
agentId: agentId,
|
||||
currentKey: currentNormalized,
|
||||
activeAgentId: activeAgentId)
|
||||
}
|
||||
if Self.matchesSelectedAgentGlobal(
|
||||
incoming: incomingNormalized,
|
||||
|
|
@ -104,10 +125,46 @@ extension OpenClawChatViewModel {
|
|||
return normalized?.isEmpty == false ? normalized : nil
|
||||
}
|
||||
|
||||
private static func matchesGlobalAgent(agentId: String?, activeAgentId: String?) -> Bool {
|
||||
guard let activeAgentId = normalizedAgentId(activeAgentId) else { return false }
|
||||
guard let incomingAgentId = normalizedAgentId(agentId) else { return true }
|
||||
return incomingAgentId == activeAgentId
|
||||
private static func matchesAliasAgent(
|
||||
incomingKey: String,
|
||||
agentId: String?,
|
||||
currentKey: String,
|
||||
activeAgentId: String?,
|
||||
allowIncomingOwnerWhenCurrentUnknown: Bool = false) -> Bool
|
||||
{
|
||||
let currentAgentID = self.agentIDFromSessionKey(currentKey) ?? self.normalizedAgentId(activeAgentId)
|
||||
let payloadAgentID = self.normalizedAgentId(agentId)
|
||||
let keyAgentID = self.agentIDFromSessionKey(incomingKey)
|
||||
if let payloadAgentID, let keyAgentID, payloadAgentID != keyAgentID {
|
||||
return false
|
||||
}
|
||||
let incomingAgentID = payloadAgentID ?? keyAgentID
|
||||
guard let currentAgentID else {
|
||||
return allowIncomingOwnerWhenCurrentUnknown && incomingAgentID != nil
|
||||
}
|
||||
guard let incomingAgentID else { return true }
|
||||
return incomingAgentID == currentAgentID
|
||||
}
|
||||
|
||||
private static func agentIDFromSessionKey(_ sessionKey: String) -> String? {
|
||||
let parts = sessionKey.split(separator: ":", maxSplits: 2, omittingEmptySubsequences: false)
|
||||
guard parts.count >= 3, parts[0] == "agent" else { return nil }
|
||||
let agentID = String(parts[1]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return agentID.isEmpty ? nil : agentID
|
||||
}
|
||||
|
||||
private static func matchesSelectedAgentWrapper(incoming: String, current: String) -> Bool {
|
||||
let incomingParts = incoming.split(separator: ":", maxSplits: 2, omittingEmptySubsequences: false)
|
||||
if incomingParts.count == 3,
|
||||
incomingParts[0] == "agent",
|
||||
String(incomingParts[2]) == current
|
||||
{
|
||||
return true
|
||||
}
|
||||
let currentParts = current.split(separator: ":", maxSplits: 2, omittingEmptySubsequences: false)
|
||||
return currentParts.count == 3 &&
|
||||
currentParts[0] == "agent" &&
|
||||
String(currentParts[2]) == incoming
|
||||
}
|
||||
|
||||
private static func matchesMainAlias(incoming: String, current: String, mainSessionKey: String) -> Bool {
|
||||
|
|
|
|||
|
|
@ -8,29 +8,41 @@ extension OpenClawChatViewModel {
|
|||
struct SessionSnapshot {
|
||||
var key: String
|
||||
var generation: UInt64
|
||||
var agentID: String?
|
||||
var deliveryAgentID: String?
|
||||
var sessionRoutingContract: String?
|
||||
}
|
||||
|
||||
func replaceMessages(_ messages: [OpenClawChatMessage]) {
|
||||
guard self.messages != messages else { return }
|
||||
self.messages = messages
|
||||
self.markTimelineChanged()
|
||||
markTimelineChanged()
|
||||
}
|
||||
|
||||
func persistTranscriptToCache(sessionKey: String, messages: [OpenClawChatMessage]) {
|
||||
func persistTranscriptToCache(
|
||||
sessionKey: String,
|
||||
agentID: String?,
|
||||
messages: [OpenClawChatMessage],
|
||||
canonicalMessageIdempotencyKeys: Set<String>)
|
||||
{
|
||||
guard let transcriptCache else { return }
|
||||
// Chain writes so an older snapshot can never land after a newer one;
|
||||
// detached tasks alone give no ordering guarantee across awaits.
|
||||
let previous = self.pendingCacheWriteTask
|
||||
self.pendingCacheWriteTask = Task.detached {
|
||||
let previous = pendingCacheWriteTask
|
||||
pendingCacheWriteTask = Task.detached {
|
||||
await previous?.value
|
||||
await transcriptCache.storeTranscript(sessionKey: sessionKey, messages: messages)
|
||||
await transcriptCache.storeCanonicalTranscript(
|
||||
sessionKey: sessionKey,
|
||||
agentID: Self.transcriptCacheAgentID(sessionKey: sessionKey, agentID: agentID),
|
||||
messages: messages,
|
||||
canonicalMessageIdempotencyKeys: canonicalMessageIdempotencyKeys)
|
||||
}
|
||||
}
|
||||
|
||||
func persistSessionsToCache(_ sessions: [OpenClawChatSessionEntry]) {
|
||||
guard let transcriptCache else { return }
|
||||
let previous = self.pendingCacheWriteTask
|
||||
self.pendingCacheWriteTask = Task.detached {
|
||||
let previous = pendingCacheWriteTask
|
||||
pendingCacheWriteTask = Task.detached {
|
||||
await previous?.value
|
||||
await transcriptCache.storeSessions(sessions)
|
||||
}
|
||||
|
|
@ -42,7 +54,7 @@ extension OpenClawChatViewModel {
|
|||
/// applyHistoryPayload reconciliation path.
|
||||
func paintFromCacheIfNeeded(session: SessionSnapshot) {
|
||||
guard let transcriptCache else { return }
|
||||
if self.sessions.isEmpty, !self.hasAppliedLiveSessions {
|
||||
if sessions.isEmpty, !hasAppliedLiveSessions {
|
||||
Task { [weak self] in
|
||||
let cached = await transcriptCache.loadSessions()
|
||||
guard let self, !cached.isEmpty else { return }
|
||||
|
|
@ -52,9 +64,11 @@ extension OpenClawChatViewModel {
|
|||
self.sessions = cached
|
||||
}
|
||||
}
|
||||
guard self.messages.isEmpty, !self.hasAppliedLiveHistory else { return }
|
||||
guard messages.isEmpty, !hasAppliedLiveHistory else { return }
|
||||
Task { [weak self] in
|
||||
let cached = await transcriptCache.loadTranscript(sessionKey: session.key)
|
||||
let cached = await transcriptCache.loadTranscript(
|
||||
sessionKey: session.key,
|
||||
agentID: Self.transcriptCacheAgentID(sessionKey: session.key, agentID: session.agentID))
|
||||
guard let self, !cached.isEmpty else { return }
|
||||
guard self.isCurrentSession(session), !self.hasAppliedLiveHistory, self.messages.isEmpty else {
|
||||
return
|
||||
|
|
@ -63,4 +77,10 @@ extension OpenClawChatViewModel {
|
|||
self.isShowingCachedTranscript = true
|
||||
}
|
||||
}
|
||||
|
||||
static func transcriptCacheAgentID(sessionKey: String, agentID: String?) -> String? {
|
||||
guard Self.agentID(fromSessionKey: sessionKey) == nil else { return nil }
|
||||
let normalized = agentID?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
return normalized?.isEmpty == false ? normalized : nil
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,11 +39,18 @@ public final class OpenClawChatViewModel {
|
|||
|
||||
public private(set) var isLoading = false
|
||||
public private(set) var isSending = false
|
||||
private var isSubmittingDraft = false
|
||||
public private(set) var isAborting = false
|
||||
public var errorText: String?
|
||||
public var attachments: [OpenClawPendingAttachment] = []
|
||||
/// Setter is module-internal for the health/outbox extension only.
|
||||
public internal(set) var healthOK: Bool = false
|
||||
|
||||
/// True when this view model owns a gateway-scoped durable text outbox.
|
||||
public var supportsOfflineTextOutbox: Bool {
|
||||
self.outbox != nil
|
||||
}
|
||||
|
||||
public private(set) var pendingRunCount: Int = 0
|
||||
|
||||
public private(set) var sessionKey: String
|
||||
|
|
@ -76,34 +83,23 @@ public final class OpenClawChatViewModel {
|
|||
var outboxCommandIDsByMessageID: [UUID: String] = [:]
|
||||
@ObservationIgnored
|
||||
var outboxMessageIDsByCommandID: [String: UUID] = [:]
|
||||
/// Recent canonical keys let the MainActor resolve proof that arrives
|
||||
/// after SQLite cancellation commits but before its UI continuation runs.
|
||||
@ObservationIgnored
|
||||
var canonicalOutboxMessageKeys: [String] = []
|
||||
@ObservationIgnored
|
||||
var isFlushingOutbox = false
|
||||
@ObservationIgnored
|
||||
var isOutboxFlushRequestedWhileActive = false
|
||||
@ObservationIgnored
|
||||
var hasRecoveredInterruptedOutboxSends = false
|
||||
/// Tombstones set synchronously on user delete so an active flush pass
|
||||
/// never sends a command whose bubble the user just removed.
|
||||
var cancelingOutboxCommandIDs: Set<String> = []
|
||||
@ObservationIgnored
|
||||
var deletedOutboxCommandIDs: Set<String> = []
|
||||
var outboxPresentationGeneration: UInt64 = 0
|
||||
@ObservationIgnored
|
||||
var outboxChangesTask: Task<Void, Never>?
|
||||
/// Backoff between failed flush attempts; internal so tests can shorten it.
|
||||
@ObservationIgnored
|
||||
var outboxRetryDelaysMs: [UInt64] = [2000, 8000]
|
||||
/// Consecutive transport-level flush failures. Paces retries up the
|
||||
/// delay ladder without touching the durable per-command retryCount
|
||||
/// (reserved for gateway verdicts); past the ladder, health drops and
|
||||
/// the reconnect machinery owns pacing.
|
||||
@ObservationIgnored
|
||||
var outboxTransportFailureStreak = 0
|
||||
/// User idempotency keys of turns just flushed from the outbox whose
|
||||
/// durable outbox row is already deleted but which no history snapshot
|
||||
/// has confirmed yet. Reconciliation must not evict them: the gateway
|
||||
/// history can lag the ack, and eviction here would drop the turn from
|
||||
/// both the screen and the write-through cache. Drained when a snapshot
|
||||
/// contains the key; bounded because every flush drains or session
|
||||
/// switches clear it.
|
||||
@ObservationIgnored
|
||||
var recentlySentOutboxUserKeys: Set<String> = []
|
||||
/// False until restoreOutboxMessages has adopted durable rows for the
|
||||
/// visible session. Until then the in-memory outbox state is blind to
|
||||
/// rows persisted by an earlier process, so the FIFO send gate must
|
||||
|
|
@ -117,6 +113,7 @@ public final class OpenClawChatViewModel {
|
|||
@ObservationIgnored
|
||||
var pendingCacheWriteTask: Task<Void, Never>?
|
||||
private(set) var activeAgentId: String?
|
||||
private(set) var sessionRoutingContract: String?
|
||||
var sessionDefaults: OpenClawChatSessionsDefaults?
|
||||
private let prefersExplicitThinkingLevel: Bool
|
||||
private let onSessionChanged: (@MainActor (String) -> Void)?
|
||||
|
|
@ -241,6 +238,7 @@ public final class OpenClawChatViewModel {
|
|||
sessionKey: String,
|
||||
transport: any OpenClawChatTransport,
|
||||
activeAgentId: String? = nil,
|
||||
sessionRoutingContract: String? = nil,
|
||||
haptics: OpenClawChatHaptics = OpenClawChatHaptics(),
|
||||
transcriptCache: (any OpenClawChatTranscriptCache)? = nil,
|
||||
outbox: (any OpenClawChatCommandOutbox)? = nil,
|
||||
|
|
@ -257,9 +255,12 @@ public final class OpenClawChatViewModel {
|
|||
self.modelPickerStore = modelPickerStore
|
||||
self.modelPickerFavorites = modelPickerStore.favorites
|
||||
self.modelPickerRecents = modelPickerStore.recents
|
||||
self.outbox = outbox
|
||||
let normalizedAgentId = activeAgentId?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
self.activeAgentId = normalizedAgentId?.isEmpty == false ? normalizedAgentId : nil
|
||||
self.outbox = outbox
|
||||
let normalizedRoutingContract = sessionRoutingContract?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.sessionRoutingContract = normalizedRoutingContract?.isEmpty == false ? normalizedRoutingContract : nil
|
||||
let normalizedThinkingLevel = Self.normalizedThinkingLevel(initialThinkingLevel)
|
||||
let initialResolvedThinkingLevel = normalizedThinkingLevel ?? "off"
|
||||
self.thinkingLevel = initialResolvedThinkingLevel
|
||||
|
|
@ -281,12 +282,22 @@ public final class OpenClawChatViewModel {
|
|||
}
|
||||
}
|
||||
}
|
||||
if let outbox = self.outbox {
|
||||
let changes = outbox.changes()
|
||||
self.outboxChangesTask = Task { [weak self, changes] in
|
||||
for await change in changes {
|
||||
guard !Task.isCancelled else { return }
|
||||
self?.handleOutboxChange(change)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
self.eventTask?.cancel()
|
||||
self.bootstrapTask?.cancel()
|
||||
self.outboxRetryTask?.cancel()
|
||||
self.outboxChangesTask?.cancel()
|
||||
for (_, task) in self.pendingRunTimeoutTasks {
|
||||
task.cancel()
|
||||
}
|
||||
|
|
@ -353,21 +364,46 @@ public final class OpenClawChatViewModel {
|
|||
}
|
||||
|
||||
public func syncActiveAgentId(_ agentId: String?) {
|
||||
self.syncDeliveryIdentity(
|
||||
activeAgentId: agentId,
|
||||
sessionRoutingContract: self.sessionRoutingContract)
|
||||
}
|
||||
|
||||
public func syncSessionRoutingContract(_ contract: String?) {
|
||||
self.syncDeliveryIdentity(
|
||||
activeAgentId: self.activeAgentId,
|
||||
sessionRoutingContract: contract)
|
||||
}
|
||||
|
||||
/// Updates the alias owner and its gateway routing contract as one
|
||||
/// identity change so an intermediate bootstrap cannot win either value.
|
||||
public func syncDeliveryIdentity(
|
||||
activeAgentId agentId: String?,
|
||||
sessionRoutingContract contract: String?)
|
||||
{
|
||||
let normalized = agentId?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
let nextAgentId = normalized?.isEmpty == false ? normalized : nil
|
||||
guard self.activeAgentId != nextAgentId else { return }
|
||||
let normalizedContract = contract?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let nextContract = normalizedContract?.isEmpty == false ? normalizedContract : nil
|
||||
let agentChanged = self.activeAgentId != nextAgentId
|
||||
let contractChanged = self.sessionRoutingContract != nextContract
|
||||
guard agentChanged || contractChanged else { return }
|
||||
// A scoped key can be the main alias under either side of a contract
|
||||
// change. Check both or stale transcript state can survive the switch.
|
||||
let contractRoutingChanged = contractChanged &&
|
||||
(usesMutableContractRouting(for: sessionRoutingContract) ||
|
||||
self.usesMutableContractRouting(for: nextContract))
|
||||
self.activeAgentId = nextAgentId
|
||||
let normalizedSessionKey = self.sessionKey.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
let normalizedMainSessionKey = self.resolvedMainSessionKey
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
guard normalizedSessionKey == "global" ||
|
||||
normalizedSessionKey == "main" ||
|
||||
normalizedSessionKey == normalizedMainSessionKey
|
||||
else {
|
||||
self.sessionRoutingContract = nextContract
|
||||
let bootstrapIdentityChanged =
|
||||
(agentChanged && self.usesMutableAgentRouting) ||
|
||||
contractRoutingChanged
|
||||
guard bootstrapIdentityChanged else {
|
||||
if contractChanged, self.healthOK { flushOutboxIfNeeded() }
|
||||
return
|
||||
}
|
||||
// Main aliases belong to the configured default agent. Changing that
|
||||
// identity changes their transcript owner, so no old request or row may cross it.
|
||||
// Restart when this key depends on a changed routing value so cleared
|
||||
// state cannot remain stuck or cross session owners.
|
||||
self.advanceSessionGeneration()
|
||||
self.clearSessionOwnedState()
|
||||
self.startBootstrap()
|
||||
|
|
@ -396,7 +432,7 @@ public final class OpenClawChatViewModel {
|
|||
query: String,
|
||||
filter: OpenClawChatCommandFilter) -> [OpenClawChatCommandChoice]
|
||||
{
|
||||
if let cache = self.slashFilterCache, cache.query == query, cache.filter == filter {
|
||||
if let cache = slashFilterCache, cache.query == query, cache.filter == filter {
|
||||
return cache.result
|
||||
}
|
||||
let result = Self.filteredSlashCommands(self.slashCommands, query: query, filter: filter)
|
||||
|
|
@ -417,6 +453,29 @@ public final class OpenClawChatViewModel {
|
|||
return (trimmed?.isEmpty == false ? trimmed : nil) ?? "main"
|
||||
}
|
||||
|
||||
private var usesMutableAgentRouting: Bool {
|
||||
Self.agentID(fromSessionKey: self.sessionKey) == nil
|
||||
}
|
||||
|
||||
private func usesMutableContractRouting(for contract: String?) -> Bool {
|
||||
if self.usesMutableAgentRouting { return true }
|
||||
let parts = self.sessionKey
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.split(separator: ":", maxSplits: 2, omittingEmptySubsequences: false)
|
||||
guard parts.count == 3 else { return false }
|
||||
let normalizedSessionKey = parts[2].trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
let resolvedMainParts = self.resolvedMainSessionKey
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.split(separator: ":", maxSplits: 2, omittingEmptySubsequences: false)
|
||||
let normalizedMainSessionKey = String(resolvedMainParts.last ?? "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
let contractMainKey = OpenClawChatSessionRoutingContract.parse(contract)?.mainKey ?? ""
|
||||
return normalizedSessionKey == "global" ||
|
||||
normalizedSessionKey == "main" ||
|
||||
normalizedSessionKey == normalizedMainSessionKey ||
|
||||
normalizedSessionKey == contractMainKey
|
||||
}
|
||||
|
||||
public var showsModelPicker: Bool {
|
||||
!self.modelChoices.isEmpty
|
||||
}
|
||||
|
|
@ -449,7 +508,7 @@ public final class OpenClawChatViewModel {
|
|||
}
|
||||
|
||||
public var canSend: Bool {
|
||||
!self.isSending && self.pendingRunCount == 0 && self.hasDraftToSend
|
||||
!self.isSubmittingDraft && !self.isSending && self.pendingRunCount == 0 && self.hasDraftToSend
|
||||
}
|
||||
|
||||
public var hasDraftToSend: Bool {
|
||||
|
|
@ -458,7 +517,7 @@ public final class OpenClawChatViewModel {
|
|||
}
|
||||
|
||||
public var canSendDraft: Bool {
|
||||
!self.isSending && self.hasDraftToSend
|
||||
!self.isSubmittingDraft && !self.isSending && self.hasDraftToSend
|
||||
}
|
||||
|
||||
// MARK: - Internals
|
||||
|
|
@ -491,11 +550,21 @@ public final class OpenClawChatViewModel {
|
|||
}
|
||||
|
||||
func currentSessionSnapshot() -> SessionSnapshot {
|
||||
SessionSnapshot(key: self.sessionKey, generation: self.sessionGeneration)
|
||||
SessionSnapshot(
|
||||
key: self.sessionKey,
|
||||
generation: self.sessionGeneration,
|
||||
agentID: self.activeAgentId,
|
||||
deliveryAgentID: Self.agentID(fromSessionKey: self.sessionKey) ?? self.activeAgentId,
|
||||
sessionRoutingContract: self.sessionRoutingContract)
|
||||
}
|
||||
|
||||
func isCurrentSession(_ snapshot: SessionSnapshot) -> Bool {
|
||||
self.sessionKey == snapshot.key && self.sessionGeneration == snapshot.generation
|
||||
let contractSensitive = self.usesMutableContractRouting(for: snapshot.sessionRoutingContract) ||
|
||||
self.usesMutableContractRouting(for: self.sessionRoutingContract)
|
||||
return self.sessionKey == snapshot.key &&
|
||||
self.sessionGeneration == snapshot.generation &&
|
||||
(!self.usesMutableAgentRouting || self.activeAgentId == snapshot.agentID) &&
|
||||
(!contractSensitive || self.sessionRoutingContract == snapshot.sessionRoutingContract)
|
||||
}
|
||||
|
||||
private func isCurrentBootstrap(_ context: BootstrapContext) -> Bool {
|
||||
|
|
@ -546,9 +615,9 @@ public final class OpenClawChatViewModel {
|
|||
syncThinkingOptions: Bool = false) -> Bool
|
||||
{
|
||||
guard self.canApplyHistory(request) else { return false }
|
||||
let incoming = self.adoptingProvisionalFinalMessageIDs(
|
||||
let incoming = adoptingProvisionalFinalMessageIDs(
|
||||
in: Self.decodeMessages(payload.messages ?? []))
|
||||
let unmatchedProvisionalFinalIDs = Set(self.provisionalFinalMessagesMissing(from: incoming).map(\.id))
|
||||
let unmatchedProvisionalFinalIDs = Set(provisionalFinalMessagesMissing(from: incoming).map(\.id))
|
||||
var retainedMessageIDs = unmatchedProvisionalFinalIDs
|
||||
if request.historyMutationGeneration != self.historyMutationGeneration {
|
||||
for message in self.messages where request.visibleMessagesByID[message.id] != message {
|
||||
|
|
@ -559,6 +628,10 @@ public final class OpenClawChatViewModel {
|
|||
}
|
||||
}
|
||||
}
|
||||
// Durable outbox rows remain authoritative until canonical history
|
||||
// confirms their idempotency key. Keep their bubbles through lagging
|
||||
// snapshots, including across app relaunches and session switches.
|
||||
retainedMessageIDs.formUnion(self.outboxCommandIDsByMessageID.keys)
|
||||
var nextMessages = if preservingOptimisticLocalMessages {
|
||||
Self.reconcileRunRefreshMessages(
|
||||
previous: self.messages,
|
||||
|
|
@ -567,28 +640,17 @@ public final class OpenClawChatViewModel {
|
|||
} else {
|
||||
Self.reconcileMessageIDs(previous: self.messages, incoming: incoming)
|
||||
}
|
||||
// Ack-to-history window: turns just flushed from the outbox may not
|
||||
// be in this snapshot yet. Their durable rows are gone, so keep the
|
||||
// visible rows (appended: they are the newest turns) until a
|
||||
// snapshot carries their idempotency key.
|
||||
if !self.recentlySentOutboxUserKeys.isEmpty {
|
||||
self.recentlySentOutboxUserKeys.subtract(incoming.compactMap(\.idempotencyKey))
|
||||
let nextKeys = Set(nextMessages.compactMap(\.idempotencyKey))
|
||||
let preserved = self.messages.filter { message in
|
||||
guard let key = message.idempotencyKey else { return false }
|
||||
return self.recentlySentOutboxUserKeys.contains(key) && !nextKeys.contains(key)
|
||||
}
|
||||
nextMessages.append(contentsOf: preserved)
|
||||
}
|
||||
let reconciledMessageIDs = Set(nextMessages.map(\.id))
|
||||
nextMessages.append(contentsOf: self.messages.filter { message in
|
||||
retainedMessageIDs.contains(message.id) && !reconciledMessageIDs.contains(message.id)
|
||||
})
|
||||
self.replaceMessages(Self.dedupeMessages(nextMessages))
|
||||
self.prunePendingLocalUserEchoMessageIDs()
|
||||
self.clearProvisionalFinalMarkersAdoptedByHistory(incoming)
|
||||
self.pruneProvisionalFinalMessages()
|
||||
self.pruneRunMessageScopes()
|
||||
nextMessages = Self.dedupeMessages(nextMessages)
|
||||
replaceMessages(nextMessages)
|
||||
confirmOutboxCommands(in: incoming)
|
||||
prunePendingLocalUserEchoMessageIDs()
|
||||
clearProvisionalFinalMarkersAdoptedByHistory(incoming)
|
||||
pruneProvisionalFinalMessages()
|
||||
pruneRunMessageScopes()
|
||||
self.rescopeRunsAdoptedAfterHistoryRequest(request)
|
||||
self.sessionId = payload.sessionId
|
||||
self.applyInFlightRunSnapshot(payload.inFlightRun, for: request)
|
||||
|
|
@ -609,7 +671,7 @@ public final class OpenClawChatViewModel {
|
|||
self.thinkingLevel = level
|
||||
}
|
||||
if syncThinkingOptions || appliedThinkingLevel != nil {
|
||||
self.syncThinkingLevelOptions()
|
||||
syncThinkingLevelOptions()
|
||||
}
|
||||
// Live history is the source of truth: it clears the cached marker and
|
||||
// is written through so the next cold open pre-paints current rows.
|
||||
|
|
@ -618,15 +680,17 @@ public final class OpenClawChatViewModel {
|
|||
// An empty post-send refresh is incomplete by contract: reconciliation
|
||||
// preserves the visible transcript, so preserve its last canonical cache too.
|
||||
if !preservingOptimisticLocalMessages || !incoming.isEmpty {
|
||||
// Persist the RECONCILED transcript, not raw incoming: a stale
|
||||
// snapshot missing a just-acked turn must not overwrite the
|
||||
// cache after the durable outbox row is already gone — the cache
|
||||
// mirrors what the user sees.
|
||||
self.persistTranscriptToCache(sessionKey: request.session.key, messages: nextMessages)
|
||||
// Persist the reconciled transcript, including durable outbox
|
||||
// rows retained while canonical history catches up.
|
||||
persistTranscriptToCache(
|
||||
sessionKey: request.session.key,
|
||||
agentID: request.session.agentID,
|
||||
messages: nextMessages,
|
||||
canonicalMessageIdempotencyKeys: Set(incoming.compactMap(\.idempotencyKey)))
|
||||
}
|
||||
// Wholesale history replacement drops local-only queued bubbles;
|
||||
// re-adopt or re-append them from the durable outbox.
|
||||
self.restoreOutboxMessages(session: request.session)
|
||||
restoreOutboxMessages(session: request.session)
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
@ -653,7 +717,7 @@ public final class OpenClawChatViewModel {
|
|||
for runId in self.pendingRuns {
|
||||
let scope = self.runMessageScopesByRunID[runId]
|
||||
if !request.pendingRunIDs.contains(runId) || scope?.latestUserTurn == nil {
|
||||
self.runMessageScopesByRunID[runId] = self.currentRunMessageScope()
|
||||
self.runMessageScopesByRunID[runId] = currentRunMessageScope()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -690,7 +754,7 @@ public final class OpenClawChatViewModel {
|
|||
self.updateStreamingAssistantText(nil)
|
||||
}
|
||||
if self.runMessageScopesByRunID[runId] == nil {
|
||||
self.runMessageScopesByRunID[runId] = self.currentRunMessageScope()
|
||||
self.runMessageScopesByRunID[runId] = currentRunMessageScope()
|
||||
}
|
||||
self.armPendingRunTimeout(runId: runId)
|
||||
if !bufferedText.isEmpty {
|
||||
|
|
@ -717,8 +781,8 @@ public final class OpenClawChatViewModel {
|
|||
let context = BootstrapContext(
|
||||
id: bootstrapGeneration,
|
||||
historyRequest: historyRequest)
|
||||
self.paintFromCacheIfNeeded(session: context.session)
|
||||
self.restoreOutboxMessages(session: context.session)
|
||||
paintFromCacheIfNeeded(session: context.session)
|
||||
restoreOutboxMessages(session: context.session)
|
||||
self.bootstrapTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.bootstrap(context: context)
|
||||
|
|
@ -743,7 +807,7 @@ public final class OpenClawChatViewModel {
|
|||
for: context.historyRequest,
|
||||
preservingOptimisticLocalMessages: false,
|
||||
syncThinkingOptions: true)
|
||||
await self.pollHealthIfNeeded(force: true, sessionSnapshot: context.session)
|
||||
await pollHealthIfNeeded(force: true, sessionSnapshot: context.session)
|
||||
guard self.isCurrentBootstrap(context) else { return }
|
||||
await self.fetchSessions(limit: 50, sessionSnapshot: context.session)
|
||||
guard self.isCurrentBootstrap(context) else { return }
|
||||
|
|
@ -784,7 +848,7 @@ public final class OpenClawChatViewModel {
|
|||
self.logDiagnostic(
|
||||
"chat.ui foreground refresh sessionKey=\(context.session.key) "
|
||||
+ "pending=\(self.pendingRunCount)")
|
||||
let refresh = await self.refreshHistoryAfterRun(historyRequest: context)
|
||||
let refresh = await refreshHistoryAfterRun(historyRequest: context)
|
||||
guard self.isCurrentSession(context.session) else { return }
|
||||
if refresh.applied,
|
||||
refresh.runSnapshotApplied,
|
||||
|
|
@ -799,7 +863,7 @@ public final class OpenClawChatViewModel {
|
|||
self.pendingToolCallsById = [:]
|
||||
self.updateStreamingAssistantText(nil)
|
||||
}
|
||||
await self.pollHealthIfNeeded(force: true, sessionSnapshot: context.session)
|
||||
await pollHealthIfNeeded(force: true, sessionSnapshot: context.session)
|
||||
}
|
||||
|
||||
private static let resetTriggers: Set<String> = ["/reset", "/clear"]
|
||||
|
|
@ -814,7 +878,7 @@ public final class OpenClawChatViewModel {
|
|||
defer { self.isLoadingSlashCommands = false }
|
||||
|
||||
do {
|
||||
let commands = try await self.transport.listCommands(sessionKey: sessionSnapshot.key)
|
||||
let commands = try await transport.listCommands(sessionKey: sessionSnapshot.key)
|
||||
guard self.isCurrentSession(sessionSnapshot) else { return }
|
||||
self.slashCommands = commands
|
||||
self.slashFilterCache = nil
|
||||
|
|
@ -841,7 +905,7 @@ public final class OpenClawChatViewModel {
|
|||
}
|
||||
}
|
||||
|
||||
private func validateSlashCommandDraftForSend(trimmed: String) async -> Bool {
|
||||
private func validateSlashCommandDraftForSend(trimmed: String, hasAttachments: Bool) async -> Bool {
|
||||
guard let slashName = Self.slashCommandName(from: trimmed) else {
|
||||
return true
|
||||
}
|
||||
|
|
@ -854,7 +918,7 @@ public final class OpenClawChatViewModel {
|
|||
|
||||
if self.hasLoadedSlashCommands,
|
||||
Self.isKnownSlashCommandText(trimmed, commands: self.slashCommands),
|
||||
!self.attachments.isEmpty
|
||||
hasAttachments
|
||||
{
|
||||
self.errorText = "Commands cannot be sent with attachments."
|
||||
return false
|
||||
|
|
@ -884,7 +948,7 @@ public final class OpenClawChatViewModel {
|
|||
_ text: String,
|
||||
commands: [OpenClawChatCommandChoice]) -> Bool
|
||||
{
|
||||
guard let commandName = self.slashCommandName(from: text), !commandName.isEmpty else {
|
||||
guard let commandName = slashCommandName(from: text), !commandName.isEmpty else {
|
||||
return false
|
||||
}
|
||||
if self.commands(commands, containInvocationName: commandName) {
|
||||
|
|
@ -1003,27 +1067,44 @@ public final class OpenClawChatViewModel {
|
|||
return nil
|
||||
}
|
||||
|
||||
private func handleLocalSlashCommandIfNeeded(_ command: String) async -> Bool {
|
||||
private func handleLocalSlashCommandIfNeeded(_ command: String, draftInput: String) async -> Bool {
|
||||
if command == "/new" {
|
||||
self.input = ""
|
||||
await self.startNewSession()
|
||||
if self.input == draftInput { self.input = "" }
|
||||
await self.performStartNewSession(worktree: false)
|
||||
return true
|
||||
}
|
||||
if Self.resetTriggers.contains(command) {
|
||||
self.input = ""
|
||||
if self.input == draftInput { self.input = "" }
|
||||
await self.performReset()
|
||||
return true
|
||||
}
|
||||
if Self.compactTriggers.contains(command) {
|
||||
self.input = ""
|
||||
if self.input == draftInput { self.input = "" }
|
||||
await self.performCompact()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private static func isLiveOnlyLocalSlashCommand(_ command: String) -> Bool {
|
||||
command == "/new" || self.resetTriggers.contains(command) || self.compactTriggers.contains(command)
|
||||
}
|
||||
|
||||
private func prepareLiveOnlyLocalSlashCommand(session: SessionSnapshot) async -> Bool {
|
||||
// Always probe: a preserved view model can retain stale healthy state
|
||||
// after its transport disconnects without a health event. performSend
|
||||
// owns the send gate across this await.
|
||||
await pollHealthIfNeeded(force: true, sessionSnapshot: session)
|
||||
guard self.isCurrentSession(session) else { return false }
|
||||
guard self.healthOK else {
|
||||
self.errorText = "Connect to the gateway to run this command."
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func performSend() async {
|
||||
guard !self.isSending else {
|
||||
guard !self.isSubmittingDraft, !self.isSending else {
|
||||
self.logDiagnostic("chat.ui send ignored reason=sending sessionKey=\(self.sessionKey)")
|
||||
return
|
||||
}
|
||||
|
|
@ -1033,39 +1114,53 @@ public final class OpenClawChatViewModel {
|
|||
+ "pending=\(self.pendingRunCount)")
|
||||
return
|
||||
}
|
||||
let trimmed = self.input.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty || !self.attachments.isEmpty else {
|
||||
let draftInput = self.input
|
||||
let draftAttachments = self.attachments
|
||||
let trimmed = draftInput.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty || !draftAttachments.isEmpty else {
|
||||
self.logDiagnostic("chat.ui send ignored reason=empty sessionKey=\(self.sessionKey)")
|
||||
return
|
||||
}
|
||||
|
||||
// Own every asynchronous validation/probe below. Slash catalog lookup
|
||||
// can suspend, so taking this gate later permits duplicate enqueues.
|
||||
// Keep it separate from isSending: local /compact checks that flag.
|
||||
self.isSubmittingDraft = true
|
||||
defer { self.isSubmittingDraft = false }
|
||||
|
||||
let command = trimmed.lowercased()
|
||||
if await self.handleLocalSlashCommandIfNeeded(command) {
|
||||
return
|
||||
}
|
||||
guard await self.validateSlashCommandDraftForSend(trimmed: trimmed) else {
|
||||
return
|
||||
}
|
||||
|
||||
let sessionSnapshot = self.currentSessionSnapshot()
|
||||
let sessionKey = sessionSnapshot.key
|
||||
if Self.isLiveOnlyLocalSlashCommand(command) {
|
||||
let canRunCommand = await prepareLiveOnlyLocalSlashCommand(session: sessionSnapshot)
|
||||
guard canRunCommand else { return }
|
||||
}
|
||||
if await self.handleLocalSlashCommandIfNeeded(command, draftInput: draftInput) {
|
||||
return
|
||||
}
|
||||
guard await self.validateSlashCommandDraftForSend(
|
||||
trimmed: trimmed,
|
||||
hasAttachments: !draftAttachments.isEmpty)
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
// Raised before the health poll so the entry guard covers the whole
|
||||
// async path: a second submit during `pollHealthIfNeeded` would
|
||||
// otherwise re-capture the same draft and enqueue a duplicate
|
||||
// outbox row.
|
||||
self.isSending = true
|
||||
defer { self.isSending = false }
|
||||
|
||||
let sessionKey = sessionSnapshot.key
|
||||
|
||||
if !self.healthOK {
|
||||
await self.pollHealthIfNeeded(force: true, sessionSnapshot: sessionSnapshot)
|
||||
await pollHealthIfNeeded(force: true, sessionSnapshot: sessionSnapshot)
|
||||
guard self.isCurrentSession(sessionSnapshot) else { return }
|
||||
// Offline capture: queue text-only sends durably instead of
|
||||
// failing. Attachments stay on the live path (text only in v1).
|
||||
if !self.healthOK, self.outbox != nil, self.attachments.isEmpty {
|
||||
if !self.healthOK, self.outbox != nil, draftAttachments.isEmpty {
|
||||
self.logDiagnostic(
|
||||
"chat.ui send queued offline sessionKey=\(sessionKey) inputLen=\(trimmed.count)")
|
||||
await self.enqueueOutboxCommand(text: trimmed, session: sessionSnapshot)
|
||||
await enqueueOutboxCommand(
|
||||
text: trimmed,
|
||||
draftInput: draftInput,
|
||||
session: sessionSnapshot)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
|
@ -1081,19 +1176,22 @@ public final class OpenClawChatViewModel {
|
|||
// documented as outside the ordering guarantee. Deliberately
|
||||
// session-scoped: other sessions' queued rows are separate
|
||||
// conversations with no ordering contract against this send.
|
||||
if self.outbox != nil, self.attachments.isEmpty,
|
||||
if self.outbox != nil, draftAttachments.isEmpty,
|
||||
!self.hasRestoredOutboxMessages
|
||||
|| self.outboxStatesByMessageID.values.contains(where: { !$0.isFailed })
|
||||
{
|
||||
self.logDiagnostic(
|
||||
"chat.ui send routed behind outbox sessionKey=\(sessionKey) inputLen=\(trimmed.count)")
|
||||
await self.enqueueOutboxCommand(text: trimmed, session: sessionSnapshot)
|
||||
await enqueueOutboxCommand(
|
||||
text: trimmed,
|
||||
draftInput: draftInput,
|
||||
session: sessionSnapshot)
|
||||
return
|
||||
}
|
||||
|
||||
self.errorText = nil
|
||||
let runId = UUID().uuidString
|
||||
let messageText = trimmed.isEmpty && !self.attachments.isEmpty ? "See attached." : trimmed
|
||||
let messageText = trimmed.isEmpty && !draftAttachments.isEmpty ? "See attached." : trimmed
|
||||
let storedThinkingLevel = self.thinkingLevel
|
||||
self.pendingRuns.insert(runId)
|
||||
self.armPendingRunTimeout(runId: runId)
|
||||
|
|
@ -1117,7 +1215,7 @@ public final class OpenClawChatViewModel {
|
|||
name: nil,
|
||||
arguments: nil),
|
||||
]
|
||||
let encodedAttachments = self.attachments.map { att -> OpenClawChatAttachmentPayload in
|
||||
let encodedAttachments = draftAttachments.map { att -> OpenClawChatAttachmentPayload in
|
||||
OpenClawChatAttachmentPayload(
|
||||
type: att.type,
|
||||
mimeType: att.mimeType,
|
||||
|
|
@ -1148,11 +1246,12 @@ public final class OpenClawChatViewModel {
|
|||
timestamp: userMessageTimestamp,
|
||||
idempotencyKey: "\(runId):user"))
|
||||
self.pendingLocalUserEchoMessageIDsByRunID[runId] = userMessageID
|
||||
self.runMessageScopesByRunID[runId] = self.currentRunMessageScope()
|
||||
self.runMessageScopesByRunID[runId] = currentRunMessageScope()
|
||||
|
||||
// Clear input immediately for responsive UX (before network await)
|
||||
self.input = ""
|
||||
self.attachments = []
|
||||
if self.input == draftInput { self.input = "" }
|
||||
let sentAttachmentIDs = Set(draftAttachments.map(\.id))
|
||||
self.attachments.removeAll { sentAttachmentIDs.contains($0.id) }
|
||||
|
||||
do {
|
||||
await self.waitForPendingModelPatches(in: sessionKey)
|
||||
|
|
@ -1163,6 +1262,8 @@ public final class OpenClawChatViewModel {
|
|||
let thinkingLevel = self.effectiveThinkingLevelForSend(storedThinkingLevel)
|
||||
let response = try await transport.sendMessage(
|
||||
sessionKey: sessionKey,
|
||||
agentID: sessionSnapshot.deliveryAgentID,
|
||||
expectedSessionRoutingContract: sessionSnapshot.sessionRoutingContract,
|
||||
message: messageText,
|
||||
thinking: thinkingLevel,
|
||||
idempotencyKey: runId,
|
||||
|
|
@ -1183,14 +1284,14 @@ public final class OpenClawChatViewModel {
|
|||
// The gateway can reuse an identical active run without writing
|
||||
// this second turn. Move the optimistic row onto that durable
|
||||
// identity, collapsing it if the canonical row is already here.
|
||||
let rekeyedUserEcho = self.rekeyLocalUserEcho(
|
||||
let rekeyedUserEcho = rekeyLocalUserEcho(
|
||||
messageID: pendingUserMessageID,
|
||||
runId: response.runId)
|
||||
self.pendingLocalUserEchoMessageIDsByRunID[response.runId] = rekeyedUserEcho?.pendingMessageID
|
||||
let remoteRunScope = rekeyedUserEcho?.scope ?? localRunScope ?? self.currentRunMessageScope()
|
||||
let remoteRunScope = rekeyedUserEcho?.scope ?? localRunScope ?? currentRunMessageScope()
|
||||
self.runMessageScopesByRunID[response.runId] = remoteRunScope
|
||||
self.rescopeProvisionalFinalMessages(runId: response.runId, scope: remoteRunScope)
|
||||
reusedRunAlreadyFinal = self.hasRecordedFinalMessage(runId: response.runId)
|
||||
rescopeProvisionalFinalMessages(runId: response.runId, scope: remoteRunScope)
|
||||
reusedRunAlreadyFinal = hasRecordedFinalMessage(runId: response.runId)
|
||||
if reusedRunAlreadyFinal {
|
||||
self.clearPendingRun(response.runId, hapticEvent: .runCompleted)
|
||||
self.pendingToolCallsById = [:]
|
||||
|
|
@ -1208,7 +1309,7 @@ public final class OpenClawChatViewModel {
|
|||
!reusedRunAlreadyFinal
|
||||
{
|
||||
let historyContext = self.beginHistoryRequest(for: sessionSnapshot)
|
||||
let refresh = await self.refreshHistoryAfterRun(historyRequest: historyContext)
|
||||
let refresh = await refreshHistoryAfterRun(historyRequest: historyContext)
|
||||
guard self.isCurrentSession(sessionSnapshot) else { return }
|
||||
let hasInFlightRunSnapshot = refresh.applied &&
|
||||
refresh.runSnapshotApplied &&
|
||||
|
|
@ -1231,31 +1332,37 @@ public final class OpenClawChatViewModel {
|
|||
} catch {
|
||||
guard self.isCurrentSession(sessionSnapshot) else { return }
|
||||
// Stale-healthy disconnects surface here instead of at the send
|
||||
// gate. Requeue text-only sends durably (same runId = same
|
||||
// idempotency identity, safe even if the send actually landed)
|
||||
// and keep the optimistic bubble as the queued row.
|
||||
if encodedAttachments.isEmpty {
|
||||
// gate. A route rejection before dispatch stays auto-retryable;
|
||||
// every ambiguous failure remains fail-closed.
|
||||
if encodedAttachments.isEmpty, !(error is GatewayResponseError) {
|
||||
self.runMessageScopesByRunID.removeValue(forKey: runId)
|
||||
self.clearPendingRun(runId)
|
||||
let requeued = await self.requeueFailedLiveSend(
|
||||
let deliveryIsAmbiguous = !(error is OpenClawChatTransportSendError)
|
||||
let preserved = await preserveFailedLiveSend(
|
||||
runId: runId,
|
||||
text: messageText,
|
||||
thinking: self.effectiveThinkingLevelForSend(storedThinkingLevel),
|
||||
messageID: userMessageID,
|
||||
session: sessionSnapshot)
|
||||
if requeued {
|
||||
session: sessionSnapshot,
|
||||
deliveryIsAmbiguous: deliveryIsAmbiguous)
|
||||
if preserved {
|
||||
self.applyTransportHealth(false)
|
||||
let outcome = deliveryIsAmbiguous ? "delivery unconfirmed" : "queued after route change"
|
||||
self.logDiagnostic(
|
||||
"chat.ui send requeued offline sessionKey=\(sessionKey) "
|
||||
"chat.ui send \(outcome) sessionKey=\(sessionKey) "
|
||||
+ "localRunId=\(runId) error=\(error.localizedDescription)")
|
||||
return
|
||||
}
|
||||
guard self.isCurrentSession(sessionSnapshot) else { return }
|
||||
// Refused requeue (queue full / broken store): restore the
|
||||
// Refused persistence (queue full / broken store): restore the
|
||||
// draft so the text is not lost with the failed bubble.
|
||||
if self.input.isEmpty {
|
||||
self.input = messageText
|
||||
}
|
||||
}
|
||||
if encodedAttachments.isEmpty, self.input.isEmpty {
|
||||
self.input = messageText
|
||||
}
|
||||
self.removePendingLocalUserEcho(for: runId)
|
||||
self.runMessageScopesByRunID.removeValue(forKey: runId)
|
||||
self.errorText = error.localizedDescription
|
||||
|
|
@ -1291,8 +1398,8 @@ public final class OpenClawChatViewModel {
|
|||
self.sessionDefaults = res.defaults
|
||||
self.hasAppliedLiveSessions = true
|
||||
self.syncSelectedModel()
|
||||
self.syncThinkingLevelOptions()
|
||||
self.persistSessionsToCache(res.sessions)
|
||||
syncThinkingLevelOptions()
|
||||
persistSessionsToCache(res.sessions)
|
||||
} catch {
|
||||
// Best-effort.
|
||||
}
|
||||
|
|
@ -1356,14 +1463,13 @@ public final class OpenClawChatViewModel {
|
|||
/// Clears state owned by the current session/agent before a new identity can consume events.
|
||||
private func clearSessionOwnedState() {
|
||||
self.modelSelectionID = Self.defaultModelSelectionID
|
||||
self.replaceMessages([])
|
||||
replaceMessages([])
|
||||
self.isShowingCachedTranscript = false
|
||||
self.hasAppliedLiveHistory = false
|
||||
self.pendingLocalUserEchoMessageIDsByRunID.removeAll()
|
||||
self.runMessageScopesByRunID.removeAll()
|
||||
self.provisionalFinalMessagesByID.removeAll()
|
||||
self.recentlySentOutboxUserKeys.removeAll()
|
||||
self.resetOutboxPresentationForSessionSwitch()
|
||||
resetOutboxPresentationForSessionSwitch()
|
||||
self.sessionId = nil
|
||||
self.pendingToolCallsById = [:]
|
||||
self.updateStreamingAssistantText(nil)
|
||||
|
|
@ -1437,7 +1543,7 @@ public final class OpenClawChatViewModel {
|
|||
|
||||
let sessionKey = self.sessionKey
|
||||
self.thinkingLevel = next
|
||||
self.syncThinkingLevelOptions()
|
||||
syncThinkingLevelOptions()
|
||||
self.updateCurrentSessionThinkingLevel(next, sessionKey: sessionKey)
|
||||
self.onThinkingLevelChanged?(next)
|
||||
self.nextThinkingSelectionRequestID &+= 1
|
||||
|
|
@ -1618,7 +1724,7 @@ public final class OpenClawChatViewModel {
|
|||
private func generatedNewSessionKey() -> String {
|
||||
let baseKey = "ios-\(UUID().uuidString.lowercased())"
|
||||
guard let agentID = Self.agentID(fromSessionKey: sessionKey) ??
|
||||
self.activeAgentId ??
|
||||
activeAgentId ??
|
||||
Self.agentID(fromSessionKey: resolvedMainSessionKey) ??
|
||||
sessions.lazy.compactMap({ Self.agentID(fromSessionKey: $0.key) }).first
|
||||
else {
|
||||
|
|
@ -1627,7 +1733,7 @@ public final class OpenClawChatViewModel {
|
|||
return "agent:\(agentID):\(baseKey)"
|
||||
}
|
||||
|
||||
private static func agentID(fromSessionKey sessionKey: String) -> String? {
|
||||
static func agentID(fromSessionKey sessionKey: String) -> String? {
|
||||
let parts = sessionKey
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.split(separator: ":", omittingEmptySubsequences: false)
|
||||
|
|
@ -1650,7 +1756,7 @@ public final class OpenClawChatViewModel {
|
|||
sessionKey: sessionKey,
|
||||
syncSelection: syncSelection)
|
||||
if sessionKey == self.sessionKey {
|
||||
self.syncThinkingLevelOptions()
|
||||
syncThinkingLevelOptions()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1795,7 +1901,7 @@ public final class OpenClawChatViewModel {
|
|||
private func handleTransportEvent(_ evt: OpenClawChatTransportEvent) {
|
||||
switch evt {
|
||||
case let .health(ok):
|
||||
self.applyTransportHealth(ok)
|
||||
applyTransportHealth(ok)
|
||||
case .tick:
|
||||
let context = self.currentSessionSnapshot()
|
||||
Task { await self.pollHealthIfNeeded(force: false, sessionSnapshot: context) }
|
||||
|
|
@ -1821,15 +1927,17 @@ public final class OpenClawChatViewModel {
|
|||
}
|
||||
|
||||
private func handleSessionMessageEvent(_ payload: OpenClawSessionMessageEventPayload) {
|
||||
if let sessionKey = payload.sessionKey,
|
||||
!self.matchesCurrentSessionKey(incoming: sessionKey, agentId: payload.agentId, current: self.sessionKey)
|
||||
{
|
||||
return
|
||||
}
|
||||
|
||||
guard let message = payload.message else { return }
|
||||
|
||||
let sanitized = Self.stripInboundMetadata(from: message)
|
||||
let isCurrentSession = payload.sessionKey.map {
|
||||
self.matchesCurrentSessionKey(incoming: $0, agentId: payload.agentId, current: self.sessionKey)
|
||||
} ?? true
|
||||
// Confirmation is gateway-scoped, not presentation-scoped. A flush
|
||||
// can drain session A while session B is visible, and A's event must
|
||||
// still retire its durable row before this handler returns early.
|
||||
confirmOutboxCommands(in: [sanitized])
|
||||
guard isCurrentSession else { return }
|
||||
|
||||
self.invalidateHistorySnapshots()
|
||||
// The active client also receives the gateway's echo of the user turn it
|
||||
// just sent. performSend already appended an optimistic row carrying a
|
||||
|
|
@ -1838,17 +1946,17 @@ public final class OpenClawChatViewModel {
|
|||
// Adopt the server record onto the exactly correlated row even when the
|
||||
// run's final event already cleared pending state. Same-content turns
|
||||
// without this key remain distinct.
|
||||
if self.adoptCorrelatedUserMessage(incoming: sanitized) {
|
||||
if adoptCorrelatedUserMessage(incoming: sanitized) {
|
||||
return
|
||||
}
|
||||
if self.adoptProvisionalFinalMessage(incoming: sanitized) {
|
||||
if adoptProvisionalFinalMessage(incoming: sanitized) {
|
||||
return
|
||||
}
|
||||
|
||||
let reconciled = Self.reconcileMessageIDs(previous: self.messages, incoming: self.messages + [sanitized])
|
||||
self.replaceMessages(Self.dedupeMessages(reconciled))
|
||||
self.pruneProvisionalFinalMessages()
|
||||
self.pruneRunMessageScopes()
|
||||
replaceMessages(Self.dedupeMessages(reconciled))
|
||||
pruneProvisionalFinalMessages()
|
||||
pruneRunMessageScopes()
|
||||
}
|
||||
|
||||
private func handleChatEvent(_ chat: OpenClawChatEventPayload) {
|
||||
|
|
@ -1960,14 +2068,14 @@ public final class OpenClawChatViewModel {
|
|||
}
|
||||
|
||||
let runId = Self.normalizedRunID(chat.runId)
|
||||
let scope = self.runMessageScope(for: runId)
|
||||
let scope = runMessageScope(for: runId)
|
||||
guard self.isCurrentSession(scope.session) else { return }
|
||||
guard let reconciliationKey = Self.finalMessageReconciliationKey(for: message) else { return }
|
||||
if let runId, self.hasRecordedFinalMessage(runId: runId) {
|
||||
if let runId, hasRecordedFinalMessage(runId: runId) {
|
||||
return
|
||||
}
|
||||
|
||||
if self.hasCanonicalFinalMessageMatching(message, scope: scope) {
|
||||
if hasCanonicalFinalMessageMatching(message, scope: scope) {
|
||||
if let runId {
|
||||
self.runMessageScopesByRunID.removeValue(forKey: runId)
|
||||
}
|
||||
|
|
@ -1975,15 +2083,15 @@ public final class OpenClawChatViewModel {
|
|||
}
|
||||
|
||||
let reconciled = Self.reconcileMessageIDs(previous: self.messages, incoming: self.messages + [message])
|
||||
self.replaceMessages(Self.dedupeMessages(reconciled))
|
||||
replaceMessages(Self.dedupeMessages(reconciled))
|
||||
if self.messages.contains(where: { $0.id == message.id }) {
|
||||
self.provisionalFinalMessagesByID[message.id] = ProvisionalFinalMessage(
|
||||
reconciliationKey: reconciliationKey,
|
||||
runId: runId,
|
||||
scope: scope)
|
||||
}
|
||||
self.pruneProvisionalFinalMessages()
|
||||
self.pruneRunMessageScopes()
|
||||
pruneProvisionalFinalMessages()
|
||||
pruneRunMessageScopes()
|
||||
}
|
||||
|
||||
static func isAssistantMessage(_ message: OpenClawChatMessage) -> Bool {
|
||||
|
|
@ -2199,7 +2307,7 @@ public final class OpenClawChatViewModel {
|
|||
}
|
||||
self.logDiagnostic(diagnostic)
|
||||
let historyContext = self.beginHistoryRequest(for: sessionSnapshot)
|
||||
let refresh = await self.refreshHistoryAfterRun(historyRequest: historyContext)
|
||||
let refresh = await refreshHistoryAfterRun(historyRequest: historyContext)
|
||||
guard self.isCurrentSession(sessionSnapshot),
|
||||
self.pendingRuns.contains(runId)
|
||||
else {
|
||||
|
|
@ -2219,7 +2327,7 @@ public final class OpenClawChatViewModel {
|
|||
|
||||
@discardableResult
|
||||
private func clearPendingRunIfAssistantMessagePresent(runId: String, after timestamp: Double) -> Bool {
|
||||
guard let hapticEvent = self.assistantHapticEvent(after: timestamp) else { return false }
|
||||
guard let hapticEvent = assistantHapticEvent(after: timestamp) else { return false }
|
||||
self.clearPendingRun(runId, hapticEvent: hapticEvent)
|
||||
self.pendingToolCallsById = [:]
|
||||
self.updateStreamingAssistantText(nil)
|
||||
|
|
@ -2248,7 +2356,7 @@ public final class OpenClawChatViewModel {
|
|||
}
|
||||
guard let refreshKey = userRefreshIdentityKey(for: messages[userIndex]) else {
|
||||
return LatestUserTurn(
|
||||
idempotencyKey: self.normalizedIdempotencyKey(messages[userIndex].idempotencyKey),
|
||||
idempotencyKey: normalizedIdempotencyKey(messages[userIndex].idempotencyKey),
|
||||
refreshKey: nil,
|
||||
occurrence: 0,
|
||||
timestamp: messages[userIndex].timestamp)
|
||||
|
|
@ -2284,7 +2392,7 @@ public final class OpenClawChatViewModel {
|
|||
var occurrence = 0
|
||||
var latestMatchingUserIndex: [OpenClawChatMessage].Index?
|
||||
for (index, message) in messages.enumerated() {
|
||||
guard self.userRefreshIdentityKey(for: message) == refreshKey else { continue }
|
||||
guard userRefreshIdentityKey(for: message) == refreshKey else { continue }
|
||||
occurrence += 1
|
||||
latestMatchingUserIndex = index
|
||||
guard occurrence == user.occurrence else { continue }
|
||||
|
|
@ -2347,7 +2455,7 @@ public final class OpenClawChatViewModel {
|
|||
}
|
||||
|
||||
private func assistantHapticEventAfterLatestUser() -> OpenClawChatHaptics.Event? {
|
||||
guard let userIndex = self.messages.lastIndex(where: { $0.role.lowercased() == "user" }) else { return nil }
|
||||
guard let userIndex = messages.lastIndex(where: { $0.role.lowercased() == "user" }) else { return nil }
|
||||
let nextIndex = self.messages.index(after: userIndex)
|
||||
guard nextIndex < self.messages.endIndex else { return nil }
|
||||
return self.messages[nextIndex...].reversed().lazy.compactMap(Self.assistantHapticEvent).first
|
||||
|
|
@ -2360,10 +2468,49 @@ public final class OpenClawChatViewModel {
|
|||
}.first
|
||||
}
|
||||
|
||||
/// Narrow seam for the outbox extension: pull durable history after a
|
||||
/// flush so acked commands reconcile with their queued bubbles.
|
||||
func refreshHistoryAfterOutboxFlush() async {
|
||||
await self.refreshHistoryAfterRun()
|
||||
/// Pull canonical history for every session touched by one route-bound
|
||||
/// outbox pass. Background sessions only retire confirmed rows; the
|
||||
/// visible session also runs the normal reconciliation/cache pipeline.
|
||||
func refreshHistoriesAfterOutboxFlush(
|
||||
targets: Set<OutboxDeliveryTarget>,
|
||||
routeLease: OpenClawChatTransportRouteLease) async
|
||||
{
|
||||
let sortedTargets = targets.sorted { lhs, rhs in
|
||||
if lhs.deliverySessionKey != rhs.deliverySessionKey {
|
||||
return lhs.deliverySessionKey < rhs.deliverySessionKey
|
||||
}
|
||||
return (lhs.agentID ?? "") < (rhs.agentID ?? "")
|
||||
}
|
||||
for target in sortedTargets {
|
||||
let visibleRequest = matchesCurrentSessionKey(
|
||||
incoming: target.presentationSessionKey,
|
||||
agentId: target.agentID,
|
||||
current: self.sessionKey)
|
||||
? self.beginHistoryRequest()
|
||||
: nil
|
||||
do {
|
||||
let payload = try await routeLease.requestHistory(
|
||||
sessionKey: target.deliverySessionKey,
|
||||
agentID: target.agentID)
|
||||
let incoming = Self.decodeMessages(payload.messages ?? [])
|
||||
await confirmOutboxCommandsNow(in: incoming)
|
||||
if let visibleRequest {
|
||||
_ = self.applyHistoryPayload(
|
||||
payload,
|
||||
for: visibleRequest,
|
||||
preservingOptimisticLocalMessages: true)
|
||||
}
|
||||
} catch is CancellationError {
|
||||
// The gateway route changed during confirmation. Keep every
|
||||
// unconfirmed row durable for a later matching reconnect.
|
||||
applyTransportHealth(false)
|
||||
return
|
||||
} catch {
|
||||
self.logDiagnostic(
|
||||
"chat.ui outbox history failed sessionKey=\(target.deliverySessionKey) "
|
||||
+ "error=\(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
|
|
|
|||
|
|
@ -48,6 +48,12 @@ public struct GatewayNodeSessionRoute: Sendable, Equatable {
|
|||
fileprivate let channelGeneration: UInt64
|
||||
}
|
||||
|
||||
/// A route lease became stale before its request touched the channel. Unlike
|
||||
/// a socket cancellation, this proves the payload was never dispatched.
|
||||
public enum GatewayNodeSessionRequestError: Error, Sendable {
|
||||
case routeChangedBeforeDispatch
|
||||
}
|
||||
|
||||
public actor GatewayNodeSession {
|
||||
private let logger = Logger(subsystem: "ai.openclaw", category: "node.gateway")
|
||||
private let decoder = JSONDecoder()
|
||||
|
|
@ -68,6 +74,7 @@ public actor GatewayNodeSession {
|
|||
private var hasEverConnected = false
|
||||
private var hasNotifiedConnected = false
|
||||
private var snapshotReceived = false
|
||||
private var serverCapabilities: Set<GatewayServerCapability>?
|
||||
private var snapshotWaiters: [CheckedContinuation<Bool, Never>] = []
|
||||
|
||||
static func invokeWithTimeout(
|
||||
|
|
@ -267,7 +274,7 @@ public actor GatewayNodeSession {
|
|||
channelGeneration = self.channelGeneration
|
||||
}
|
||||
|
||||
guard let channel = self.channel else {
|
||||
guard let channel else {
|
||||
throw NSError(domain: "Gateway", code: 0, userInfo: [
|
||||
NSLocalizedDescriptionKey: "gateway channel unavailable",
|
||||
])
|
||||
|
|
@ -314,7 +321,7 @@ public actor GatewayNodeSession {
|
|||
|
||||
@discardableResult
|
||||
public func refreshPluginSurfaceUrl(surface: String, timeoutSeconds: Int = 8) async -> String? {
|
||||
guard let channel = self.channel else { return nil }
|
||||
guard let channel else { return nil }
|
||||
let trimmedSurface = surface.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedSurface.isEmpty else { return nil }
|
||||
|
||||
|
|
@ -332,7 +339,7 @@ public actor GatewayNodeSession {
|
|||
}
|
||||
|
||||
public func currentRemoteAddress() -> String? {
|
||||
guard let url = self.activeURL else { return nil }
|
||||
guard let url = activeURL else { return nil }
|
||||
guard let host = url.host else { return url.absoluteString }
|
||||
let port = url.port ?? (url.scheme == "wss" ? 443 : 80)
|
||||
if host.contains(":") {
|
||||
|
|
@ -341,11 +348,28 @@ public actor GatewayNodeSession {
|
|||
return "\(host):\(port)"
|
||||
}
|
||||
|
||||
public func currentRoute() -> GatewayNodeSessionRoute? {
|
||||
public func currentRoute(ifGatewayID expectedGatewayID: String? = nil) -> GatewayNodeSessionRoute? {
|
||||
guard self.channel != nil else { return nil }
|
||||
if let expectedGatewayID {
|
||||
let expected = expectedGatewayID.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let current = self.connectOptions?.deviceAuthGatewayID?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !expected.isEmpty, current == expected else { return nil }
|
||||
}
|
||||
return GatewayNodeSessionRoute(channelGeneration: self.channelGeneration)
|
||||
}
|
||||
|
||||
public func supportsServerCapability(
|
||||
_ capability: GatewayServerCapability,
|
||||
ifCurrentRoute expectedRoute: GatewayNodeSessionRoute) -> Bool?
|
||||
{
|
||||
guard expectedRoute.channelGeneration == self.channelGeneration,
|
||||
self.channel != nil,
|
||||
let serverCapabilities
|
||||
else { return nil }
|
||||
return serverCapabilities.contains(capability)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func sendEvent(
|
||||
event: String,
|
||||
|
|
@ -355,7 +379,7 @@ public actor GatewayNodeSession {
|
|||
if let expectedRoute, expectedRoute.channelGeneration != self.channelGeneration {
|
||||
return false
|
||||
}
|
||||
guard let channel = self.channel else { return false }
|
||||
guard let channel else { return false }
|
||||
let params: [String: AnyCodable] = [
|
||||
"event": AnyCodable(event),
|
||||
"payloadJSON": AnyCodable(payloadJSON ?? NSNull()),
|
||||
|
|
@ -370,13 +394,13 @@ public actor GatewayNodeSession {
|
|||
}
|
||||
|
||||
public func send(method: String, paramsJSON: String?) async throws {
|
||||
guard let channel = self.channel else {
|
||||
guard let channel else {
|
||||
throw NSError(domain: "Gateway", code: 11, userInfo: [
|
||||
NSLocalizedDescriptionKey: "not connected",
|
||||
])
|
||||
}
|
||||
|
||||
let params = try self.decodeParamsJSON(paramsJSON)
|
||||
let params = try decodeParamsJSON(paramsJSON)
|
||||
try await channel.send(method: method, params: params)
|
||||
}
|
||||
|
||||
|
|
@ -384,18 +408,22 @@ public actor GatewayNodeSession {
|
|||
method: String,
|
||||
paramsJSON: String?,
|
||||
timeoutSeconds: Int = 15,
|
||||
ifCurrentRoute expectedRoute: GatewayNodeSessionRoute? = nil) async throws -> Data
|
||||
ifCurrentRoute expectedRoute: GatewayNodeSessionRoute? = nil,
|
||||
distinguishPreDispatchRouteChange: Bool = false) async throws -> Data
|
||||
{
|
||||
if let expectedRoute, expectedRoute.channelGeneration != self.channelGeneration {
|
||||
if distinguishPreDispatchRouteChange {
|
||||
throw GatewayNodeSessionRequestError.routeChangedBeforeDispatch
|
||||
}
|
||||
throw CancellationError()
|
||||
}
|
||||
guard let channel = self.channel else {
|
||||
guard let channel else {
|
||||
throw NSError(domain: "Gateway", code: 11, userInfo: [
|
||||
NSLocalizedDescriptionKey: "not connected",
|
||||
])
|
||||
}
|
||||
|
||||
let params = try self.decodeParamsJSON(paramsJSON)
|
||||
let params = try decodeParamsJSON(paramsJSON)
|
||||
return try await channel.request(
|
||||
method: method,
|
||||
params: params,
|
||||
|
|
@ -418,6 +446,8 @@ public actor GatewayNodeSession {
|
|||
switch push {
|
||||
case let .snapshot(ok):
|
||||
self.pluginSurfaceUrls = self.normalizePluginSurfaceUrls(ok.pluginsurfaceurls)
|
||||
self.serverCapabilities = Set(
|
||||
GatewayServerCapability.allCases.filter { ok.supportsServerCapability($0) })
|
||||
if self.hasEverConnected {
|
||||
self.broadcastServerEvent(
|
||||
EventFrame(type: "event", event: "seqGap", payload: nil, seq: nil, stateversion: nil))
|
||||
|
|
@ -426,7 +456,7 @@ public actor GatewayNodeSession {
|
|||
self.markSnapshotReceived()
|
||||
await self.notifyConnectedIfNeeded()
|
||||
case let .event(evt):
|
||||
guard let channel = self.channel else { return }
|
||||
guard let channel else { return }
|
||||
await self.handleEvent(
|
||||
evt,
|
||||
channel: channel,
|
||||
|
|
@ -439,6 +469,7 @@ public actor GatewayNodeSession {
|
|||
private func resetConnectionState() {
|
||||
self.hasNotifiedConnected = false
|
||||
self.snapshotReceived = false
|
||||
self.serverCapabilities = nil
|
||||
self.drainSnapshotWaiters(returning: false)
|
||||
}
|
||||
|
||||
|
|
@ -515,7 +546,7 @@ public actor GatewayNodeSession {
|
|||
method: method,
|
||||
params: params,
|
||||
timeoutMs: Double(timeoutSeconds * 1000))
|
||||
let decoded = try self.decoder.decode(PluginSurfaceRefreshResponse.self, from: data)
|
||||
let decoded = try decoder.decode(PluginSurfaceRefreshResponse.self, from: data)
|
||||
let urls = self.normalizePluginSurfaceUrls(decoded.pluginSurfaceUrls)
|
||||
guard let refreshed = urls[surface] else { return nil }
|
||||
self.pluginSurfaceUrls[surface] = refreshed
|
||||
|
|
@ -536,7 +567,7 @@ public actor GatewayNodeSession {
|
|||
self.logger.info("node invoke request received")
|
||||
guard let payload = evt.payload else { return }
|
||||
do {
|
||||
let request = try self.decodeInvokeRequest(from: payload)
|
||||
let request = try decodeInvokeRequest(from: payload)
|
||||
let timeoutLabel = request.timeoutMs.map(String.init) ?? "none"
|
||||
self.logger.info(
|
||||
"node invoke request decoded id=\(request.id, privacy: .public) command=\(request.command, privacy: .public) timeoutMs=\(timeoutLabel, privacy: .public)")
|
||||
|
|
@ -587,7 +618,7 @@ public actor GatewayNodeSession {
|
|||
|
||||
private func decodeInvokeRequest(from payload: OpenClawProtocol.AnyCodable) throws -> NodeInvokeRequestPayload {
|
||||
do {
|
||||
let data = try self.encoder.encode(payload)
|
||||
let data = try encoder.encode(payload)
|
||||
return try self.decoder.decode(NodeInvokeRequestPayload.self, from: data)
|
||||
} catch {
|
||||
if let raw = payload.value as? String, let data = raw.data(using: .utf8) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,16 @@
|
|||
import OpenClawProtocol
|
||||
|
||||
public enum GatewayServerCapability: String, CaseIterable, Sendable {
|
||||
case chatSendRoutingContract = "chat-send-routing-contract"
|
||||
}
|
||||
|
||||
extension HelloOk {
|
||||
public func supportsServerCapability(_ capability: GatewayServerCapability) -> Bool {
|
||||
let values = features["capabilities"]?.value as? [AnyCodable] ?? []
|
||||
return values.contains { ($0.value as? String) == capability.rawValue }
|
||||
}
|
||||
}
|
||||
|
||||
/// Server-push messages from the gateway websocket.
|
||||
///
|
||||
/// This is the in-process replacement for the legacy `NotificationCenter` fan-out.
|
||||
|
|
|
|||
|
|
@ -8556,6 +8556,7 @@ public struct ChatSendParams: Codable, Sendable {
|
|||
public let systeminputprovenance: [String: AnyCodable]?
|
||||
public let systemprovenancereceipt: String?
|
||||
public let suppresscommandinterpretation: Bool?
|
||||
public let expectedsessionroutingcontract: String?
|
||||
public let idempotencykey: String
|
||||
|
||||
public init(
|
||||
|
|
@ -8576,6 +8577,7 @@ public struct ChatSendParams: Codable, Sendable {
|
|||
systeminputprovenance: [String: AnyCodable]?,
|
||||
systemprovenancereceipt: String?,
|
||||
suppresscommandinterpretation: Bool?,
|
||||
expectedsessionroutingcontract: String?,
|
||||
idempotencykey: String)
|
||||
{
|
||||
self.sessionkey = sessionkey
|
||||
|
|
@ -8595,6 +8597,7 @@ public struct ChatSendParams: Codable, Sendable {
|
|||
self.systeminputprovenance = systeminputprovenance
|
||||
self.systemprovenancereceipt = systemprovenancereceipt
|
||||
self.suppresscommandinterpretation = suppresscommandinterpretation
|
||||
self.expectedsessionroutingcontract = expectedsessionroutingcontract
|
||||
self.idempotencykey = idempotencykey
|
||||
}
|
||||
|
||||
|
|
@ -8615,6 +8618,7 @@ public struct ChatSendParams: Codable, Sendable {
|
|||
systeminputprovenance: [String: AnyCodable]?,
|
||||
systemprovenancereceipt: String?,
|
||||
suppresscommandinterpretation: Bool?,
|
||||
expectedsessionroutingcontract: String?,
|
||||
idempotencykey: String)
|
||||
{
|
||||
self.init(
|
||||
|
|
@ -8635,6 +8639,7 @@ public struct ChatSendParams: Codable, Sendable {
|
|||
systeminputprovenance: systeminputprovenance,
|
||||
systemprovenancereceipt: systemprovenancereceipt,
|
||||
suppresscommandinterpretation: suppresscommandinterpretation,
|
||||
expectedsessionroutingcontract: expectedsessionroutingcontract,
|
||||
idempotencykey: idempotencykey)
|
||||
}
|
||||
|
||||
|
|
@ -8656,6 +8661,7 @@ public struct ChatSendParams: Codable, Sendable {
|
|||
case systeminputprovenance = "systemInputProvenance"
|
||||
case systemprovenancereceipt = "systemProvenanceReceipt"
|
||||
case suppresscommandinterpretation = "suppressCommandInterpretation"
|
||||
case expectedsessionroutingcontract = "expectedSessionRoutingContract"
|
||||
case idempotencykey = "idempotencyKey"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -77,6 +77,25 @@ private func outboxCommand(
|
|||
}
|
||||
|
||||
struct ChatTranscriptCacheStoreTests {
|
||||
@Test func `verified routing identity survives a cold store reopen`() async throws {
|
||||
let url = try makeDatabaseURL()
|
||||
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
|
||||
let identity = try #require(OpenClawChatSessionRoutingIdentity(
|
||||
scope: " Per-Sender ",
|
||||
mainSessionKey: " Work ",
|
||||
defaultAgentID: " Main "))
|
||||
let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a")
|
||||
|
||||
await store.storeSessionRoutingIdentity(identity)
|
||||
#expect(OpenClawChatSQLiteTranscriptCache.loadSessionRoutingIdentity(
|
||||
databaseURL: url,
|
||||
gatewayID: "gw-a") == identity)
|
||||
|
||||
let reopened = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a")
|
||||
#expect(await reopened.loadSessionRoutingIdentity() == identity)
|
||||
#expect(identity.contract == "per-sender|work|main")
|
||||
}
|
||||
|
||||
@Test func `transcript and sessions round trip`() async throws {
|
||||
let url = try makeDatabaseURL()
|
||||
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
|
||||
|
|
@ -178,6 +197,46 @@ struct ChatTranscriptCacheStoreTests {
|
|||
#expect(await messageTexts(storeB.loadTranscript(sessionKey: "main")) == ["gateway B"])
|
||||
}
|
||||
|
||||
@Test func `global transcripts are scoped per agent identity`() async throws {
|
||||
let url = try makeDatabaseURL()
|
||||
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
|
||||
let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a")
|
||||
|
||||
await store.storeTranscript(
|
||||
sessionKey: "global",
|
||||
agentID: "agent-a",
|
||||
messages: [cacheMessage(
|
||||
role: "user",
|
||||
text: "agent A",
|
||||
timestamp: 1,
|
||||
idempotencyKey: "c-agent-a:user")])
|
||||
await store.storeTranscript(
|
||||
sessionKey: "global",
|
||||
agentID: "agent-b",
|
||||
messages: [cacheMessage(role: "user", text: "agent B", timestamp: 2)])
|
||||
|
||||
let agentAMessages = await store.loadTranscript(sessionKey: "global", agentID: "agent-a")
|
||||
let agentBMessages = await store.loadTranscript(sessionKey: "global", agentID: "agent-b")
|
||||
#expect(messageTexts(agentAMessages) == ["agent A"])
|
||||
#expect(messageTexts(agentBMessages) == ["agent B"])
|
||||
#expect(await store.loadTranscript(sessionKey: "global").isEmpty)
|
||||
|
||||
#expect(await store.enqueueCommand(OpenClawChatOutboxCommand(
|
||||
id: "c-agent-a",
|
||||
sessionKey: "global",
|
||||
agentID: "agent-a",
|
||||
text: "agent A",
|
||||
thinking: "off",
|
||||
createdAt: Date().timeIntervalSince1970,
|
||||
status: .queued,
|
||||
retryCount: 0,
|
||||
lastError: nil)))
|
||||
#expect(await store.cancelCommand(id: "c-agent-a") == .updated)
|
||||
#expect(await store.loadTranscript(sessionKey: "global", agentID: "agent-a").isEmpty)
|
||||
let survivingAgentBMessages = await store.loadTranscript(sessionKey: "global", agentID: "agent-b")
|
||||
#expect(messageTexts(survivingAgentBMessages) == ["agent B"])
|
||||
}
|
||||
|
||||
@Test func `empty transcript store clears cached row`() async throws {
|
||||
let url = try makeDatabaseURL()
|
||||
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
|
||||
|
|
@ -277,7 +336,7 @@ struct ChatTranscriptCacheStoreTests {
|
|||
#expect(items[1].content == nil)
|
||||
}
|
||||
|
||||
@Test func `schema version mismatch drops and rebuilds silently`() async throws {
|
||||
@Test func `v1 transcript cache migrates to durable outbox schema`() async throws {
|
||||
let url = try makeDatabaseURL()
|
||||
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
|
||||
do {
|
||||
|
|
@ -285,6 +344,282 @@ struct ChatTranscriptCacheStoreTests {
|
|||
await store.storeTranscript(
|
||||
sessionKey: "main",
|
||||
messages: [cacheMessage(role: "user", text: "old-schema", timestamp: 1)])
|
||||
await store.retire()
|
||||
}
|
||||
|
||||
var raw: OpaquePointer?
|
||||
#expect(sqlite3_open(url.path, &raw) == SQLITE_OK)
|
||||
#expect(sqlite3_exec(raw, "DROP TABLE outbox_commands", nil, nil, nil) == SQLITE_OK)
|
||||
#expect(sqlite3_exec(raw, "PRAGMA user_version = 1", nil, nil, nil) == SQLITE_OK)
|
||||
sqlite3_close_v2(raw)
|
||||
|
||||
let migrated = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a")
|
||||
#expect(await messageTexts(migrated.loadTranscript(sessionKey: "main")) == ["old-schema"])
|
||||
#expect(await migrated.enqueueCommand(outboxCommand(id: "c-1", text: "preserved migration")))
|
||||
#expect(await migrated.loadCommands().map(\.text) == ["preserved migration"])
|
||||
}
|
||||
|
||||
@Test func `v2 migration parks rows without a routing contract`() async throws {
|
||||
let url = try makeDatabaseURL()
|
||||
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
|
||||
let now = Date().timeIntervalSince1970
|
||||
do {
|
||||
let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a")
|
||||
#expect(await store.enqueueCommand(OpenClawChatOutboxCommand(
|
||||
id: "c-global",
|
||||
sessionKey: "global",
|
||||
agentID: "agent-a",
|
||||
text: "targeted before downgrade",
|
||||
thinking: "off",
|
||||
createdAt: now,
|
||||
status: .queued,
|
||||
retryCount: 0,
|
||||
lastError: nil)))
|
||||
#expect(await store.enqueueCommand(outboxCommand(
|
||||
id: "c-main",
|
||||
sessionKey: "main",
|
||||
text: "mutable main alias",
|
||||
createdAt: now + 0.5)))
|
||||
#expect(await store.enqueueCommand(outboxCommand(
|
||||
id: "c-scoped",
|
||||
sessionKey: "agent:agent-a:main",
|
||||
text: "already scoped",
|
||||
createdAt: now + 1)))
|
||||
#expect(await store.enqueueCommand(outboxCommand(
|
||||
id: "c-matrix",
|
||||
sessionKey: "agent:agent-a:matrix:channel:!MixedRoomAbCdEf:example.org",
|
||||
text: "case-sensitive room",
|
||||
createdAt: now + 2)))
|
||||
#expect(await store.enqueueCommand(outboxCommand(
|
||||
id: "c-empty-agent",
|
||||
sessionKey: "agent::main",
|
||||
text: "missing agent",
|
||||
createdAt: now + 3)))
|
||||
#expect(await store.enqueueCommand(outboxCommand(
|
||||
id: "c-empty-rest",
|
||||
sessionKey: "agent:agent-a:",
|
||||
text: "missing session",
|
||||
createdAt: now + 4)))
|
||||
await store.retire()
|
||||
}
|
||||
|
||||
var raw: OpaquePointer?
|
||||
#expect(sqlite3_open(url.path, &raw) == SQLITE_OK)
|
||||
#expect(sqlite3_exec(
|
||||
raw,
|
||||
"UPDATE outbox_commands SET status = 'sending' WHERE client_uuid = 'c-scoped'",
|
||||
nil,
|
||||
nil,
|
||||
nil) == SQLITE_OK)
|
||||
#expect(sqlite3_exec(
|
||||
raw,
|
||||
"UPDATE outbox_commands SET status = 'awaiting_confirmation' WHERE client_uuid = 'c-matrix'",
|
||||
nil,
|
||||
nil,
|
||||
nil) == SQLITE_OK)
|
||||
#expect(sqlite3_exec(raw, "ALTER TABLE outbox_commands DROP COLUMN agent_id", nil, nil, nil) == SQLITE_OK)
|
||||
#expect(sqlite3_exec(
|
||||
raw,
|
||||
"ALTER TABLE outbox_commands DROP COLUMN delivery_session_key",
|
||||
nil,
|
||||
nil,
|
||||
nil) == SQLITE_OK)
|
||||
#expect(sqlite3_exec(
|
||||
raw,
|
||||
"ALTER TABLE outbox_commands DROP COLUMN routing_contract",
|
||||
nil,
|
||||
nil,
|
||||
nil) == SQLITE_OK)
|
||||
#expect(sqlite3_exec(raw, "PRAGMA user_version = 2", nil, nil, nil) == SQLITE_OK)
|
||||
sqlite3_close_v2(raw)
|
||||
|
||||
let migrated = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a")
|
||||
let commands = await migrated.loadCommands()
|
||||
#expect(commands.map(\.agentID) == [nil, nil, nil, nil, nil, nil])
|
||||
#expect(commands.map(\.deliverySessionKey) == [
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
])
|
||||
#expect(commands.map(\.status) == [.failed, .failed, .failed, .failed, .failed, .failed])
|
||||
#expect(commands.map(\.lastError) == [
|
||||
OpenClawChatSQLiteTranscriptCache.outboxUnknownTargetError,
|
||||
OpenClawChatSQLiteTranscriptCache.outboxUnknownTargetError,
|
||||
OpenClawChatSQLiteTranscriptCache.outboxUnconfirmedError,
|
||||
OpenClawChatSQLiteTranscriptCache.outboxUnconfirmedError,
|
||||
OpenClawChatSQLiteTranscriptCache.outboxUnknownTargetError,
|
||||
OpenClawChatSQLiteTranscriptCache.outboxUnknownTargetError,
|
||||
])
|
||||
#expect(await migrated.markCommandRetriedIfPresent(
|
||||
id: "c-global",
|
||||
agentID: "agent-b",
|
||||
deliverySessionKey: "global",
|
||||
routingContract: "per-sender|main|agent-b") == .updated)
|
||||
let retried = await migrated.loadCommands().first { $0.id == "c-global" }
|
||||
#expect(retried?.agentID == "agent-b")
|
||||
#expect(retried?.deliverySessionKey == "global")
|
||||
#expect(retried?.routingContract == "per-sender|main|agent-b")
|
||||
#expect(retried?.status == .queued)
|
||||
}
|
||||
|
||||
@Test func `unknown failed command can retry without an agent`() async throws {
|
||||
let url = try makeDatabaseURL()
|
||||
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
|
||||
let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a")
|
||||
#expect(await store.enqueueCommand(OpenClawChatOutboxCommand(
|
||||
id: "c-unknown",
|
||||
sessionKey: "unknown",
|
||||
deliverySessionKey: "unknown",
|
||||
routingContract: "per-sender|main|main",
|
||||
agentID: nil,
|
||||
text: "retry reserved",
|
||||
thinking: "off",
|
||||
createdAt: Date().timeIntervalSince1970,
|
||||
status: .failed,
|
||||
retryCount: 1,
|
||||
lastError: "failed")))
|
||||
#expect(await store.loadCommands().first?.lastError == "failed")
|
||||
|
||||
#expect(await store.markCommandRetriedIfPresent(
|
||||
id: "c-unknown",
|
||||
agentID: nil,
|
||||
deliverySessionKey: "unknown",
|
||||
routingContract: "per-sender|main|main") == .updated)
|
||||
let command = try #require(await store.loadCommands().first)
|
||||
#expect(command.status == .queued)
|
||||
#expect(command.agentID == nil)
|
||||
#expect(command.deliverySessionKey == "unknown")
|
||||
}
|
||||
|
||||
@Test func `retargeted retry removes optimistic row from previous agent cache`() async throws {
|
||||
let url = try makeDatabaseURL()
|
||||
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
|
||||
let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a")
|
||||
await store.storeTranscript(
|
||||
sessionKey: "main",
|
||||
agentID: "agent-a",
|
||||
messages: [
|
||||
cacheMessage(
|
||||
role: "user",
|
||||
text: "old owner",
|
||||
timestamp: 1,
|
||||
idempotencyKey: "c-retarget:user"),
|
||||
])
|
||||
#expect(await store.enqueueCommand(OpenClawChatOutboxCommand(
|
||||
id: "c-retarget",
|
||||
sessionKey: "main",
|
||||
deliverySessionKey: "agent:agent-a:main",
|
||||
routingContract: "per-sender|main|agent-a",
|
||||
agentID: "agent-a",
|
||||
text: "old owner",
|
||||
thinking: "off",
|
||||
createdAt: Date().timeIntervalSince1970,
|
||||
status: .failed,
|
||||
retryCount: 1,
|
||||
lastError: "changed")))
|
||||
|
||||
#expect(await store.markCommandRetriedIfPresent(
|
||||
id: "c-retarget",
|
||||
agentID: "agent-b",
|
||||
deliverySessionKey: "agent:agent-b:main",
|
||||
routingContract: "per-sender|main|agent-b") == .updated)
|
||||
#expect(await store.loadTranscript(sessionKey: "main", agentID: "agent-a").isEmpty)
|
||||
let command = try #require(await store.loadCommands().first)
|
||||
#expect(command.agentID == "agent-b")
|
||||
#expect(command.status == .queued)
|
||||
}
|
||||
|
||||
@Test func `v3 migration preserves delivery ambiguity without a routing contract`() async throws {
|
||||
let url = try makeDatabaseURL()
|
||||
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
|
||||
do {
|
||||
let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a")
|
||||
#expect(await store.enqueueCommand(OpenClawChatOutboxCommand(
|
||||
id: "c-v3",
|
||||
sessionKey: "main",
|
||||
deliverySessionKey: "agent:agent-a:main",
|
||||
routingContract: "per-sender|main|agent-a",
|
||||
agentID: "agent-a",
|
||||
text: "park after downgrade",
|
||||
thinking: "off",
|
||||
createdAt: Date().timeIntervalSince1970,
|
||||
status: .queued,
|
||||
retryCount: 0,
|
||||
lastError: nil)))
|
||||
#expect(await store.enqueueCommand(OpenClawChatOutboxCommand(
|
||||
id: "c-v3-acked",
|
||||
sessionKey: "main",
|
||||
deliverySessionKey: "agent:agent-a:main",
|
||||
routingContract: "per-sender|main|agent-a",
|
||||
agentID: "agent-a",
|
||||
text: "possibly delivered",
|
||||
thinking: "off",
|
||||
createdAt: Date().timeIntervalSince1970 + 1,
|
||||
status: .awaitingConfirmation,
|
||||
retryCount: 0,
|
||||
lastError: nil)))
|
||||
await store.retire()
|
||||
}
|
||||
|
||||
var raw: OpaquePointer?
|
||||
#expect(sqlite3_open(url.path, &raw) == SQLITE_OK)
|
||||
#expect(sqlite3_exec(
|
||||
raw,
|
||||
"ALTER TABLE outbox_commands DROP COLUMN routing_contract",
|
||||
nil,
|
||||
nil,
|
||||
nil) == SQLITE_OK)
|
||||
#expect(sqlite3_exec(raw, "PRAGMA user_version = 3", nil, nil, nil) == SQLITE_OK)
|
||||
sqlite3_close_v2(raw)
|
||||
|
||||
let migrated = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a")
|
||||
let commands = await migrated.loadCommands()
|
||||
#expect(commands.map(\.status) == [.failed, .failed])
|
||||
#expect(commands.map(\.agentID) == [nil, nil])
|
||||
#expect(commands.map(\.deliverySessionKey) == ["", ""])
|
||||
#expect(commands.map(\.routingContract) == [nil, nil])
|
||||
#expect(commands.map(\.lastError) == [
|
||||
OpenClawChatSQLiteTranscriptCache.outboxUnknownTargetError,
|
||||
OpenClawChatSQLiteTranscriptCache.outboxUnconfirmedError,
|
||||
])
|
||||
}
|
||||
|
||||
@Test func `v4 migration adds routing identity without touching outbox`() async throws {
|
||||
let url = try makeDatabaseURL()
|
||||
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
|
||||
do {
|
||||
let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a")
|
||||
#expect(await store.enqueueCommand(outboxCommand(id: "c-v4", text: "preserve me")))
|
||||
await store.retire()
|
||||
}
|
||||
|
||||
var raw: OpaquePointer?
|
||||
#expect(sqlite3_open(url.path, &raw) == SQLITE_OK)
|
||||
#expect(sqlite3_exec(raw, "DROP TABLE gateway_routing_identity", nil, nil, nil) == SQLITE_OK)
|
||||
#expect(sqlite3_exec(raw, "PRAGMA user_version = 4", nil, nil, nil) == SQLITE_OK)
|
||||
sqlite3_close_v2(raw)
|
||||
|
||||
let migrated = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a")
|
||||
#expect(await migrated.loadCommands().map(\.id) == ["c-v4"])
|
||||
#expect(await migrated.loadSessionRoutingIdentity() == nil)
|
||||
let identity = try #require(OpenClawChatSessionRoutingIdentity(
|
||||
scope: "global",
|
||||
mainSessionKey: "main",
|
||||
defaultAgentID: "main"))
|
||||
await migrated.storeSessionRoutingIdentity(identity)
|
||||
#expect(await migrated.loadSessionRoutingIdentity() == identity)
|
||||
}
|
||||
|
||||
@Test func `unknown schema preserves durable outbox bytes and fails closed`() async throws {
|
||||
let url = try makeDatabaseURL()
|
||||
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
|
||||
do {
|
||||
let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a")
|
||||
#expect(await store.enqueueCommand(outboxCommand(id: "c-1", text: "do not delete")))
|
||||
await store.retire()
|
||||
}
|
||||
|
||||
var raw: OpaquePointer?
|
||||
|
|
@ -292,27 +627,38 @@ struct ChatTranscriptCacheStoreTests {
|
|||
#expect(sqlite3_exec(raw, "PRAGMA user_version = 99", nil, nil, nil) == SQLITE_OK)
|
||||
sqlite3_close_v2(raw)
|
||||
|
||||
let rebuilt = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a")
|
||||
#expect(await rebuilt.loadTranscript(sessionKey: "main").isEmpty)
|
||||
let blocked = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a")
|
||||
#expect(await blocked.loadCommands().isEmpty)
|
||||
#expect(await !blocked.enqueueCommand(outboxCommand(id: "c-2", text: "must fail closed")))
|
||||
|
||||
// Rebuilt store is fully functional again.
|
||||
await rebuilt.storeTranscript(
|
||||
sessionKey: "main",
|
||||
messages: [cacheMessage(role: "user", text: "fresh", timestamp: 2)])
|
||||
#expect(await messageTexts(rebuilt.loadTranscript(sessionKey: "main")) == ["fresh"])
|
||||
// Restore the known version to prove the blocked open did not delete
|
||||
// or rebuild the persistent outbox table.
|
||||
raw = nil
|
||||
#expect(sqlite3_open(url.path, &raw) == SQLITE_OK)
|
||||
#expect(sqlite3_exec(
|
||||
raw,
|
||||
"PRAGMA user_version = \(OpenClawChatSQLiteTranscriptCache.schemaVersion)",
|
||||
nil,
|
||||
nil,
|
||||
nil) == SQLITE_OK)
|
||||
sqlite3_close_v2(raw)
|
||||
let recovered = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a")
|
||||
#expect(await recovered.loadCommands().map(\.text) == ["do not delete"])
|
||||
}
|
||||
|
||||
@Test func `corrupt database file drops and rebuilds silently`() async throws {
|
||||
@Test func `corrupt existing database is preserved and fails closed`() async throws {
|
||||
let url = try makeDatabaseURL()
|
||||
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
|
||||
try Data("this is not a sqlite database".utf8).write(to: url)
|
||||
let original = Data("this is not a sqlite database".utf8)
|
||||
try original.write(to: url)
|
||||
|
||||
let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a")
|
||||
#expect(await store.loadTranscript(sessionKey: "main").isEmpty)
|
||||
await store.storeTranscript(
|
||||
sessionKey: "main",
|
||||
messages: [cacheMessage(role: "user", text: "recovered", timestamp: 1)])
|
||||
#expect(await messageTexts(store.loadTranscript(sessionKey: "main")) == ["recovered"])
|
||||
#expect(await store.loadTranscript(sessionKey: "main").isEmpty)
|
||||
#expect(try Data(contentsOf: url) == original)
|
||||
}
|
||||
|
||||
@Test func `undecodable row is dropped and treated as miss`() async throws {
|
||||
|
|
@ -366,35 +712,206 @@ struct ChatCommandOutboxStoreTests {
|
|||
#expect(loaded.map(\.sessionKey) == ["main", "main"])
|
||||
}
|
||||
|
||||
@Test func `claiming a deleted command returns false`() async throws {
|
||||
@Test func `claims are FIFO and exclusive until the sender advances`() async throws {
|
||||
let url = try makeDatabaseURL()
|
||||
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
|
||||
let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a")
|
||||
#expect(await store.enqueueCommand(outboxCommand(id: "c-1", text: "kept")))
|
||||
#expect(await store.markCommandSending(id: "c-1"))
|
||||
let now = Date().timeIntervalSince1970
|
||||
#expect(await store.enqueueCommand(outboxCommand(id: "c-1", text: "first", createdAt: now)))
|
||||
#expect(await store.enqueueCommand(outboxCommand(id: "c-2", text: "second", createdAt: now + 1)))
|
||||
|
||||
// Deleted (or never-existing) rows must refuse the claim so a flush
|
||||
// pass working from a stale snapshot cannot send them.
|
||||
await store.deleteCommand(id: "c-1")
|
||||
#expect(await store.markCommandSending(id: "c-1") == false)
|
||||
#expect(await store.markCommandSending(id: "never-existed") == false)
|
||||
#expect(await store.claimNextCommand()?.id == "c-1")
|
||||
#expect(await store.claimNextCommand() == nil)
|
||||
#expect(await store.markCommandAwaitingConfirmation(id: "c-1") == .updated)
|
||||
#expect(await store.claimNextCommand()?.id == "c-2")
|
||||
}
|
||||
|
||||
@Test func `interrupted sending rows revert to queued on recovery`() async throws {
|
||||
@Test func `user cancellation cannot delete a claimed command`() async throws {
|
||||
let url = try makeDatabaseURL()
|
||||
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
|
||||
let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a")
|
||||
#expect(await store.enqueueCommand(outboxCommand(id: "c-1", text: "claimed")))
|
||||
#expect(await store.claimNextCommand()?.id == "c-1")
|
||||
|
||||
#expect(await store.cancelCommand(id: "c-1") == .missing)
|
||||
#expect(await store.loadCommands().map(\.status) == [.sending])
|
||||
#expect(await store.confirmCommand(id: "c-1") == .updated)
|
||||
#expect(await store.loadCommands().isEmpty)
|
||||
}
|
||||
|
||||
@Test func `queued cancellation atomically scrubs and suppresses its cached bubble`() async throws {
|
||||
let url = try makeDatabaseURL()
|
||||
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
|
||||
let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a")
|
||||
#expect(await store.enqueueCommand(outboxCommand(id: "c-cancel", text: "cancel me")))
|
||||
let staleSnapshot = [
|
||||
cacheMessage(
|
||||
role: "user",
|
||||
text: "cancel me",
|
||||
timestamp: 1,
|
||||
idempotencyKey: "c-cancel:user"),
|
||||
cacheMessage(
|
||||
role: "assistant",
|
||||
text: "newer row",
|
||||
timestamp: 2,
|
||||
idempotencyKey: "other-run"),
|
||||
]
|
||||
await store.storeTranscript(sessionKey: "main", messages: staleSnapshot)
|
||||
|
||||
#expect(await store.cancelCommand(id: "c-cancel") == .updated)
|
||||
#expect(await messageTexts(store.loadTranscript(sessionKey: "main")) == ["newer row"])
|
||||
|
||||
// An overlapping view can finish a stale whole-transcript write after
|
||||
// cancellation. The cache owner keeps the canceled UUID suppressed.
|
||||
await store.storeTranscript(sessionKey: "main", messages: staleSnapshot)
|
||||
#expect(await messageTexts(store.loadTranscript(sessionKey: "main")) == ["newer row"])
|
||||
|
||||
// Canonical history can prove an ambiguous send really landed. That
|
||||
// authoritative row clears suppression and remains cacheable offline.
|
||||
await store.storeCanonicalTranscript(
|
||||
sessionKey: "main",
|
||||
messages: staleSnapshot,
|
||||
canonicalMessageIdempotencyKeys: ["c-cancel:user"])
|
||||
#expect(await messageTexts(store.loadTranscript(sessionKey: "main")) == ["cancel me", "newer row"])
|
||||
|
||||
#expect(await store.enqueueCommand(outboxCommand(id: "c-canonical-first", text: "already landed")))
|
||||
let canonicalFirst = [cacheMessage(
|
||||
role: "user",
|
||||
text: "already landed",
|
||||
timestamp: 3,
|
||||
idempotencyKey: "c-canonical-first:user")]
|
||||
await store.storeCanonicalTranscript(
|
||||
sessionKey: "main",
|
||||
messages: canonicalFirst,
|
||||
canonicalMessageIdempotencyKeys: ["c-canonical-first:user"])
|
||||
#expect(await store.cancelCommand(id: "c-canonical-first") == .confirmed)
|
||||
#expect(await messageTexts(store.loadTranscript(sessionKey: "main")) == ["already landed"])
|
||||
}
|
||||
|
||||
@Test func `canonical message merge preserves a newer cached snapshot`() async throws {
|
||||
let url = try makeDatabaseURL()
|
||||
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
|
||||
let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a")
|
||||
await store.storeTranscript(sessionKey: "main", messages: [
|
||||
cacheMessage(role: "assistant", text: "newer row", timestamp: 2, idempotencyKey: "newer-run"),
|
||||
])
|
||||
|
||||
await store.mergeCanonicalTranscriptMessage(
|
||||
sessionKey: "main",
|
||||
agentID: nil,
|
||||
message: cacheMessage(
|
||||
role: "user",
|
||||
text: "confirmed row",
|
||||
timestamp: 1,
|
||||
idempotencyKey: "confirmed:user"),
|
||||
canonicalMessageIdempotencyKey: "confirmed:user")
|
||||
|
||||
#expect(await messageTexts(store.loadTranscript(sessionKey: "main")) == ["confirmed row", "newer row"])
|
||||
}
|
||||
|
||||
@Test func `scoped cancellation scrubs the canonical transcript partition`() async throws {
|
||||
let url = try makeDatabaseURL()
|
||||
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
|
||||
let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a")
|
||||
let sessionKey = "agent:agent-a:matrix:channel:!MixedRoomAbCdEf:example.org"
|
||||
#expect(await store.enqueueCommand(OpenClawChatOutboxCommand(
|
||||
id: "c-scoped-cancel",
|
||||
sessionKey: sessionKey,
|
||||
agentID: "agent-a",
|
||||
text: "cancel scoped",
|
||||
thinking: "off",
|
||||
createdAt: Date().timeIntervalSince1970,
|
||||
status: .queued,
|
||||
retryCount: 0,
|
||||
lastError: nil)))
|
||||
let staleSnapshot = [cacheMessage(
|
||||
role: "user",
|
||||
text: "cancel scoped",
|
||||
timestamp: 1,
|
||||
idempotencyKey: "c-scoped-cancel:user")]
|
||||
await store.storeTranscript(sessionKey: sessionKey, messages: staleSnapshot)
|
||||
|
||||
#expect(await store.cancelCommand(id: "c-scoped-cancel") == .updated)
|
||||
#expect(await store.loadTranscript(sessionKey: sessionKey).isEmpty)
|
||||
|
||||
await store.storeTranscript(sessionKey: sessionKey, messages: staleSnapshot)
|
||||
#expect(await store.loadTranscript(sessionKey: sessionKey).isEmpty)
|
||||
}
|
||||
|
||||
@Test func `cancellation lookup failure preserves the queued command`() async throws {
|
||||
let url = try makeDatabaseURL()
|
||||
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
|
||||
let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a")
|
||||
#expect(await store.enqueueCommand(outboxCommand(id: "c-lookup", text: "keep me")))
|
||||
|
||||
var raw: OpaquePointer?
|
||||
#expect(sqlite3_open(url.path, &raw) == SQLITE_OK)
|
||||
#expect(sqlite3_exec(
|
||||
raw,
|
||||
"ALTER TABLE outbox_commands RENAME TO outbox_commands_unavailable",
|
||||
nil,
|
||||
nil,
|
||||
nil) == SQLITE_OK)
|
||||
sqlite3_close_v2(raw)
|
||||
|
||||
#expect(await store.cancelCommand(id: "c-lookup") == .unavailable)
|
||||
|
||||
raw = nil
|
||||
#expect(sqlite3_open(url.path, &raw) == SQLITE_OK)
|
||||
#expect(sqlite3_exec(
|
||||
raw,
|
||||
"ALTER TABLE outbox_commands_unavailable RENAME TO outbox_commands",
|
||||
nil,
|
||||
nil,
|
||||
nil) == SQLITE_OK)
|
||||
sqlite3_close_v2(raw)
|
||||
#expect(await store.loadCommands().map(\.id) == ["c-lookup"])
|
||||
}
|
||||
|
||||
@Test func `transcript lookup failure rolls back queued cancellation`() async throws {
|
||||
let url = try makeDatabaseURL()
|
||||
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
|
||||
let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a")
|
||||
#expect(await store.enqueueCommand(outboxCommand(id: "c-cache", text: "keep me")))
|
||||
await store.storeTranscript(
|
||||
sessionKey: "main",
|
||||
messages: [cacheMessage(
|
||||
role: "user",
|
||||
text: "keep me",
|
||||
timestamp: 1,
|
||||
idempotencyKey: "c-cache:user")])
|
||||
|
||||
var raw: OpaquePointer?
|
||||
#expect(sqlite3_open(url.path, &raw) == SQLITE_OK)
|
||||
#expect(sqlite3_exec(
|
||||
raw,
|
||||
"ALTER TABLE cached_transcripts RENAME TO cached_transcripts_unavailable",
|
||||
nil,
|
||||
nil,
|
||||
nil) == SQLITE_OK)
|
||||
sqlite3_close_v2(raw)
|
||||
|
||||
#expect(await store.cancelCommand(id: "c-cache") == .unavailable)
|
||||
#expect(await store.loadCommands().map(\.id) == ["c-cache"])
|
||||
}
|
||||
|
||||
@Test func `interrupted sending rows fail closed on recovery`() async throws {
|
||||
let url = try makeDatabaseURL()
|
||||
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
|
||||
do {
|
||||
let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a")
|
||||
#expect(await store.enqueueCommand(outboxCommand(id: "c-1", text: "in flight")))
|
||||
await store.markCommandSending(id: "c-1")
|
||||
#expect(await store.claimNextCommand()?.id == "c-1")
|
||||
#expect(await store.loadCommands().map(\.status) == [.sending])
|
||||
}
|
||||
|
||||
// Simulated crash mid-send: a fresh process recovers the row to
|
||||
// queued; the idempotency key makes the re-send safe.
|
||||
// Simulated crash mid-send: the durable result is ambiguous, so a
|
||||
// fresh process requires explicit user retry instead of replaying it.
|
||||
let reopened = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a")
|
||||
#expect(await reopened.recoverInterruptedSends())
|
||||
#expect(await reopened.loadCommands().map(\.status) == [.queued])
|
||||
let recovered = await reopened.loadCommands()
|
||||
#expect(recovered.map(\.status) == [.failed])
|
||||
#expect(recovered.map(\.lastError) == [OpenClawChatSQLiteTranscriptCache.outboxUnconfirmedError])
|
||||
|
||||
// An unreachable store must report failure so callers do not burn
|
||||
// their once-per-launch recovery gate while the DB is locked.
|
||||
|
|
@ -402,6 +919,102 @@ struct ChatCommandOutboxStoreTests {
|
|||
#expect(await !reopened.recoverInterruptedSends())
|
||||
}
|
||||
|
||||
@Test func `failed post-claim transition reopens same-process recovery`() async throws {
|
||||
let url = try makeDatabaseURL()
|
||||
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
|
||||
let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a")
|
||||
#expect(await store.enqueueCommand(outboxCommand(id: "c-claim", text: "recover me")))
|
||||
#expect(await store.recoverInterruptedSends())
|
||||
#expect(await store.claimNextCommand()?.id == "c-claim")
|
||||
|
||||
var raw: OpaquePointer?
|
||||
#expect(sqlite3_open(url.path, &raw) == SQLITE_OK)
|
||||
#expect(sqlite3_exec(
|
||||
raw,
|
||||
"ALTER TABLE outbox_commands RENAME TO outbox_commands_unavailable",
|
||||
nil,
|
||||
nil,
|
||||
nil) == SQLITE_OK)
|
||||
sqlite3_close_v2(raw)
|
||||
|
||||
#expect(await store.markCommandAwaitingConfirmation(id: "c-claim") == .unavailable)
|
||||
|
||||
raw = nil
|
||||
#expect(sqlite3_open(url.path, &raw) == SQLITE_OK)
|
||||
#expect(sqlite3_exec(
|
||||
raw,
|
||||
"ALTER TABLE outbox_commands_unavailable RENAME TO outbox_commands",
|
||||
nil,
|
||||
nil,
|
||||
nil) == SQLITE_OK)
|
||||
sqlite3_close_v2(raw)
|
||||
|
||||
#expect(await store.recoverInterruptedSends())
|
||||
#expect(await store.loadCommands().map(\.status) == [.failed])
|
||||
}
|
||||
|
||||
@Test func `failed terminal transition reports unavailable and reopens recovery`() async throws {
|
||||
let url = try makeDatabaseURL()
|
||||
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
|
||||
let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a")
|
||||
#expect(await store.enqueueCommand(outboxCommand(id: "c-terminal", text: "stop retrying")))
|
||||
#expect(await store.recoverInterruptedSends())
|
||||
#expect(await store.claimNextCommand()?.id == "c-terminal")
|
||||
|
||||
var raw: OpaquePointer?
|
||||
#expect(sqlite3_open(url.path, &raw) == SQLITE_OK)
|
||||
#expect(sqlite3_exec(
|
||||
raw,
|
||||
"ALTER TABLE outbox_commands RENAME TO outbox_commands_unavailable",
|
||||
nil,
|
||||
nil,
|
||||
nil) == SQLITE_OK)
|
||||
sqlite3_close_v2(raw)
|
||||
|
||||
#expect(
|
||||
await store.markCommandFailedIfPresent(id: "c-terminal", retryCount: 3, lastError: "rejected") ==
|
||||
.unavailable)
|
||||
|
||||
raw = nil
|
||||
#expect(sqlite3_open(url.path, &raw) == SQLITE_OK)
|
||||
#expect(sqlite3_exec(
|
||||
raw,
|
||||
"ALTER TABLE outbox_commands_unavailable RENAME TO outbox_commands",
|
||||
nil,
|
||||
nil,
|
||||
nil) == SQLITE_OK)
|
||||
sqlite3_close_v2(raw)
|
||||
|
||||
#expect(await store.recoverInterruptedSends())
|
||||
let command = await store.loadCommands().first
|
||||
#expect(command?.status == .failed)
|
||||
#expect(command?.retryCount == 0)
|
||||
#expect(command?.lastError == OpenClawChatSQLiteTranscriptCache.outboxUnconfirmedError)
|
||||
}
|
||||
|
||||
@Test func `unknown row status is skipped without blocking later commands`() async throws {
|
||||
let url = try makeDatabaseURL()
|
||||
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
|
||||
let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a")
|
||||
#expect(await store.enqueueCommand(outboxCommand(id: "c-valid", text: "send me", createdAt: 2)))
|
||||
|
||||
var raw: OpaquePointer?
|
||||
#expect(sqlite3_open(url.path, &raw) == SQLITE_OK)
|
||||
#expect(sqlite3_exec(
|
||||
raw,
|
||||
"""
|
||||
INSERT INTO outbox_commands(
|
||||
client_uuid, gateway_id, session_key, text, thinking, created_at, status, retry_count, last_error
|
||||
) VALUES ('c-unknown', 'gw-a', 'main', 'skip me', 'off', 1, 'future_status', 0, '')
|
||||
""",
|
||||
nil,
|
||||
nil,
|
||||
nil) == SQLITE_OK)
|
||||
sqlite3_close_v2(raw)
|
||||
|
||||
#expect(await store.loadCommands().map(\.id) == ["c-valid"])
|
||||
}
|
||||
|
||||
@Test func `queued commands expire to failed at the staleness boundary`() async throws {
|
||||
let url = try makeDatabaseURL()
|
||||
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
|
||||
|
|
@ -412,14 +1025,28 @@ struct ChatCommandOutboxStoreTests {
|
|||
outboxCommand(id: "c-stale", text: "stale", createdAt: now - maxAge - 60)))
|
||||
#expect(await store.enqueueCommand(
|
||||
outboxCommand(id: "c-fresh", text: "fresh", createdAt: now - maxAge + 60)))
|
||||
#expect(await store.enqueueCommand(
|
||||
OpenClawChatOutboxCommand(
|
||||
id: "c-unconfirmed",
|
||||
sessionKey: "main",
|
||||
text: "unconfirmed",
|
||||
thinking: "off",
|
||||
createdAt: now - maxAge - 60,
|
||||
status: .sending,
|
||||
retryCount: 0,
|
||||
lastError: nil)))
|
||||
#expect(await store.markCommandAwaitingConfirmation(id: "c-unconfirmed") == .updated)
|
||||
|
||||
let loaded = await store.loadCommands()
|
||||
let stale = try #require(loaded.first { $0.id == "c-stale" })
|
||||
let fresh = try #require(loaded.first { $0.id == "c-fresh" })
|
||||
let unconfirmed = try #require(loaded.first { $0.id == "c-unconfirmed" })
|
||||
#expect(stale.status == .failed)
|
||||
#expect(stale.lastError == OpenClawChatSQLiteTranscriptCache.outboxExpiredError)
|
||||
#expect(fresh.status == .queued)
|
||||
#expect(fresh.lastError == nil)
|
||||
#expect(unconfirmed.status == .failed)
|
||||
#expect(unconfirmed.lastError == OpenClawChatSQLiteTranscriptCache.outboxUnconfirmedError)
|
||||
}
|
||||
|
||||
@Test func `enqueue refuses beyond the queue bound`() async throws {
|
||||
|
|
@ -435,7 +1062,7 @@ struct ChatCommandOutboxStoreTests {
|
|||
#expect(await store.loadCommands().count == bound)
|
||||
|
||||
// Deleting a row frees capacity again.
|
||||
await store.deleteCommand(id: "c-0")
|
||||
#expect(await store.cancelCommand(id: "c-0") == .updated)
|
||||
#expect(await store.enqueueCommand(outboxCommand(id: "c-after-delete", text: "fits now")))
|
||||
}
|
||||
|
||||
|
|
@ -449,8 +1076,9 @@ struct ChatCommandOutboxStoreTests {
|
|||
#expect(await storeB.loadCommands().isEmpty)
|
||||
|
||||
// Cross-gateway mutations must not leak either.
|
||||
await storeB.markCommandFailed(id: "c-a", retryCount: 3, lastError: "boom")
|
||||
await storeB.deleteCommand(id: "c-a")
|
||||
#expect(
|
||||
await storeB.markCommandFailedIfPresent(id: "c-a", retryCount: 3, lastError: "boom") == .missing)
|
||||
#expect(await storeB.cancelCommand(id: "c-a") == .missing)
|
||||
let survivors = await storeA.loadCommands()
|
||||
#expect(survivors.map(\.id) == ["c-a"])
|
||||
#expect(survivors.map(\.status) == [.queued])
|
||||
|
|
@ -468,13 +1096,17 @@ struct ChatCommandOutboxStoreTests {
|
|||
#expect(loaded.map(\.retryCount) == [2])
|
||||
#expect(loaded.map(\.lastError) == ["socket closed"])
|
||||
|
||||
await store.markCommandFailed(id: "c-1", retryCount: 3, lastError: "gave up")
|
||||
#expect(await store.claimNextCommand()?.id == "c-1")
|
||||
#expect(await store.markCommandFailedIfPresent(
|
||||
id: "c-1",
|
||||
retryCount: 3,
|
||||
lastError: "gave up") == .updated)
|
||||
loaded = await store.loadCommands()
|
||||
#expect(loaded.map(\.status) == [.failed])
|
||||
#expect(loaded.map(\.retryCount) == [3])
|
||||
#expect(loaded.map(\.lastError) == ["gave up"])
|
||||
|
||||
await store.deleteCommand(id: "c-1")
|
||||
#expect(await store.cancelCommand(id: "c-1") == .updated)
|
||||
#expect(await store.loadCommands().isEmpty)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -192,6 +192,7 @@ private func makeViewModel(
|
|||
sessionKey: String = "main",
|
||||
activeAgentId: String? = nil,
|
||||
historyResponses: [OpenClawChatHistoryPayload],
|
||||
sessionRoutingContract: String? = nil,
|
||||
sessionsResponses: [OpenClawChatSessionsListResponse] = [],
|
||||
modelResponses: [[OpenClawChatModelChoice]] = [],
|
||||
commandResponses: [[OpenClawChatCommandChoice]] = [],
|
||||
|
|
@ -216,7 +217,9 @@ private func makeViewModel(
|
|||
// Default to a throwaway suite so model selections in unrelated tests never
|
||||
// write favorites/recents into the test host's standard UserDefaults.
|
||||
let pickerStore = modelPickerStore
|
||||
?? ChatModelPickerStore(defaults: UserDefaults(suiteName: "ChatViewModelTests.\(UUID().uuidString)") ?? .standard)
|
||||
??
|
||||
ChatModelPickerStore(defaults: UserDefaults(suiteName: "ChatViewModelTests.\(UUID().uuidString)") ??
|
||||
.standard)
|
||||
let transport = TestChatTransport(
|
||||
historyResponses: historyResponses,
|
||||
sessionsResponses: sessionsResponses,
|
||||
|
|
@ -238,6 +241,7 @@ private func makeViewModel(
|
|||
sessionKey: sessionKey,
|
||||
transport: transport,
|
||||
activeAgentId: activeAgentId,
|
||||
sessionRoutingContract: sessionRoutingContract,
|
||||
modelPickerStore: pickerStore,
|
||||
initialThinkingLevel: initialThinkingLevel,
|
||||
onSessionChanged: onSessionChanged,
|
||||
|
|
@ -454,6 +458,8 @@ private actor TestChatTransportState {
|
|||
var resetSessionKeys: [String] = []
|
||||
var compactSessionKeys: [String] = []
|
||||
var sentSessionKeys: [String] = []
|
||||
var sentAgentIDs: [String?] = []
|
||||
var sentRoutingContracts: [String?] = []
|
||||
var sentMessages: [String] = []
|
||||
var sentRunIds: [String] = []
|
||||
var commandSessionKeys: [String] = []
|
||||
|
|
@ -574,6 +580,25 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor
|
|||
thinkingLevel: "off")
|
||||
}
|
||||
|
||||
func sendMessage(
|
||||
sessionKey: String,
|
||||
agentID: String?,
|
||||
expectedSessionRoutingContract: String?,
|
||||
message: String,
|
||||
thinking: String,
|
||||
idempotencyKey: String,
|
||||
attachments: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse
|
||||
{
|
||||
await self.state.sentAgentIDsAppend(agentID)
|
||||
await self.state.sentRoutingContractsAppend(expectedSessionRoutingContract)
|
||||
return try await self.sendMessage(
|
||||
sessionKey: sessionKey,
|
||||
message: message,
|
||||
thinking: thinking,
|
||||
idempotencyKey: idempotencyKey,
|
||||
attachments: attachments)
|
||||
}
|
||||
|
||||
func sendMessage(
|
||||
sessionKey: String,
|
||||
message: String,
|
||||
|
|
@ -687,6 +712,14 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor
|
|||
await self.state.sentMessages
|
||||
}
|
||||
|
||||
func sentAgentIDs() async -> [String?] {
|
||||
await self.state.sentAgentIDs
|
||||
}
|
||||
|
||||
func sentRoutingContracts() async -> [String?] {
|
||||
await self.state.sentRoutingContracts
|
||||
}
|
||||
|
||||
func commandSessionKeys() async -> [String] {
|
||||
await self.state.commandSessionKeys
|
||||
}
|
||||
|
|
@ -819,6 +852,14 @@ extension TestChatTransportState {
|
|||
self.sentSessionKeys.append(v)
|
||||
}
|
||||
|
||||
fileprivate func sentAgentIDsAppend(_ v: String?) {
|
||||
self.sentAgentIDs.append(v)
|
||||
}
|
||||
|
||||
fileprivate func sentRoutingContractsAppend(_ v: String?) {
|
||||
self.sentRoutingContracts.append(v)
|
||||
}
|
||||
|
||||
fileprivate func sentMessagesAppend(_ v: String) {
|
||||
self.sentMessages.append(v)
|
||||
}
|
||||
|
|
@ -939,12 +980,12 @@ struct ChatViewModelTests {
|
|||
let olderUser = chatTextMessage(
|
||||
role: "user",
|
||||
text: "repeat request",
|
||||
timestamp: now - 10_000,
|
||||
timestamp: now - 10000,
|
||||
idempotencyKey: "older:prompt")
|
||||
let olderAssistant = chatTextMessage(
|
||||
role: "assistant",
|
||||
text: "older reply",
|
||||
timestamp: now - 9_000,
|
||||
timestamp: now - 9000,
|
||||
idempotencyKey: "older:assistant")
|
||||
let existingHistory = historyPayload(messages: [olderUser, olderAssistant])
|
||||
let (_, vm) = await makeViewModel(
|
||||
|
|
@ -1338,6 +1379,110 @@ struct ChatViewModelTests {
|
|||
#expect(await MainActor.run { vm.messages.isEmpty })
|
||||
}
|
||||
|
||||
@Test func `live send binds the captured agent and routing contract`() async throws {
|
||||
let contract = "per-sender|main|reviewer"
|
||||
let (transport, vm) = await makeViewModel(
|
||||
activeAgentId: "reviewer",
|
||||
historyResponses: [historyPayload(), historyPayload()],
|
||||
sessionRoutingContract: contract)
|
||||
try await loadAndWaitBootstrap(vm: vm)
|
||||
|
||||
await sendUserMessage(vm, text: "route safely")
|
||||
_ = try await waitForLastSentRunId(transport)
|
||||
|
||||
#expect(await transport.sentAgentIDs() == ["reviewer"])
|
||||
#expect(await transport.sentRoutingContracts() == [contract])
|
||||
}
|
||||
|
||||
@Test func `alias routing contract change restarts bootstrap`() async throws {
|
||||
let historyCalls = AsyncCounter()
|
||||
let oldHistory = historyPayload(messages: [
|
||||
chatTextMessage(role: "assistant", text: "old route", timestamp: 1),
|
||||
])
|
||||
let newHistory = historyPayload(messages: [
|
||||
chatTextMessage(role: "assistant", text: "new route", timestamp: 2),
|
||||
])
|
||||
let (_, vm) = await makeViewModel(
|
||||
activeAgentId: "main",
|
||||
historyResponses: [oldHistory, newHistory],
|
||||
requestHistoryHook: { _ in _ = await historyCalls.increment() })
|
||||
try await loadAndWaitBootstrap(vm: vm)
|
||||
try await waitUntil("initial route history") {
|
||||
await MainActor.run { vm.messages.first?.content.first?.text == "old route" }
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
vm.syncDeliveryIdentity(
|
||||
activeAgentId: "work",
|
||||
sessionRoutingContract: "per-sender|work-main|work")
|
||||
}
|
||||
|
||||
try await waitUntil("replacement route history") {
|
||||
guard await historyCalls.current() == 2 else { return false }
|
||||
return await MainActor.run { vm.messages.first?.content.first?.text == "new route" }
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `custom main routing contract change restarts bootstrap`() async throws {
|
||||
let historyCalls = AsyncCounter()
|
||||
let oldHistory = historyPayload(
|
||||
sessionKey: "agent:ops:work",
|
||||
messages: [chatTextMessage(role: "assistant", text: "old scope", timestamp: 1)])
|
||||
let newHistory = historyPayload(
|
||||
sessionKey: "agent:ops:work",
|
||||
messages: [chatTextMessage(role: "assistant", text: "new scope", timestamp: 2)])
|
||||
let (_, vm) = await makeViewModel(
|
||||
sessionKey: "agent:ops:work",
|
||||
activeAgentId: "ops",
|
||||
historyResponses: [oldHistory, newHistory],
|
||||
sessionRoutingContract: "global|work|ops",
|
||||
requestHistoryHook: { _ in _ = await historyCalls.increment() })
|
||||
try await loadAndWaitBootstrap(vm: vm)
|
||||
try await waitUntil("initial custom main history") {
|
||||
await MainActor.run { vm.messages.first?.content.first?.text == "old scope" }
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
vm.syncSessionRoutingContract("per-sender|work|ops")
|
||||
}
|
||||
|
||||
try await waitUntil("replacement custom main history") {
|
||||
guard await historyCalls.current() == 2 else { return false }
|
||||
return await MainActor.run { vm.messages.first?.content.first?.text == "new scope" }
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `unscoped agent update replaces an active bootstrap`() async throws {
|
||||
let firstHistoryGate = AsyncGate()
|
||||
let historyCalls = AsyncCounter()
|
||||
let firstHistory = historyPayload(
|
||||
sessionKey: "Matrix:!Room:example.org",
|
||||
messages: [chatTextMessage(role: "assistant", text: "old agent", timestamp: 1)])
|
||||
let replacementHistory = historyPayload(
|
||||
sessionKey: "Matrix:!Room:example.org",
|
||||
messages: [chatTextMessage(role: "assistant", text: "new agent", timestamp: 2)])
|
||||
let (_, vm) = await makeViewModel(
|
||||
sessionKey: "Matrix:!Room:example.org",
|
||||
historyResponses: [firstHistory, replacementHistory],
|
||||
requestHistoryHook: { _ in
|
||||
let call = await historyCalls.increment()
|
||||
if call == 1 { await firstHistoryGate.wait() }
|
||||
})
|
||||
|
||||
await MainActor.run { vm.load() }
|
||||
try await waitUntil("first unscoped bootstrap") { await historyCalls.current() == 1 }
|
||||
await MainActor.run { vm.syncActiveAgentId("work") }
|
||||
try await waitUntil("replacement unscoped bootstrap") {
|
||||
guard await historyCalls.current() == 2 else { return false }
|
||||
return await MainActor.run {
|
||||
!vm.isLoading && vm.messages.first?.content.first?.text == "new agent"
|
||||
}
|
||||
}
|
||||
await firstHistoryGate.open()
|
||||
try await Task.sleep(for: .milliseconds(25))
|
||||
#expect(await MainActor.run { vm.messages.first?.content.first?.text } == "new agent")
|
||||
}
|
||||
|
||||
@Test func `intermediate session message preserves pending recovery snapshot`() async throws {
|
||||
let historyGate = AsyncGate()
|
||||
let historyCalls = AsyncCounter()
|
||||
|
|
@ -3676,6 +3821,51 @@ struct ChatViewModelTests {
|
|||
#expect(await MainActor.run { vm.messages.isEmpty })
|
||||
}
|
||||
|
||||
@Test func `exact ordinary session matches before agent bootstrap`() async {
|
||||
let matches = await MainActor.run {
|
||||
(
|
||||
OpenClawChatViewModel.matchesCurrentSessionKey(
|
||||
incoming: "main",
|
||||
agentId: "work",
|
||||
current: "main",
|
||||
mainSessionKey: "main"),
|
||||
OpenClawChatViewModel.matchesCurrentSessionKey(
|
||||
incoming: "main",
|
||||
agentId: "work",
|
||||
current: "main",
|
||||
mainSessionKey: "main",
|
||||
activeAgentId: "main"))
|
||||
}
|
||||
#expect(matches.0)
|
||||
#expect(!matches.1)
|
||||
}
|
||||
|
||||
@Test func `agent scoped opaque event matches only its presentation owner`() async {
|
||||
let matches = await MainActor.run {
|
||||
(
|
||||
OpenClawChatViewModel.matchesCurrentSessionKey(
|
||||
incoming: "agent:reviewer:Matrix:Channel:!MixedRoom:example.org",
|
||||
current: "Matrix:Channel:!MixedRoom:example.org",
|
||||
mainSessionKey: "main",
|
||||
activeAgentId: "reviewer"),
|
||||
OpenClawChatViewModel.matchesCurrentSessionKey(
|
||||
incoming: "agent:reviewer:Matrix:Channel:!MixedRoom:example.org",
|
||||
agentId: "work",
|
||||
current: "Matrix:Channel:!MixedRoom:example.org",
|
||||
mainSessionKey: "main",
|
||||
activeAgentId: "reviewer"),
|
||||
OpenClawChatViewModel.matchesCurrentSessionKey(
|
||||
incoming: "agent:reviewer:Matrix:Channel:!MixedRoom:example.org",
|
||||
current: "Matrix:Channel:!MixedRoom:example.org",
|
||||
mainSessionKey: "main",
|
||||
activeAgentId: "work"))
|
||||
}
|
||||
|
||||
#expect(matches.0)
|
||||
#expect(!matches.1)
|
||||
#expect(!matches.2)
|
||||
}
|
||||
|
||||
@Test func `ignores agent main session message for different current main alias`() async throws {
|
||||
let now = Date().timeIntervalSince1970 * 1000
|
||||
let (transport, vm) = await makeViewModel(historyResponses: [historyPayload()])
|
||||
|
|
|
|||
|
|
@ -306,15 +306,16 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
|
|||
}
|
||||
|
||||
private static func invokeRequestData(id: String, command: String, paramsJSON: String?) -> Data {
|
||||
let payload: [String: Any] = [
|
||||
"id": id,
|
||||
"nodeId": "test-node",
|
||||
"command": command,
|
||||
"paramsJSON": paramsJSON ?? NSNull(),
|
||||
]
|
||||
let frame: [String: Any] = [
|
||||
"type": "event",
|
||||
"event": "node.invoke.request",
|
||||
"payload": [
|
||||
"id": id,
|
||||
"nodeId": "test-node",
|
||||
"command": command,
|
||||
"paramsJSON": paramsJSON ?? (NSNull() as Any),
|
||||
],
|
||||
"payload": payload,
|
||||
]
|
||||
return (try? JSONSerialization.data(withJSONObject: frame)) ?? Data()
|
||||
}
|
||||
|
|
@ -594,7 +595,8 @@ struct GatewayNodeSessionTests {
|
|||
clientId: "openclaw-ios-test",
|
||||
clientMode: "node",
|
||||
clientDisplayName: "iOS Test",
|
||||
includeDeviceIdentity: false)
|
||||
includeDeviceIdentity: false,
|
||||
deviceAuthGatewayID: "gw-a")
|
||||
|
||||
try await gateway.connect(
|
||||
url: #require(URL(string: "ws://first.example.invalid")),
|
||||
|
|
@ -606,7 +608,8 @@ struct GatewayNodeSessionTests {
|
|||
onConnected: {},
|
||||
onDisconnected: { _ in },
|
||||
onInvoke: { req in BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) })
|
||||
let firstRoute = try #require(await gateway.currentRoute())
|
||||
let firstRoute = try #require(await gateway.currentRoute(ifGatewayID: "gw-a"))
|
||||
#expect(await gateway.currentRoute(ifGatewayID: "GW-A") == nil)
|
||||
|
||||
try await gateway.connect(
|
||||
url: #require(URL(string: "ws://second.example.invalid")),
|
||||
|
|
@ -633,6 +636,16 @@ struct GatewayNodeSessionTests {
|
|||
} catch is CancellationError {
|
||||
// Expected: the route lease belongs to the first channel.
|
||||
}
|
||||
do {
|
||||
_ = try await gateway.request(
|
||||
method: "exec.approval.get",
|
||||
paramsJSON: "{}",
|
||||
ifCurrentRoute: firstRoute,
|
||||
distinguishPreDispatchRouteChange: true)
|
||||
Issue.record("typed stale route request unexpectedly reached the replacement channel")
|
||||
} catch is GatewayNodeSessionRequestError {
|
||||
// Expected: callers can distinguish a request rejected before dispatch.
|
||||
}
|
||||
let replacementTask = try #require(session.latestTask())
|
||||
#expect(replacementTask.sentRequestCount(method: "node.event") == 0)
|
||||
#expect(replacementTask.sentRequestCount(method: "exec.approval.get") == 0)
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ Availability: iPhone app builds are distributed through Apple channels when enab
|
|||
- Receives `node.invoke` commands and reports node status events.
|
||||
- Browses the selected agent's workspace read-only from the Agents surface (Files): directory drill-down, syntax-highlighted text previews, image previews, and share-sheet export. No write operations; previews are size-capped by the gateway.
|
||||
- Keeps a small read-only offline cache of recent chat sessions and transcripts per paired gateway: cold opens paint the last known transcript immediately and refresh once the gateway responds, recent chats stay browsable while disconnected, and reset/forget purges the protected local cache.
|
||||
- Queues text messages sent while disconnected in a durable per-gateway outbox (up to 50): queued bubbles show in the transcript, flush in order on reconnect with idempotency keys so nothing sends twice, retry with backoff before surfacing as "Not sent" with retry/delete in the message context menu, and expire instead of sending after 48 hours offline; reset/forget clears the queue with the cache.
|
||||
- Queues text messages sent while disconnected in a durable per-gateway outbox (up to 50): queued bubbles show in the transcript, flush in order on reconnect with idempotent retries, remain durable until canonical history confirms the send, retry with backoff before surfacing a retry/delete action, and expire instead of sending after 48 hours offline; reset/forget clears the queue with the cache.
|
||||
|
||||
## Requirements
|
||||
|
||||
|
|
|
|||
|
|
@ -794,6 +794,12 @@ describe("validateChatSendParams", () => {
|
|||
};
|
||||
|
||||
expect(validateChatSendParams(base)).toBe(true);
|
||||
expect(
|
||||
validateChatSendParams({
|
||||
...base,
|
||||
expectedSessionRoutingContract: "per-sender|main|main",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(validateChatSendParams({ ...base, fastAutoOnSeconds: 2 })).toBe(true);
|
||||
expect(validateChatSendParams({ ...base, fastAutoOnSeconds: 0 })).toBe(false);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -270,6 +270,7 @@ import {
|
|||
errorShape,
|
||||
type GatewayFrame,
|
||||
GatewayFrameSchema,
|
||||
GATEWAY_SERVER_CAPS,
|
||||
type HelloOk,
|
||||
HelloOkSchema,
|
||||
type LogsTailParams,
|
||||
|
|
@ -1130,6 +1131,7 @@ export function formatValidationErrors(errors: ValidationError[] | null | undefi
|
|||
// protocol surface changes.
|
||||
export {
|
||||
ConnectParamsSchema,
|
||||
GATEWAY_SERVER_CAPS,
|
||||
HelloOkSchema,
|
||||
RequestFrameSchema,
|
||||
ResponseFrameSchema,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,10 @@ import { Type } from "typebox";
|
|||
import { GatewayClientIdSchema, GatewayClientModeSchema, NonEmptyString } from "./primitives.js";
|
||||
import { SnapshotSchema, StateVersionSchema } from "./snapshot.js";
|
||||
|
||||
export const GATEWAY_SERVER_CAPS = {
|
||||
CHAT_SEND_ROUTING_CONTRACT: "chat-send-routing-contract",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Top-level gateway frame schemas.
|
||||
*
|
||||
|
|
@ -97,6 +101,7 @@ export const HelloOkSchema = Type.Object(
|
|||
{
|
||||
methods: Type.Array(NonEmptyString),
|
||||
events: Type.Array(NonEmptyString),
|
||||
capabilities: Type.Optional(Type.Array(NonEmptyString)),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
|
|
|
|||
|
|
@ -96,6 +96,7 @@ export const ChatSendParamsSchema = Type.Object(
|
|||
systemInputProvenance: Type.Optional(InputProvenanceSchema),
|
||||
systemProvenanceReceipt: Type.Optional(Type.String()),
|
||||
suppressCommandInterpretation: Type.Optional(Type.Boolean()),
|
||||
expectedSessionRoutingContract: Type.Optional(NonEmptyString),
|
||||
idempotencyKey: NonEmptyString,
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
import type { SessionScope } from "./types.js";
|
||||
|
||||
const FALLBACK_DEFAULT_AGENT_ID = "main";
|
||||
export const SESSION_ROUTING_CHANGED_ERROR_REASON = "session-routing-changed";
|
||||
|
||||
/** Builds the canonical main session key for an agent. */
|
||||
function buildMainSessionKey(agentId: string, mainKey?: string): string {
|
||||
|
|
@ -27,6 +28,20 @@ export function resolveMainSessionKey(cfg?: {
|
|||
return buildMainSessionKey(defaultAgentId, cfg?.session?.mainKey);
|
||||
}
|
||||
|
||||
/** Stable fingerprint for the config values that canonicalize chat session keys. */
|
||||
export function resolveSessionRoutingContract(cfg?: {
|
||||
session?: { scope?: SessionScope; mainKey?: string };
|
||||
agents?: { list?: Array<{ id?: string; default?: boolean }> };
|
||||
}): string {
|
||||
const agents = Array.isArray(cfg?.agents?.list) ? cfg.agents.list : [];
|
||||
const defaultAgentId =
|
||||
agents.find((agent) => agent?.default)?.id ?? agents[0]?.id ?? FALLBACK_DEFAULT_AGENT_ID;
|
||||
const scope = cfg?.session?.scope ?? "per-sender";
|
||||
return [scope, normalizeMainKey(cfg?.session?.mainKey), normalizeAgentId(defaultAgentId)].join(
|
||||
"|",
|
||||
);
|
||||
}
|
||||
|
||||
export { resolveAgentIdFromSessionKey };
|
||||
|
||||
/** Resolves the main session key for one explicit agent. */
|
||||
|
|
|
|||
|
|
@ -27,6 +27,93 @@ function createMockContext() {
|
|||
}
|
||||
|
||||
describe("chat.send error broadcast", () => {
|
||||
it("rejects a stale expected session routing contract before dispatch", async () => {
|
||||
const ctx = createMockContext();
|
||||
const respond = vi.fn();
|
||||
|
||||
await chatHandlers["chat.send"]({
|
||||
params: {
|
||||
sessionKey: "main",
|
||||
message: "hello",
|
||||
expectedSessionRoutingContract: "global|main|main",
|
||||
idempotencyKey: "test-stale-routing",
|
||||
},
|
||||
respond: respond as never,
|
||||
context: ctx as unknown as GatewayRequestContext,
|
||||
req: {} as never,
|
||||
client: null as never,
|
||||
isWebchatConnect: () => false,
|
||||
});
|
||||
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
false,
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
code: "INVALID_REQUEST",
|
||||
details: { reason: "session-routing-changed" },
|
||||
}),
|
||||
);
|
||||
expect(ctx.addChatRun).not.toHaveBeenCalled();
|
||||
expect(ctx.broadcast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns an idempotent cached send after session routing changes", async () => {
|
||||
const ctx = createMockContext();
|
||||
const respond = vi.fn();
|
||||
ctx.dedupe.set("chat:test-cached-routing", {
|
||||
ts: Date.now(),
|
||||
ok: true,
|
||||
payload: { runId: "test-cached-routing", status: "started" },
|
||||
});
|
||||
|
||||
await chatHandlers["chat.send"]({
|
||||
params: {
|
||||
sessionKey: "main",
|
||||
message: "hello",
|
||||
expectedSessionRoutingContract: "global|main|main",
|
||||
idempotencyKey: "test-cached-routing",
|
||||
},
|
||||
respond: respond as never,
|
||||
context: ctx as unknown as GatewayRequestContext,
|
||||
req: {} as never,
|
||||
client: null as never,
|
||||
isWebchatConnect: () => false,
|
||||
});
|
||||
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
true,
|
||||
{ runId: "test-cached-routing", status: "started" },
|
||||
undefined,
|
||||
{ cached: true },
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a stale routing contract before a stop side effect", async () => {
|
||||
const ctx = createMockContext();
|
||||
const respond = vi.fn();
|
||||
|
||||
await chatHandlers["chat.send"]({
|
||||
params: {
|
||||
sessionKey: "main",
|
||||
message: "/stop",
|
||||
expectedSessionRoutingContract: "global|main|main",
|
||||
idempotencyKey: "test-stale-stop-routing",
|
||||
},
|
||||
respond: respond as never,
|
||||
context: ctx as unknown as GatewayRequestContext,
|
||||
req: {} as never,
|
||||
client: null as never,
|
||||
isWebchatConnect: () => false,
|
||||
});
|
||||
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
false,
|
||||
undefined,
|
||||
expect.objectContaining({ details: { reason: "session-routing-changed" } }),
|
||||
);
|
||||
expect(ctx.addChatRun).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should broadcast error when addChatRun throws", async () => {
|
||||
const ctx = createMockContext();
|
||||
const respond = vi.fn();
|
||||
|
|
|
|||
|
|
@ -69,6 +69,10 @@ import {
|
|||
resolveSessionWorkStartError,
|
||||
updateSessionStoreEntry,
|
||||
} from "../../config/sessions.js";
|
||||
import {
|
||||
resolveSessionRoutingContract,
|
||||
SESSION_ROUTING_CHANGED_ERROR_REASON,
|
||||
} from "../../config/sessions/main-session.js";
|
||||
import { resolveMirroredTranscriptText } from "../../config/sessions/transcript-mirror.js";
|
||||
import { CURRENT_SESSION_VERSION } from "../../config/sessions/version.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
|
|
@ -3663,6 +3667,7 @@ export const chatHandlers: GatewayRequestHandlers = {
|
|||
systemInputProvenance?: InputProvenance;
|
||||
systemProvenanceReceipt?: string;
|
||||
suppressCommandInterpretation?: boolean;
|
||||
expectedSessionRoutingContract?: string;
|
||||
idempotencyKey: string;
|
||||
};
|
||||
const suppressCommandInterpretation = p.suppressCommandInterpretation === true;
|
||||
|
|
@ -3750,6 +3755,20 @@ export const chatHandlers: GatewayRequestHandlers = {
|
|||
);
|
||||
const sessionLoadMs = roundedChatSendTimingMs(performance.now() - sessionLoadStartedAtMs);
|
||||
const { cfg, storePath, entry, canonicalKey: sessionKey, legacyKey } = sessionLoadResult;
|
||||
const expectedSessionRoutingContract = normalizeOptionalText(p.expectedSessionRoutingContract);
|
||||
const sessionRoutingChanged = (candidateConfig: OpenClawConfig) =>
|
||||
expectedSessionRoutingContract !== undefined &&
|
||||
expectedSessionRoutingContract.toLowerCase() !==
|
||||
resolveSessionRoutingContract(candidateConfig);
|
||||
const respondSessionRoutingChanged = () => {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, "session routing changed; review and retry", {
|
||||
details: { reason: SESSION_ROUTING_CHANGED_ERROR_REASON },
|
||||
}),
|
||||
);
|
||||
};
|
||||
const selectedAgent = validateChatSelectedAgent({
|
||||
cfg,
|
||||
requestedSessionKey: rawSessionKey,
|
||||
|
|
@ -3819,6 +3838,10 @@ export const chatHandlers: GatewayRequestHandlers = {
|
|||
}
|
||||
|
||||
if (stopCommand) {
|
||||
if (sessionRoutingChanged(cfg)) {
|
||||
respondSessionRoutingChanged();
|
||||
return;
|
||||
}
|
||||
const defaultAgentId = resolveDefaultAgentId(cfg);
|
||||
const stopAgentId =
|
||||
sessionKey === "global" ? (selectedAgent.agentId ?? defaultAgentId) : selectedAgent.agentId;
|
||||
|
|
@ -3902,6 +3925,12 @@ export const chatHandlers: GatewayRequestHandlers = {
|
|||
});
|
||||
return;
|
||||
}
|
||||
// Cached/in-flight retries are already bound to their original target and
|
||||
// must remain queryable after config changes. Gate only a new dispatch.
|
||||
if (sessionRoutingChanged(cfg)) {
|
||||
respondSessionRoutingChanged();
|
||||
return;
|
||||
}
|
||||
const archivedSessionError = resolveSessionWorkStartError(sessionKey, entry);
|
||||
if (archivedSessionError) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, archivedSessionError));
|
||||
|
|
@ -4018,7 +4047,11 @@ export const chatHandlers: GatewayRequestHandlers = {
|
|||
});
|
||||
return;
|
||||
}
|
||||
const latestEntry = loadSessionEntry(rawSessionKey, sessionLoadOptions).entry;
|
||||
const latestSession = loadSessionEntry(rawSessionKey, sessionLoadOptions);
|
||||
if (sessionRoutingChanged(latestSession.cfg)) {
|
||||
throw new Error(SESSION_ROUTING_CHANGED_ERROR_REASON);
|
||||
}
|
||||
const latestEntry = latestSession.entry;
|
||||
if (entry && !latestEntry) {
|
||||
throw new Error(`Session "${sessionKey}" was deleted while starting work. Retry.`);
|
||||
}
|
||||
|
|
@ -4063,6 +4096,10 @@ export const chatHandlers: GatewayRequestHandlers = {
|
|||
});
|
||||
} catch (err) {
|
||||
clearPendingChatSendReservation();
|
||||
if (err instanceof Error && err.message === SESSION_ROUTING_CHANGED_ERROR_REASON) {
|
||||
respondSessionRoutingChanged();
|
||||
return;
|
||||
}
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, formatForLog(err)));
|
||||
return;
|
||||
}
|
||||
|
|
@ -4246,6 +4283,15 @@ export const chatHandlers: GatewayRequestHandlers = {
|
|||
return;
|
||||
}
|
||||
|
||||
// Attachment preparation and admission can suspend. Recheck immediately
|
||||
// before ACK/dispatch so hot config reload cannot cross the send boundary.
|
||||
if (sessionRoutingChanged(context.getRuntimeConfig())) {
|
||||
cleanupAdmittedRun({ force: true });
|
||||
clearAgentRunContext(clientRunId, lifecycleGeneration);
|
||||
respondSessionRoutingChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const serverTiming = shouldIncludeChatSendAckServerTiming(clientInfo)
|
||||
? {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
// protocol version checks, and token-backed operator/node clients.
|
||||
import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
|
||||
import { WebSocket } from "ws";
|
||||
import { GATEWAY_SERVER_CAPS } from "../../packages/gateway-protocol/src/index.js";
|
||||
import {
|
||||
connectReq,
|
||||
ConnectErrorDetailCodes,
|
||||
|
|
@ -155,10 +156,14 @@ export function registerDefaultAuthTokenSuite(): void {
|
|||
const payload = res.payload as
|
||||
| {
|
||||
type?: unknown;
|
||||
features?: { capabilities?: unknown };
|
||||
snapshot?: { configPath?: string; stateDir?: string };
|
||||
}
|
||||
| undefined;
|
||||
expect(payload?.type).toBe("hello-ok");
|
||||
expect(payload?.features?.capabilities).toContain(
|
||||
GATEWAY_SERVER_CAPS.CHAT_SEND_ROUTING_CONTRACT,
|
||||
);
|
||||
expect(payload?.snapshot?.configPath).toBe(createConfigIO().configPath);
|
||||
expect(payload?.snapshot?.stateDir).toBe(STATE_DIR);
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { afterAll, afterEach, beforeAll, describe, expect, test, vi } from "vite
|
|||
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
|
||||
import type { GetReplyOptions } from "../auto-reply/get-reply-options.types.js";
|
||||
import type { InternalGetReplyOptions } from "../auto-reply/reply/get-reply.types.js";
|
||||
import { clearConfigCache } from "../config/config.js";
|
||||
import { clearConfigCache, getRuntimeConfig } from "../config/config.js";
|
||||
import { invalidateSessionStoreCache } from "../config/sessions/store-cache.js";
|
||||
import type { AgentModelConfig } from "../config/types.agents-shared.js";
|
||||
import { rotateAgentEventLifecycleGeneration } from "../infra/agent-events.js";
|
||||
|
|
@ -1732,6 +1732,7 @@ describe("gateway server chat", () => {
|
|||
});
|
||||
|
||||
const context = {
|
||||
getRuntimeConfig,
|
||||
loadGatewayModelCatalog: vi.fn<GatewayRequestContext["loadGatewayModelCatalog"]>(
|
||||
async () => [
|
||||
{
|
||||
|
|
@ -1873,6 +1874,7 @@ describe("gateway server chat", () => {
|
|||
|
||||
const responses: Array<{ id: string; ok: boolean; payload?: unknown; error?: unknown }> = [];
|
||||
const context = {
|
||||
getRuntimeConfig,
|
||||
loadGatewayModelCatalog: vi.fn<GatewayRequestContext["loadGatewayModelCatalog"]>(),
|
||||
logGateway: {
|
||||
info: vi.fn(),
|
||||
|
|
@ -2182,6 +2184,7 @@ describe("gateway server chat", () => {
|
|||
|
||||
const responses: Array<{ id: string; ok: boolean; payload?: unknown; error?: unknown }> = [];
|
||||
const context = {
|
||||
getRuntimeConfig,
|
||||
loadGatewayModelCatalog: vi.fn<GatewayRequestContext["loadGatewayModelCatalog"]>(),
|
||||
logGateway: {
|
||||
info: vi.fn(),
|
||||
|
|
@ -2297,6 +2300,7 @@ describe("gateway server chat", () => {
|
|||
|
||||
const responses: Array<{ id: string; ok: boolean; payload?: unknown; error?: unknown }> = [];
|
||||
const context = {
|
||||
getRuntimeConfig,
|
||||
loadGatewayModelCatalog: vi.fn<GatewayRequestContext["loadGatewayModelCatalog"]>(),
|
||||
logGateway: {
|
||||
info: vi.fn(),
|
||||
|
|
@ -2650,6 +2654,7 @@ describe("gateway server chat", () => {
|
|||
const responses: Array<{ ok: boolean; payload?: unknown; error?: unknown }> = [];
|
||||
const broadcastToConnIds = vi.fn();
|
||||
const context = {
|
||||
getRuntimeConfig,
|
||||
loadGatewayModelCatalog: vi.fn<GatewayRequestContext["loadGatewayModelCatalog"]>(),
|
||||
logGateway: {
|
||||
info: vi.fn(),
|
||||
|
|
@ -2802,6 +2807,7 @@ describe("gateway server chat", () => {
|
|||
const broadcast = vi.fn();
|
||||
const broadcastToConnIds = vi.fn();
|
||||
const context = {
|
||||
getRuntimeConfig,
|
||||
loadGatewayModelCatalog: vi.fn<GatewayRequestContext["loadGatewayModelCatalog"]>(),
|
||||
logGateway: {
|
||||
info: vi.fn(),
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import {
|
|||
type ErrorShape,
|
||||
errorShape,
|
||||
formatValidationErrors,
|
||||
GATEWAY_SERVER_CAPS,
|
||||
MIN_PROBE_PROTOCOL_VERSION,
|
||||
PROTOCOL_VERSION,
|
||||
validateConnectParams,
|
||||
|
|
@ -2121,7 +2122,11 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
|
|||
version: resolveRuntimeServiceVersion(process.env),
|
||||
connId,
|
||||
},
|
||||
features: { methods: gatewayMethods, events },
|
||||
features: {
|
||||
methods: gatewayMethods,
|
||||
events,
|
||||
capabilities: [GATEWAY_SERVER_CAPS.CHAT_SEND_ROUTING_CONTRACT],
|
||||
},
|
||||
snapshot,
|
||||
...(controlUiTabs.length > 0 ? { controlUiTabs } : {}),
|
||||
...(Object.keys(pluginSurfaceUrls).length > 0 ? { pluginSurfaceUrls } : {}),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue