diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index 8781056cc599..e476c04de044 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -5859,7 +5859,7 @@ }, { "kind": "conditional-branch", - "line": 303, + "line": 308, "path": "apps/ios/Sources/Chat/AppleReviewDemoChatTransport.swift", "source": "\\\"\\(trimmed)\\\"", "surface": "apple", @@ -5867,7 +5867,7 @@ }, { "kind": "conditional-branch", - "line": 303, + "line": 308, "path": "apps/ios/Sources/Chat/AppleReviewDemoChatTransport.swift", "source": "that request", "surface": "apple", @@ -7201,9 +7201,17 @@ "surface": "apple", "id": "native.apple.a97cdae7c8e13f0e" }, + { + "kind": "ui-localized-call", + "line": 100, + "path": "apps/ios/Sources/Design/ChatProTab.swift", + "source": "What would you like to work on?", + "surface": "apple", + "id": "native.apple.202b97e93b938049" + }, { "kind": "ui-call", - "line": 109, + "line": 111, "path": "apps/ios/Sources/Design/ChatProTab.swift", "source": "The session attaches once the gateway is ready.", "surface": "apple", @@ -7211,7 +7219,7 @@ }, { "kind": "ui-modifier", - "line": 185, + "line": 187, "path": "apps/ios/Sources/Design/ChatProTab.swift", "source": "Opens Settings / Gateway", "surface": "apple", @@ -7219,7 +7227,7 @@ }, { "kind": "conditional-branch", - "line": 235, + "line": 237, "path": "apps/ios/Sources/Design/ChatProTab.swift", "source": "Connected", "surface": "apple", @@ -7227,7 +7235,7 @@ }, { "kind": "conditional-branch", - "line": 235, + "line": 237, "path": "apps/ios/Sources/Design/ChatProTab.swift", "source": "Unavailable", "surface": "apple", @@ -7235,7 +7243,7 @@ }, { "kind": "conditional-branch", - "line": 246, + "line": 248, "path": "apps/ios/Sources/Design/ChatProTab.swift", "source": "Connect to a gateway", "surface": "apple", @@ -7243,12 +7251,60 @@ }, { "kind": "conditional-branch", - "line": 246, + "line": 248, "path": "apps/ios/Sources/Design/ChatProTab.swift", "source": "Message \\(self.agentDisplayName)...", "surface": "apple", "id": "native.apple.e8173c7d24457256" }, + { + "kind": "ui-localized-call", + "line": 298, + "path": "apps/ios/Sources/Design/ChatProTab.swift", + "source": "Check OpenClaw status", + "surface": "apple", + "id": "native.apple.14492e334b87e4f2" + }, + { + "kind": "ui-localized-call", + "line": 299, + "path": "apps/ios/Sources/Design/ChatProTab.swift", + "source": "Summarize the current OpenClaw status and tell me what needs attention.", + "surface": "apple", + "id": "native.apple.bbf7bd23da1e7865" + }, + { + "kind": "ui-localized-call", + "line": 302, + "path": "apps/ios/Sources/Design/ChatProTab.swift", + "source": "What can I control here?", + "surface": "apple", + "id": "native.apple.ea8cbb1b4230c652" + }, + { + "kind": "ui-localized-call", + "line": 303, + "path": "apps/ios/Sources/Design/ChatProTab.swift", + "source": "Show me which phone controls and device capabilities are available right now.", + "surface": "apple", + "id": "native.apple.451d25bdb3130ed0" + }, + { + "kind": "ui-localized-call", + "line": 306, + "path": "apps/ios/Sources/Design/ChatProTab.swift", + "source": "Help me start voice chat", + "surface": "apple", + "id": "native.apple.503ec402d08bd43a" + }, + { + "kind": "ui-localized-call", + "line": 307, + "path": "apps/ios/Sources/Design/ChatProTab.swift", + "source": "Help me start a realtime voice session from this phone.", + "surface": "apple", + "id": "native.apple.65e462758be96174" + }, { "kind": "ui-call", "line": 99, @@ -10987,7 +11043,7 @@ }, { "kind": "ui-call", - "line": 24, + "line": 25, "path": "apps/ios/Sources/Onboarding/OnboardingWizardSteps.swift", "source": "Welcome to OpenClaw", "surface": "apple", @@ -10995,7 +11051,7 @@ }, { "kind": "ui-call-concatenated", - "line": 36, + "line": 37, "path": "apps/ios/Sources/Onboarding/OnboardingWizardSteps.swift", "source": "The connected agent can use capabilities you enable, including camera, microphone, photos, contacts, calendar, and location. Continue only if you trust the gateway and agent.", "surface": "apple", @@ -11003,7 +11059,7 @@ }, { "kind": "ui-call", - "line": 44, + "line": 45, "path": "apps/ios/Sources/Onboarding/OnboardingWizardSteps.swift", "source": "Security", "surface": "apple", @@ -11011,7 +11067,7 @@ }, { "kind": "ui-call", - "line": 57, + "line": 58, "path": "apps/ios/Sources/Onboarding/OnboardingWizardSteps.swift", "source": "You can change permissions later in Settings.", "surface": "apple", @@ -11019,7 +11075,7 @@ }, { "kind": "ui-call", - "line": 63, + "line": 64, "path": "apps/ios/Sources/Onboarding/OnboardingWizardSteps.swift", "source": "Continue", "surface": "apple", @@ -11027,7 +11083,7 @@ }, { "kind": "ui-call", - "line": 143, + "line": 146, "path": "apps/ios/Sources/Onboarding/OnboardingWizardSteps.swift", "source": "Connect Gateway", "surface": "apple", @@ -11035,15 +11091,47 @@ }, { "kind": "ui-call", - "line": 147, + "line": 151, "path": "apps/ios/Sources/Onboarding/OnboardingWizardSteps.swift", "source": "/pair qr", "surface": "apple", "id": "native.apple.9f65b2eadb6aee92" }, + { + "kind": "conditional-branch", + "line": 161, + "path": "apps/ios/Sources/Onboarding/OnboardingWizardSteps.swift", + "source": "Copied", + "surface": "apple", + "id": "native.apple.fe3269c93d6f8524" + }, + { + "kind": "conditional-branch", + "line": 161, + "path": "apps/ios/Sources/Onboarding/OnboardingWizardSteps.swift", + "source": "Copy", + "surface": "apple", + "id": "native.apple.bad4a8110ca864e1" + }, + { + "kind": "conditional-branch", + "line": 169, + "path": "apps/ios/Sources/Onboarding/OnboardingWizardSteps.swift", + "source": "Copy pair command", + "surface": "apple", + "id": "native.apple.b6f3f5c300b28f17" + }, + { + "kind": "conditional-branch", + "line": 169, + "path": "apps/ios/Sources/Onboarding/OnboardingWizardSteps.swift", + "source": "Pair command copied", + "surface": "apple", + "id": "native.apple.7308cbcb1e40d70e" + }, { "kind": "ui-call", - "line": 150, + "line": 174, "path": "apps/ios/Sources/Onboarding/OnboardingWizardSteps.swift", "source": "Run this in your OpenClaw chat, then scan the code.", "surface": "apple", @@ -11051,7 +11139,7 @@ }, { "kind": "ui-call", - "line": 162, + "line": 186, "path": "apps/ios/Sources/Onboarding/OnboardingWizardSteps.swift", "source": "Scan QR Code", "surface": "apple", @@ -11059,7 +11147,7 @@ }, { "kind": "ui-call", - "line": 171, + "line": 195, "path": "apps/ios/Sources/Onboarding/OnboardingWizardSteps.swift", "source": "Set Up Manually", "surface": "apple", @@ -11115,7 +11203,7 @@ }, { "kind": "ui-call", - "line": 144, + "line": 147, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Back", "surface": "apple", @@ -11123,7 +11211,7 @@ }, { "kind": "ui-call", - "line": 152, + "line": 155, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Close", "surface": "apple", @@ -11131,7 +11219,7 @@ }, { "kind": "ui-call", - "line": 167, + "line": 170, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Done", "surface": "apple", @@ -11139,7 +11227,7 @@ }, { "kind": "ui-modifier", - "line": 175, + "line": 178, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "QR Scanner Unavailable", "surface": "apple", @@ -11147,7 +11235,7 @@ }, { "kind": "ui-call", - "line": 180, + "line": 183, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "OK", "surface": "apple", @@ -11155,7 +11243,7 @@ }, { "kind": "ui-call", - "line": 209, + "line": 212, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Scan QR Code", "surface": "apple", @@ -11163,7 +11251,7 @@ }, { "kind": "ui-call", - "line": 216, + "line": 219, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Cancel", "surface": "apple", @@ -11171,7 +11259,7 @@ }, { "kind": "ui-call", - "line": 223, + "line": 226, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Photos", "surface": "apple", @@ -11179,7 +11267,7 @@ }, { "kind": "ui-named-argument", - "line": 347, + "line": 350, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "LAN or Tailscale host", "surface": "apple", @@ -11187,7 +11275,7 @@ }, { "kind": "ui-named-argument", - "line": 355, + "line": 358, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "VPS with domain", "surface": "apple", @@ -11195,7 +11283,7 @@ }, { "kind": "ui-named-argument", - "line": 366, + "line": 369, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "For local iOS app development", "surface": "apple", @@ -11203,7 +11291,7 @@ }, { "kind": "ui-call", - "line": 381, + "line": 384, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Continue", "surface": "apple", @@ -11211,7 +11299,7 @@ }, { "kind": "ui-call", - "line": 390, + "line": 393, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Developer mode", "surface": "apple", @@ -11219,7 +11307,7 @@ }, { "kind": "conditional-branch", - "line": 418, + "line": 421, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Off", "surface": "apple", @@ -11227,7 +11315,7 @@ }, { "kind": "conditional-branch", - "line": 418, + "line": 421, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "On", "surface": "apple", @@ -11235,7 +11323,7 @@ }, { "kind": "ui-call", - "line": 425, + "line": 428, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Mode", "surface": "apple", @@ -11243,7 +11331,7 @@ }, { "kind": "ui-call", - "line": 426, + "line": 429, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Discovery", "surface": "apple", @@ -11251,7 +11339,7 @@ }, { "kind": "ui-call", - "line": 428, + "line": 431, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Progress", "surface": "apple", @@ -11259,7 +11347,7 @@ }, { "kind": "ui-call", - "line": 430, + "line": 433, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Status", "surface": "apple", @@ -11267,7 +11355,7 @@ }, { "kind": "ui-call", - "line": 449, + "line": 452, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Choose a mode first.", "surface": "apple", @@ -11275,7 +11363,7 @@ }, { "kind": "ui-call", - "line": 454, + "line": 457, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Back to Mode Selection", "surface": "apple", @@ -11283,7 +11371,7 @@ }, { "kind": "ui-call", - "line": 466, + "line": 469, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "No gateways found yet.", "surface": "apple", @@ -11291,7 +11379,7 @@ }, { "kind": "ui-call", - "line": 491, + "line": 494, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Resolving…", "surface": "apple", @@ -11299,7 +11387,7 @@ }, { "kind": "ui-call", - "line": 507, + "line": 510, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Restart Discovery", "surface": "apple", @@ -11307,7 +11395,7 @@ }, { "kind": "ui-call", - "line": 513, + "line": 516, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Discovered Gateways", "surface": "apple", @@ -11315,7 +11403,7 @@ }, { "kind": "ui-named-argument", - "line": 517, + "line": 520, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Manual Fallback", "surface": "apple", @@ -11323,7 +11411,7 @@ }, { "kind": "ui-named-argument", - "line": 522, + "line": 525, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Domain Settings", "surface": "apple", @@ -11331,7 +11419,7 @@ }, { "kind": "ui-call", - "line": 537, + "line": 540, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Developer Local", "surface": "apple", @@ -11339,7 +11427,7 @@ }, { "kind": "ui-call", - "line": 540, + "line": 543, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Default host is localhost. Use your Mac LAN IP if simulator networking requires it.", "surface": "apple", @@ -11347,7 +11435,7 @@ }, { "kind": "ui-call", - "line": 564, + "line": 567, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Gateway rejected credentials. Scan a fresh QR code or update token/password.", "surface": "apple", @@ -11355,7 +11443,7 @@ }, { "kind": "ui-call", - "line": 568, + "line": 571, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Auth token looks valid.", "surface": "apple", @@ -11363,7 +11451,7 @@ }, { "kind": "ui-call", - "line": 582, + "line": 585, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Resume After Approval", "surface": "apple", @@ -11371,7 +11459,7 @@ }, { "kind": "ui-call", - "line": 588, + "line": 591, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Pairing Approval", "surface": "apple", @@ -11379,7 +11467,7 @@ }, { "kind": "ui-call-concatenated", - "line": 598, + "line": 601, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Approve this device on the gateway.\n1) `\\(commandLine)`\n2) `/pair approve` in your OpenClaw chat\n\\(requestLine)\nOpenClaw will also retry automatically when you return to this app.", "surface": "apple", @@ -11387,7 +11475,7 @@ }, { "kind": "ui-call", - "line": 612, + "line": 615, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Scan QR Code Again", "surface": "apple", @@ -11395,7 +11483,7 @@ }, { "kind": "ui-call", - "line": 625, + "line": 628, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Retry Connection", "surface": "apple", @@ -11403,15 +11491,15 @@ }, { "kind": "ui-call", - "line": 666, + "line": 661, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", - "source": "Open OpenClaw", + "source": "Go to Chat", "surface": "apple", - "id": "native.apple.f50052c1d760ec10" + "id": "native.apple.e674edbcb002aec6" }, { "kind": "ui-call", - "line": 680, + "line": 675, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Paste setup code", "surface": "apple", @@ -11419,7 +11507,7 @@ }, { "kind": "ui-call", - "line": 695, + "line": 690, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Applying...", "surface": "apple", @@ -11427,7 +11515,7 @@ }, { "kind": "ui-call", - "line": 699, + "line": 694, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Apply Setup Code", "surface": "apple", @@ -11435,7 +11523,7 @@ }, { "kind": "ui-call", - "line": 714, + "line": 709, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Setup Code", "surface": "apple", @@ -11443,7 +11531,7 @@ }, { "kind": "ui-call", - "line": 717, + "line": 712, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Use this if you received a setup code instead of a QR code.", "surface": "apple", @@ -11451,7 +11539,7 @@ }, { "kind": "ui-call", - "line": 724, + "line": 719, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Host", "surface": "apple", @@ -11459,7 +11547,7 @@ }, { "kind": "ui-call", - "line": 728, + "line": 723, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Port", "surface": "apple", @@ -11467,7 +11555,7 @@ }, { "kind": "ui-call", - "line": 731, + "line": 726, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Use TLS", "surface": "apple", @@ -11475,7 +11563,7 @@ }, { "kind": "ui-call", - "line": 732, + "line": 727, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Discovery Domain (optional)", "surface": "apple", @@ -11483,7 +11571,7 @@ }, { "kind": "ui-call", - "line": 737, + "line": 732, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Gateway Auth Token", "surface": "apple", @@ -11491,7 +11579,7 @@ }, { "kind": "ui-call", - "line": 740, + "line": 735, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Gateway Password", "surface": "apple", @@ -11499,7 +11587,7 @@ }, { "kind": "ui-call", - "line": 784, + "line": 779, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Connecting…", "surface": "apple", @@ -11659,7 +11747,7 @@ }, { "kind": "conditional-branch", - "line": 990, + "line": 994, "path": "apps/ios/Sources/RootTabs.swift", "source": "Gateway needs attention", "surface": "apple", @@ -11667,7 +11755,7 @@ }, { "kind": "conditional-branch", - "line": 990, + "line": 994, "path": "apps/ios/Sources/RootTabs.swift", "source": "OpenClaw iOS", "surface": "apple", @@ -11675,7 +11763,7 @@ }, { "kind": "conditional-branch", - "line": 1026, + "line": 1030, "path": "apps/ios/Sources/RootTabs.swift", "source": "Available", "surface": "apple", @@ -11683,7 +11771,7 @@ }, { "kind": "conditional-branch", - "line": 1026, + "line": 1030, "path": "apps/ios/Sources/RootTabs.swift", "source": "Gateway default", "surface": "apple", @@ -19731,7 +19819,7 @@ }, { "kind": "ui-call", - "line": 404, + "line": 425, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift", "source": "Jump to latest", "surface": "apple", @@ -19739,7 +19827,7 @@ }, { "kind": "ui-modifier", - "line": 415, + "line": 436, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift", "source": "Jump to latest reply", "surface": "apple", @@ -19747,7 +19835,7 @@ }, { "kind": "ui-named-argument", - "line": 435, + "line": 456, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift", "source": "Refresh", "surface": "apple", @@ -19755,7 +19843,7 @@ }, { "kind": "ui-call", - "line": 832, + "line": 881, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift", "source": "Loading chat", "surface": "apple", @@ -19763,7 +19851,7 @@ }, { "kind": "ui-modifier", - "line": 912, + "line": 961, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift", "source": "Dismiss", "surface": "apple", diff --git a/apps/ios/Resources/Localizable.xcstrings b/apps/ios/Resources/Localizable.xcstrings index 05b34dcbb35b..d15819b7a401 100644 --- a/apps/ios/Resources/Localizable.xcstrings +++ b/apps/ios/Resources/Localizable.xcstrings @@ -681,6 +681,142 @@ } } }, + "Check OpenClaw status": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Check OpenClaw status" + } + }, + "zh-CN": { + "stringUnit": { + "state": "translated", + "value": "检查 OpenClaw 状态" + } + }, + "zh-TW": { + "stringUnit": { + "state": "translated", + "value": "檢查 OpenClaw 狀態" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Verificar status do OpenClaw" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "OpenClaw-Status prüfen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Comprobar el estado de OpenClaw" + } + }, + "ja-JP": { + "stringUnit": { + "state": "translated", + "value": "OpenClawのステータスを確認" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "OpenClaw 상태 확인" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Vérifier l’état d’OpenClaw" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "OpenClaw की स्थिति जाँचें" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "التحقق من حالة OpenClaw" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Controlla lo stato di OpenClaw" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "OpenClaw durumunu kontrol et" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Перевірити стан OpenClaw" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Periksa status OpenClaw" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Sprawdź stan OpenClaw" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ตรวจสอบสถานะ OpenClaw" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Kiểm tra trạng thái OpenClaw" + } + }, + "nl": { + "stringUnit": { + "state": "translated", + "value": "OpenClaw-status controleren" + } + }, + "fa": { + "stringUnit": { + "state": "translated", + "value": "بررسی وضعیت OpenClaw" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Проверить состояние OpenClaw" + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Kontrollera OpenClaw-status" + } + } + } + }, "Close": { "localizations": { "en": { @@ -2177,6 +2313,278 @@ } } }, + "Help me start a realtime voice session from this phone.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Help me start a realtime voice session from this phone." + } + }, + "zh-CN": { + "stringUnit": { + "state": "translated", + "value": "帮我从这部手机开始实时语音会话。" + } + }, + "zh-TW": { + "stringUnit": { + "state": "translated", + "value": "幫我從這支手機開始即時語音工作階段。" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Ajude-me a iniciar uma sessão de voz em tempo real neste telefone." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Hilf mir, auf diesem Telefon eine Echtzeit-Sprachsitzung zu starten." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ayúdame a iniciar una sesión de voz en tiempo real desde este teléfono." + } + }, + "ja-JP": { + "stringUnit": { + "state": "translated", + "value": "このスマートフォンからリアルタイム音声セッションを開始するのを手伝ってください。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 휴대전화에서 실시간 음성 세션을 시작하도록 도와주세요." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aidez-moi à démarrer une session vocale en temps réel depuis ce téléphone." + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "इस फ़ोन से रीयल-टाइम वॉइस सत्र शुरू करने में मेरी मदद करें।" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "ساعدني في بدء جلسة صوتية في الوقت الفعلي من هذا الهاتف." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Aiutami ad avviare una sessione vocale in tempo reale da questo telefono." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bu telefondan gerçek zamanlı bir sesli oturum başlatmama yardım et." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Допоможи мені почати голосовий сеанс у реальному часі з цього телефона." + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Bantu saya memulai sesi suara waktu nyata dari ponsel ini." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Pomóż mi rozpocząć sesję głosową w czasie rzeczywistym na tym telefonie." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ช่วยฉันเริ่มเซสชันเสียงแบบเรียลไทม์จากโทรศัพท์เครื่องนี้" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Giúp tôi bắt đầu một phiên thoại theo thời gian thực từ điện thoại này." + } + }, + "nl": { + "stringUnit": { + "state": "translated", + "value": "Help me vanaf deze telefoon een realtime spraaksessie te starten." + } + }, + "fa": { + "stringUnit": { + "state": "translated", + "value": "به من کمک کن از این تلفن یک جلسه صوتی بلادرنگ را شروع کنم." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Помоги мне начать голосовой сеанс в реальном времени с этого телефона." + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Hjälp mig att starta en röstsession i realtid från den här telefonen." + } + } + } + }, + "Help me start voice chat": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Help me start voice chat" + } + }, + "zh-CN": { + "stringUnit": { + "state": "translated", + "value": "帮我开始语音聊天" + } + }, + "zh-TW": { + "stringUnit": { + "state": "translated", + "value": "幫我開始語音聊天" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Ajude-me a iniciar um chat por voz" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Hilf mir, einen Sprachchat zu starten" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ayúdame a iniciar un chat de voz" + } + }, + "ja-JP": { + "stringUnit": { + "state": "translated", + "value": "音声チャットの開始を手伝って" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "음성 채팅 시작 도와줘" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aidez-moi à démarrer un chat vocal" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "वॉइस चैट शुरू करने में मेरी मदद करें" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "ساعدني في بدء محادثة صوتية" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Aiutami ad avviare una chat vocale" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sesli sohbet başlatmama yardım et" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Допоможи мені почати голосовий чат" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Bantu saya memulai chat suara" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Pomóż mi rozpocząć czat głosowy" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ช่วยฉันเริ่มแชทด้วยเสียง" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Giúp tôi bắt đầu trò chuyện thoại" + } + }, + "nl": { + "stringUnit": { + "state": "translated", + "value": "Help me een spraakchat te starten" + } + }, + "fa": { + "stringUnit": { + "state": "translated", + "value": "به من کمک کن گفت‌وگوی صوتی را شروع کنم" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Помоги мне начать голосовой чат" + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Hjälp mig att starta en röstchatt" + } + } + } + }, "Invalid saved gateway URL.": { "localizations": { "en": { @@ -4761,6 +5169,278 @@ } } }, + "Show me which phone controls and device capabilities are available right now.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show me which phone controls and device capabilities are available right now." + } + }, + "zh-CN": { + "stringUnit": { + "state": "translated", + "value": "告诉我目前可以使用哪些手机控制功能和设备能力。" + } + }, + "zh-TW": { + "stringUnit": { + "state": "translated", + "value": "告訴我目前可以使用哪些手機控制功能和裝置能力。" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Mostre quais controles do telefone e recursos do dispositivo estão disponíveis agora." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zeige mir, welche Telefonsteuerungen und Gerätefunktionen gerade verfügbar sind." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Muéstrame qué controles del teléfono y funciones del dispositivo están disponibles ahora." + } + }, + "ja-JP": { + "stringUnit": { + "state": "translated", + "value": "現在利用できるスマートフォンの操作機能とデバイス機能を教えてください。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "지금 사용할 수 있는 휴대전화 제어 기능과 기기 기능을 보여 주세요." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Montrez-moi les commandes du téléphone et les fonctionnalités de l’appareil actuellement disponibles." + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "मुझे दिखाएं कि अभी कौन-से फ़ोन नियंत्रण और डिवाइस क्षमताएं उपलब्ध हैं।" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "اعرض لي عناصر التحكم في الهاتف وإمكانات الجهاز المتاحة الآن." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Mostrami quali controlli del telefono e funzionalità del dispositivo sono disponibili ora." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Şu anda hangi telefon kontrollerinin ve cihaz özelliklerinin kullanılabildiğini göster." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Покажи, які елементи керування телефоном і можливості пристрою доступні зараз." + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Tunjukkan kontrol ponsel dan kemampuan perangkat yang tersedia saat ini." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Pokaż mi, które funkcje sterowania telefonem i możliwości urządzenia są teraz dostępne." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แสดงให้ฉันเห็นว่าตอนนี้มีการควบคุมโทรศัพท์และความสามารถของอุปกรณ์ใดบ้าง" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Cho tôi biết hiện có những tính năng điều khiển điện thoại và khả năng thiết bị nào." + } + }, + "nl": { + "stringUnit": { + "state": "translated", + "value": "Laat me zien welke telefoonbediening en apparaatfuncties nu beschikbaar zijn." + } + }, + "fa": { + "stringUnit": { + "state": "translated", + "value": "نشان بده اکنون کدام کنترل‌های تلفن و قابلیت‌های دستگاه در دسترس هستند." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Покажи, какие элементы управления телефоном и возможности устройства сейчас доступны." + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Visa vilka telefonkontroller och enhetsfunktioner som är tillgängliga just nu." + } + } + } + }, + "Summarize the current OpenClaw status and tell me what needs attention.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Summarize the current OpenClaw status and tell me what needs attention." + } + }, + "zh-CN": { + "stringUnit": { + "state": "translated", + "value": "总结当前的 OpenClaw 状态,并告诉我哪些方面需要注意。" + } + }, + "zh-TW": { + "stringUnit": { + "state": "translated", + "value": "總結目前的 OpenClaw 狀態,並告訴我哪些方面需要注意。" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Resuma o status atual do OpenClaw e diga o que precisa de atenção." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Fasse den aktuellen OpenClaw-Status zusammen und sage mir, was Aufmerksamkeit erfordert." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Resume el estado actual de OpenClaw y dime qué necesita atención." + } + }, + "ja-JP": { + "stringUnit": { + "state": "translated", + "value": "現在のOpenClawの状態を要約し、注意が必要な点を教えてください。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "현재 OpenClaw 상태를 요약하고 주의가 필요한 항목을 알려 주세요." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Résumez l’état actuel d’OpenClaw et indiquez-moi ce qui nécessite mon attention." + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "OpenClaw की मौजूदा स्थिति का सारांश दें और बताएं कि किन चीज़ों पर ध्यान देने की ज़रूरत है।" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لخّص حالة OpenClaw الحالية وأخبرني بما يحتاج إلى الاهتمام." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Riassumi lo stato attuale di OpenClaw e dimmi cosa richiede attenzione." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Mevcut OpenClaw durumunu özetle ve nelerin ilgilenilmesi gerektiğini söyle." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Підсумуй поточний стан OpenClaw і скажи, що потребує уваги." + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Ringkas status OpenClaw saat ini dan beri tahu saya apa yang perlu diperhatikan." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Podsumuj bieżący stan OpenClaw i powiedz mi, co wymaga uwagi." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "สรุปสถานะ OpenClaw ปัจจุบันและบอกฉันว่ามีอะไรที่ต้องตรวจสอบ" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Tóm tắt trạng thái OpenClaw hiện tại và cho tôi biết những gì cần chú ý." + } + }, + "nl": { + "stringUnit": { + "state": "translated", + "value": "Vat de huidige OpenClaw-status samen en vertel me wat aandacht nodig heeft." + } + }, + "fa": { + "stringUnit": { + "state": "translated", + "value": "وضعیت فعلی OpenClaw را خلاصه کن و بگو چه چیزهایی نیاز به توجه دارند." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Подведи итог текущего состояния OpenClaw и скажи, что требует внимания." + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Sammanfatta aktuell OpenClaw-status och berätta vad som behöver uppmärksammas." + } + } + } + }, "Talk": { "localizations": { "en": { @@ -5441,6 +6121,278 @@ } } }, + "What can I control here?": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "What can I control here?" + } + }, + "zh-CN": { + "stringUnit": { + "state": "translated", + "value": "我可以在这里控制什么?" + } + }, + "zh-TW": { + "stringUnit": { + "state": "translated", + "value": "我可以在這裡控制什麼?" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "O que posso controlar aqui?" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Was kann ich hier steuern?" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "¿Qué puedo controlar aquí?" + } + }, + "ja-JP": { + "stringUnit": { + "state": "translated", + "value": "ここでは何を操作できますか?" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "여기서 무엇을 제어할 수 있나요?" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Que puis-je contrôler ici ?" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "मैं यहाँ क्या नियंत्रित कर सकता हूँ?" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "ما الذي يمكنني التحكم فيه هنا؟" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Cosa posso controllare qui?" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Burada neleri kontrol edebilirim?" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Чим я можу керувати тут?" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Apa yang dapat saya kontrol di sini?" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Czym mogę tutaj sterować?" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ฉันควบคุมอะไรที่นี่ได้บ้าง" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Tôi có thể điều khiển gì ở đây?" + } + }, + "nl": { + "stringUnit": { + "state": "translated", + "value": "Wat kan ik hier bedienen?" + } + }, + "fa": { + "stringUnit": { + "state": "translated", + "value": "اینجا چه چیزهایی را می‌توانم کنترل کنم؟" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Чем я могу управлять здесь?" + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Vad kan jag styra här?" + } + } + } + }, + "What would you like to work on?": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "What would you like to work on?" + } + }, + "zh-CN": { + "stringUnit": { + "state": "translated", + "value": "你想处理什么?" + } + }, + "zh-TW": { + "stringUnit": { + "state": "translated", + "value": "你想處理什麼?" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Em que você gostaria de trabalhar?" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Woran möchtest du arbeiten?" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "¿En qué te gustaría trabajar?" + } + }, + "ja-JP": { + "stringUnit": { + "state": "translated", + "value": "何に取り組みますか?" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "무엇을 진행하고 싶으신가요?" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Sur quoi souhaitez-vous travailler ?" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "आप किस पर काम करना चाहेंगे?" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "ما الذي تود العمل عليه؟" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Su cosa vuoi lavorare?" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Ne üzerinde çalışmak istersiniz?" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Над чим ви хочете попрацювати?" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Apa yang ingin Anda kerjakan?" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nad czym chcesz popracować?" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "คุณอยากทำอะไร" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Bạn muốn làm việc gì?" + } + }, + "nl": { + "stringUnit": { + "state": "translated", + "value": "Waaraan wil je werken?" + } + }, + "fa": { + "stringUnit": { + "state": "translated", + "value": "می‌خواهید روی چه چیزی کار کنید؟" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Над чем вы хотите поработать?" + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Vad vill du arbeta med?" + } + } + } + }, "Writing": { "localizations": { "en": { diff --git a/apps/ios/Sources/Chat/AppleReviewDemoChatTransport.swift b/apps/ios/Sources/Chat/AppleReviewDemoChatTransport.swift index c0ae06e4f7cd..257169763fa5 100644 --- a/apps/ios/Sources/Chat/AppleReviewDemoChatTransport.swift +++ b/apps/ios/Sources/Chat/AppleReviewDemoChatTransport.swift @@ -80,9 +80,9 @@ struct LocalChatFixture { modelID: "gpt-5.5", modelName: "GPT-5.5", responsePrefix: "OpenClaw is connected to your gateway.", - seedMessages: [ - "Ready when you are. I can check a project, coordinate an agent, or prepare the next step.", - ], + seedMessages: ProcessInfo.processInfo.arguments.contains("--openclaw-empty-chat-fixture") + ? [] + : ["Ready when you are. I can check a project, coordinate an agent, or prepare the next step."], agents: [ AgentSummary( id: "main", @@ -298,7 +298,12 @@ private actor LocalFixtureChatStore { func sendMessage(sessionKey _: String, message: String, runId: String) throws -> OpenClawChatSendResponse { let now = Date().timeIntervalSince1970 * 1000 - self.messages.append(Self.message(role: "user", text: message, timestamp: now)) + self.messages.append( + Self.message( + role: "user", + text: message, + timestamp: now, + idempotencyKey: "\(runId):user")) let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines) let subject = trimmed.isEmpty ? "that request" : "\"\(trimmed)\"" self.messages.append( @@ -377,7 +382,12 @@ private actor LocalFixtureChatStore { } } - private static func message(role: String, text: String, timestamp: Double) -> OpenClawChatMessage { + private static func message( + role: String, + text: String, + timestamp: Double, + idempotencyKey: String? = nil) -> OpenClawChatMessage + { OpenClawChatMessage( role: role, content: [ @@ -388,7 +398,8 @@ private actor LocalFixtureChatStore { fileName: nil, content: nil), ], - timestamp: timestamp) + timestamp: timestamp, + idempotencyKey: idempotencyKey) } private static func normalizedSessionKey(_ value: String, fallback: String) -> String { diff --git a/apps/ios/Sources/Design/ChatProTab.swift b/apps/ios/Sources/Design/ChatProTab.swift index 122248e25f88..c087630fff42 100644 --- a/apps/ios/Sources/Design/ChatProTab.swift +++ b/apps/ios/Sources/Design/ChatProTab.swift @@ -97,6 +97,8 @@ struct ChatProTab: View { composerChrome: .clean, isComposerEnabled: self.gatewayConnected, messagePlaceholder: self.messagePlaceholder, + emptyAssistantIntro: String(localized: "What would you like to work on?"), + emptyAssistantPrompts: Self.emptyAssistantPrompts, talkControl: self.talkControl) // iMessage-style grey bubbles for agent replies in the clean chrome. .environment(\.openClawAssistantBubblesInCleanChrome, true) @@ -270,7 +272,7 @@ struct ChatProTab: View { private var agentBadge: String { if let identity = activeAgent?.identity, let emoji = identity["emoji"]?.value as? String, - let normalizedEmoji = normalized(emoji) + let normalizedEmoji = Self.normalizedBadgeEmoji(emoji) { return normalizedEmoji } @@ -284,6 +286,27 @@ struct ChatProTab: View { return "OC" } + nonisolated static func normalizedBadgeEmoji(_ value: String?) -> String? { + guard let value else { return nil } + let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines) + return normalized.isEmpty || normalized == "?" ? nil : normalized + } + + nonisolated static let emptyAssistantPrompts: [OpenClawChatView.StarterPrompt] = [ + OpenClawChatView.StarterPrompt( + id: "summarize-status", + title: String(localized: "Check OpenClaw status"), + prompt: String(localized: "Summarize the current OpenClaw status and tell me what needs attention.")), + OpenClawChatView.StarterPrompt( + id: "show-controls", + title: String(localized: "What can I control here?"), + prompt: String(localized: "Show me which phone controls and device capabilities are available right now.")), + OpenClawChatView.StarterPrompt( + id: "start-voice", + title: String(localized: "Help me start voice chat"), + prompt: String(localized: "Help me start a realtime voice session from this phone.")), + ] + private func normalized(_ value: String?) -> String? { guard let value else { return nil } let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/apps/ios/Sources/Onboarding/OnboardingWizardSteps.swift b/apps/ios/Sources/Onboarding/OnboardingWizardSteps.swift index 4a1e4a006ffe..819e3ea66162 100644 --- a/apps/ios/Sources/Onboarding/OnboardingWizardSteps.swift +++ b/apps/ios/Sources/Onboarding/OnboardingWizardSteps.swift @@ -1,4 +1,5 @@ import SwiftUI +import UIKit private enum OnboardingLayout { static let contentMaxWidth: CGFloat = 680 @@ -131,6 +132,8 @@ struct OnboardingWelcomeStep: View { let onScanQRCode: () -> Void let onManualSetup: () -> Void + @State private var pairCommandCopied = false + var body: some View { VStack(spacing: 0) { Spacer() @@ -144,8 +147,29 @@ struct OnboardingWelcomeStep: View { .font(OpenClawType.title1) .padding(.bottom, 12) - KeycapText("/pair qr") - .padding(.bottom, 10) + HStack(spacing: 8) { + KeycapText("/pair qr") + Button { + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + UIPasteboard.general.string = "/pair qr" + self.pairCommandCopied = true + } + } label: { + Label( + self.pairCommandCopied ? "Copied" : "Copy", + systemImage: self.pairCommandCopied ? "checkmark" : "doc.on.doc") + .font(OpenClawType.captionSemiBold) + } + .buttonStyle(.bordered) + .controlSize(.small) + .tint(.primary) + .animation(nil, value: self.pairCommandCopied) + .accessibilityLabel(self.pairCommandCopied ? "Pair command copied" : "Copy pair command") + .accessibilityIdentifier("onboarding-copy-pair-command") + } + .padding(.bottom, 10) Text("Run this in your OpenClaw chat, then scan the code.") .font(OpenClawType.subhead) diff --git a/apps/ios/Sources/Onboarding/OnboardingWizardView.swift b/apps/ios/Sources/Onboarding/OnboardingWizardView.swift index e490a00cedf9..8c81044fb380 100644 --- a/apps/ios/Sources/Onboarding/OnboardingWizardView.swift +++ b/apps/ios/Sources/Onboarding/OnboardingWizardView.swift @@ -75,15 +75,18 @@ struct OnboardingWizardView: View { let allowSkip: Bool let onRequestLocalNetworkAccess: (String) -> Void let onClose: () -> Void + let onComplete: () -> Void init( allowSkip: Bool, onRequestLocalNetworkAccess: @escaping (String) -> Void, - onClose: @escaping () -> Void) + onClose: @escaping () -> Void, + onComplete: @escaping () -> Void) { self.allowSkip = allowSkip self.onRequestLocalNetworkAccess = onRequestLocalNetworkAccess self.onClose = onClose + self.onComplete = onComplete _step = State( initialValue: OnboardingStateStore.shouldPresentFirstRunIntro() ? .intro : .welcome) } @@ -646,24 +649,16 @@ struct OnboardingWizardView: View { .foregroundStyle(OpenClawBrand.textPrimary) .padding(.bottom, 8) - let server = self.appModel.gatewayServerName ?? "gateway" - Text(server) + Text(self.successEndpoint) .font(OpenClawType.subhead) .foregroundStyle(.secondary) - .padding(.bottom, 4) - - if let addr = self.appModel.gatewayRemoteAddress { - Text(addr) - .font(OpenClawType.subhead) - .foregroundStyle(.secondary) - } Spacer() Button { - self.onClose() + self.onComplete() } label: { - Text("Open OpenClaw") + Label("Go to Chat", systemImage: "bubble.left.and.bubble.right.fill") .font(OpenClawType.headline) } .font(OpenClawType.headline) @@ -997,6 +992,15 @@ extension OnboardingWizardView { return !host.isEmpty && self.manualPort > 0 && self.manualPort <= 65535 } + private var successEndpoint: String { + let serverName = self.appModel.gatewayServerName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !serverName.isEmpty { + return serverName + } + let remoteAddress = self.appModel.gatewayRemoteAddress?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return remoteAddress.isEmpty ? "gateway" : remoteAddress + } + private func initializeState() { if self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { if let last = GatewaySettingsStore.loadLastGatewayConnection() { diff --git a/apps/ios/Sources/RootTabs.swift b/apps/ios/Sources/RootTabs.swift index 714c0c88c427..a39fc7c60c78 100644 --- a/apps/ios/Sources/RootTabs.swift +++ b/apps/ios/Sources/RootTabs.swift @@ -900,6 +900,10 @@ struct RootTabs: View { }, onClose: { self.showOnboarding = false + }, + onComplete: { + self.selectSidebarDestination(.chat) + self.showOnboarding = false }) .environment(self.appModel) .environment(self.voiceWake) diff --git a/apps/ios/Tests/GatewayStatusBuilderTests.swift b/apps/ios/Tests/GatewayStatusBuilderTests.swift index 334f6de48dc1..4ca403c897de 100644 --- a/apps/ios/Tests/GatewayStatusBuilderTests.swift +++ b/apps/ios/Tests/GatewayStatusBuilderTests.swift @@ -41,4 +41,16 @@ import Testing #expect(ChatProTab.gatewayPillTitle(state: .connected, isGatewayUsable: true) == "Connected") #expect(ChatProTab.gatewayPillTitle(state: .connected, isGatewayUsable: false) == "Unavailable") } + + @Test func `chat agent badge rejects placeholder question mark`() { + #expect(ChatProTab.normalizedBadgeEmoji(" 🦞 ") == "🦞") + #expect(ChatProTab.normalizedBadgeEmoji("?") == nil) + #expect(ChatProTab.normalizedBadgeEmoji(" ") == nil) + #expect(ChatProTab.normalizedBadgeEmoji(nil) == nil) + } + + @Test func `chat starter prompts stay stable and actionable`() { + #expect(ChatProTab.emptyAssistantPrompts.map(\.id) == ["summarize-status", "show-controls", "start-voice"]) + #expect(ChatProTab.emptyAssistantPrompts.allSatisfy { !$0.title.isEmpty && !$0.prompt.isEmpty }) + } } diff --git a/apps/ios/Tests/IOSGatewayChatTransportTests.swift b/apps/ios/Tests/IOSGatewayChatTransportTests.swift index 62919f1185ae..8216c0e62bd8 100644 --- a/apps/ios/Tests/IOSGatewayChatTransportTests.swift +++ b/apps/ios/Tests/IOSGatewayChatTransportTests.swift @@ -1,4 +1,5 @@ import Foundation +import OpenClawChatUI import OpenClawKit import OpenClawProtocol import Testing @@ -175,3 +176,23 @@ import Testing #expect(mapped == nil) } } + +@Suite struct LocalFixtureChatTransportTests { + @Test func sentUserTurnCarriesGatewayIdempotencyMetadata() async throws { + let transport = LocalFixtureChatTransport(fixture: .appleReviewDemo) + + _ = try await transport.sendMessage( + sessionKey: "main", + message: "hello", + thinking: "auto", + idempotencyKey: "fixture-run", + attachments: []) + let history = try await transport.requestHistory(sessionKey: "main") + let decoded = try #require(history.messages).compactMap { payload -> OpenClawChatMessage? in + guard let data = try? JSONEncoder().encode(payload) else { return nil } + return try? JSONDecoder().decode(OpenClawChatMessage.self, from: data) + } + + #expect(decoded.last(where: { $0.role == "user" })?.idempotencyKey == "fixture-run:user") + } +} diff --git a/apps/ios/UITests/OpenClawSnapshotUITests.swift b/apps/ios/UITests/OpenClawSnapshotUITests.swift index 966d8bf0b75b..6c0778f226c7 100644 --- a/apps/ios/UITests/OpenClawSnapshotUITests.swift +++ b/apps/ios/UITests/OpenClawSnapshotUITests.swift @@ -293,6 +293,103 @@ final class OpenClawSnapshotUITests: XCTestCase { self.attachScreenshot(named: "chat-light") } + func testEmptyChatStarterPromptSendsMessage() throws { + self.launchApp( + for: ScreenshotTarget( + initialTab: "chat", + initialDestination: "chat", + name: "chat-empty-starters"), + additionalArguments: ["--openclaw-empty-chat-fixture"]) + + let starter = try XCTUnwrap(self.app?.buttons["chat-starter-summarize-status"]) + XCTAssertTrue(starter.waitForExistence(timeout: 8)) + XCTAssertTrue(self.app?.staticTexts["What would you like to work on?"].exists == true) + self.attachScreenshot(named: "chat-empty-starters") + + starter.tap() + let sentText = "Summarize the current OpenClaw status and tell me what needs attention." + let sentRows = self.app?.staticTexts.matching(NSPredicate(format: "label == %@", sentText)) + XCTAssertTrue(sentRows?.firstMatch.waitForExistence(timeout: 5) == true) + XCTAssertEqual(sentRows?.count, 1) + XCTAssertTrue( + self.app?.staticTexts.matching(NSPredicate(format: "label CONTAINS %@", "I can help with")) + .firstMatch.waitForExistence(timeout: 5) == true) + self.attachScreenshot(named: "chat-starter-response") + } + + func testEmptyChatStarterPromptsLocalizeInGerman() throws { + self.launchApp( + for: ScreenshotTarget( + initialTab: "chat", + initialDestination: "chat", + name: "chat-empty-starters-german"), + additionalArguments: [ + "--openclaw-empty-chat-fixture", + "-AppleLanguages", + "(de)", + "-AppleLocale", + "de_DE", + ]) + + XCTAssertTrue(self.app?.staticTexts["Woran möchtest du arbeiten?"].waitForExistence(timeout: 8) == true) + let starter = try XCTUnwrap(self.app?.buttons["OpenClaw-Status prüfen"]) + XCTAssertTrue(starter.exists) + starter.tap() + XCTAssertTrue( + self.app?.staticTexts[ + "Fasse den aktuellen OpenClaw-Status zusammen und sage mir, was Aufmerksamkeit erfordert." + ].waitForExistence(timeout: 5) == true) + self.attachScreenshot(named: "chat-empty-starters-german") + } + + func testOnboardingPairCommandAndCompletionOpenChat() throws { + try XCTSkipIf(UIDevice.current.userInterfaceIdiom != .phone, "Phone onboarding proof only") + self.addUIInterruptionMonitor(withDescription: "Local network access") { alert in + guard alert.buttons["Allow"].exists else { return false } + alert.buttons["Allow"].tap() + return true + } + + let app = XCUIApplication() + setupSnapshot(app) + app.launchArguments += [ + "--openclaw-reset-onboarding", + "--openclaw-initial-tab", + "settings", + "--openclaw-initial-destination", + "settings", + ] + app.launch() + self.app = app + + XCTAssertTrue(app.buttons["Continue"].waitForExistence(timeout: 8)) + app.buttons["Continue"].tap() + app.tap() + + let copyPairCommand = app.buttons["onboarding-copy-pair-command"] + XCTAssertTrue(copyPairCommand.waitForExistence(timeout: 8)) + copyPairCommand.tap() + XCTAssertEqual(copyPairCommand.label, "Pair command copied") + self.attachScreenshot(named: "onboarding-copy-pair-command") + + app.buttons["Set Up Manually"].tap() + let setupCode = app.textFields["Paste setup code"] + XCTAssertTrue(setupCode.waitForExistence(timeout: 5)) + setupCode.tap() + setupCode.typeText("APPLE-REVIEW-DEMO") + app.buttons["Done"].tap() + app.buttons["Apply Setup Code"].tap() + + XCTAssertTrue(app.staticTexts["Connected"].waitForExistence(timeout: 8)) + XCTAssertTrue(app.staticTexts["Apple Review Demo Gateway"].exists) + XCTAssertFalse(app.staticTexts["Local demo mode"].exists) + self.attachScreenshot(named: "onboarding-connected-go-to-chat") + + app.buttons["Go to Chat"].tap() + XCTAssertTrue(app.tabBars.buttons["Chat"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.tabBars.buttons["Chat"].isSelected) + } + func testTalkUsesNativeControls() throws { try XCTSkipIf(UIDevice.current.userInterfaceIdiom != .phone, "Phone Talk controls only") self.launchApp(for: ScreenshotTarget( @@ -602,7 +699,8 @@ final class OpenClawSnapshotUITests: XCTestCase { private func launchApp( for target: ScreenshotTarget, appearance: String? = "dark", - screenshotMode: Bool = true) + screenshotMode: Bool = true, + additionalArguments: [String] = []) { self.app?.terminate() @@ -619,6 +717,7 @@ final class OpenClawSnapshotUITests: XCTestCase { if screenshotMode { app.launchArguments.append("--openclaw-screenshot-mode") } + app.launchArguments += additionalArguments if let appearance { app.launchArguments += ["--openclaw-appearance", appearance] } @@ -684,7 +783,7 @@ final class OpenClawSnapshotUITests: XCTestCase { app.buttons["Apply Setup Code"].tap() XCTAssertTrue(app.staticTexts["Connected"].waitForExistence(timeout: 45)) - app.buttons["Open OpenClaw"].tap() + app.buttons["Go to Chat"].tap() return app } diff --git a/apps/ios/project.yml b/apps/ios/project.yml index 162f783087e0..7fa8c86d4478 100644 --- a/apps/ios/project.yml +++ b/apps/ios/project.yml @@ -65,8 +65,8 @@ targets: buildPhase: resources - path: Sources/Fonts buildPhase: resources - resources: - path: Resources/Localizable.xcstrings + buildPhase: resources dependencies: - target: OpenClawShareExtension embed: true @@ -201,8 +201,8 @@ targets: Release: Signing.xcconfig sources: - path: ShareExtension - resources: - path: Resources/Localizable.xcstrings + buildPhase: resources dependencies: - package: OpenClawKit - sdk: AppIntents.framework @@ -247,8 +247,8 @@ targets: - path: Sources/LiveActivity/OpenClawActivityAttributes.swift - path: Sources/Fonts buildPhase: resources - resources: - path: Resources/Localizable.xcstrings + buildPhase: resources dependencies: - sdk: WidgetKit.framework - sdk: ActivityKit.framework @@ -290,8 +290,8 @@ targets: - Info.plist - path: Sources/Fonts buildPhase: resources - resources: - path: Resources/Localizable.xcstrings + buildPhase: resources dependencies: - sdk: AppIntents.framework - sdk: WatchConnectivity.framework diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatModels.swift index 9c531b2060d8..7eb97acd8062 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatModels.swift @@ -183,10 +183,15 @@ public struct OpenClawChatMessageContent: Codable, Hashable, Sendable { } public struct OpenClawChatMessage: Codable, Hashable, Identifiable, Sendable { + private struct OpenClawMetadata: Codable { + let idempotencyKey: String? + } + public var id: UUID = .init() public let role: String public let content: [OpenClawChatMessageContent] public let timestamp: Double? + public let idempotencyKey: String? public let toolCallId: String? public let toolName: String? public let usage: OpenClawChatUsage? @@ -197,6 +202,8 @@ public struct OpenClawChatMessage: Codable, Hashable, Identifiable, Sendable { case role case content case timestamp + case idempotencyKey + case openClaw = "__openclaw" case toolCallId case tool_call_id case toolName @@ -211,6 +218,7 @@ public struct OpenClawChatMessage: Codable, Hashable, Identifiable, Sendable { role: String, content: [OpenClawChatMessageContent], timestamp: Double?, + idempotencyKey: String? = nil, toolCallId: String? = nil, toolName: String? = nil, usage: OpenClawChatUsage? = nil, @@ -221,6 +229,7 @@ public struct OpenClawChatMessage: Codable, Hashable, Identifiable, Sendable { self.role = role self.content = content self.timestamp = timestamp + self.idempotencyKey = idempotencyKey self.toolCallId = toolCallId self.toolName = toolName self.usage = usage @@ -232,6 +241,9 @@ public struct OpenClawChatMessage: Codable, Hashable, Identifiable, Sendable { let container = try decoder.container(keyedBy: CodingKeys.self) let decodedRole = try container.decode(String.self, forKey: .role) let decodedTimestamp = try container.decodeIfPresent(Double.self, forKey: .timestamp) + let decodedOpenClaw = try container.decodeIfPresent(OpenClawMetadata.self, forKey: .openClaw) + let decodedIdempotencyKey = try decodedOpenClaw?.idempotencyKey ?? + container.decodeIfPresent(String.self, forKey: .idempotencyKey) let decodedToolCallId = try container.decodeIfPresent(String.self, forKey: .toolCallId) ?? container.decodeIfPresent(String.self, forKey: .tool_call_id) @@ -244,6 +256,7 @@ public struct OpenClawChatMessage: Codable, Hashable, Identifiable, Sendable { self.role = decodedRole self.timestamp = decodedTimestamp + self.idempotencyKey = decodedIdempotencyKey self.toolCallId = decodedToolCallId self.toolName = decodedToolName self.usage = decodedUsage @@ -315,6 +328,7 @@ public struct OpenClawChatMessage: Codable, Hashable, Identifiable, Sendable { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(self.role, forKey: .role) try container.encodeIfPresent(self.timestamp, forKey: .timestamp) + try container.encodeIfPresent(self.idempotencyKey, forKey: .idempotencyKey) try container.encodeIfPresent(self.toolCallId, forKey: .toolCallId) try container.encodeIfPresent(self.toolName, forKey: .toolName) try container.encodeIfPresent(self.usage, forKey: .usage) diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift index 00f8fac467b8..86c4f4a7e0d6 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift @@ -44,6 +44,18 @@ public struct OpenClawChatView: View { case clean } + public struct StarterPrompt: Hashable, Identifiable, Sendable { + public let id: String + public let title: String + public let prompt: String + + public init(id: String, title: String, prompt: String) { + self.id = id + self.title = title + self.prompt = prompt + } + } + @State private var viewModel: OpenClawChatViewModel @Environment(\.scenePhase) private var scenePhase @State private var scrollerBottomID = UUID() @@ -69,6 +81,7 @@ public struct OpenClawChatView: View { private let isComposerEnabled: Bool private let messagePlaceholder: String? private let emptyAssistantIntro: String? + private let emptyAssistantPrompts: [StarterPrompt] private let talkControl: OpenClawChatTalkControl? private enum ScrollFollowTarget: Equatable { @@ -118,6 +131,7 @@ public struct OpenClawChatView: View { isComposerEnabled: Bool = true, messagePlaceholder: String? = nil, emptyAssistantIntro: String? = nil, + emptyAssistantPrompts: [StarterPrompt] = [], talkControl: OpenClawChatTalkControl? = nil) { _viewModel = State(initialValue: viewModel) @@ -135,6 +149,7 @@ public struct OpenClawChatView: View { self.isComposerEnabled = isComposerEnabled self.messagePlaceholder = messagePlaceholder self.emptyAssistantIntro = emptyAssistantIntro + self.emptyAssistantPrompts = emptyAssistantPrompts self.talkControl = talkControl } @@ -299,7 +314,13 @@ public struct OpenClawChatView: View { @ViewBuilder private var messageListRows: some View { if let introText = visibleEmptyAssistantIntro { - ChatAssistantIntroCard(text: introText) + ChatAssistantIntroCard( + text: introText, + prompts: self.emptyAssistantPrompts, + onPrompt: { prompt in + self.viewModel.input = prompt.prompt + self.viewModel.send() + }) .frame(maxWidth: .infinity, alignment: .leading) } @@ -692,6 +713,7 @@ public struct OpenClawChatView: View { role: last.role, content: content, timestamp: last.timestamp, + idempotencyKey: last.idempotencyKey, toolCallId: last.toolCallId, toolName: last.toolName, usage: last.usage, @@ -805,22 +827,49 @@ public struct OpenClawChatView: View { private struct ChatAssistantIntroCard: View { let text: String + let prompts: [OpenClawChatView.StarterPrompt] + let onPrompt: (OpenClawChatView.StarterPrompt) -> Void var body: some View { - // Rendered as a grey assistant bubble so the greeting reads like the - // agent's first message, matching the in-conversation bubble style. - Text(self.text) - .font(OpenClawChatTypography.body) - .foregroundStyle(OpenClawChatTheme.assistantText) - .multilineTextAlignment(.leading) - .padding(.vertical, 10) - .padding(.horizontal, 14) - .background( - RoundedRectangle(cornerRadius: 18, style: .continuous) - .fill(OpenClawChatTheme.assistantBubble)) - .frame(maxWidth: 320, alignment: .leading) - .padding(.top, 8) - .frame(maxWidth: .infinity, alignment: .leading) + VStack(alignment: .leading, spacing: 10) { + // Rendered as a grey assistant bubble so the greeting reads like the + // agent's first message, matching the in-conversation bubble style. + Text(self.text) + .font(OpenClawChatTypography.body) + .foregroundStyle(OpenClawChatTheme.assistantText) + .multilineTextAlignment(.leading) + .padding(.vertical, 10) + .padding(.horizontal, 14) + .background( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill(OpenClawChatTheme.assistantBubble)) + + ForEach(self.prompts) { prompt in + Button { + self.onPrompt(prompt) + } label: { + HStack(spacing: 8) { + Text(prompt.title) + .font(OpenClawChatTypography.body(size: 15, weight: .semibold, relativeTo: .callout)) + .multilineTextAlignment(.leading) + Spacer(minLength: 8) + Image(systemName: "arrow.up.right") + .font(OpenClawChatTypography.captionSemiBold) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(OpenClawChatTheme.subtleCard)) + } + .buttonStyle(.plain) + .accessibilityIdentifier("chat-starter-\(prompt.id)") + } + } + .frame(maxWidth: 340, alignment: .leading) + .padding(.top, 8) + .frame(maxWidth: .infinity, alignment: .leading) } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift index afbd8748930e..8369ef0d5b8e 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift @@ -133,6 +133,7 @@ public final class OpenClawChatViewModel { } private struct LatestUserTurn { + var idempotencyKey: String? var refreshKey: String? var occurrence: Int var timestamp: Double? @@ -459,6 +460,7 @@ public final class OpenClawChatViewModel { } self.replaceMessages(nextMessages) self.prunePendingLocalUserEchoMessageIDs() + self.clearProvisionalFinalMarkersAdoptedByHistory(incoming) self.pruneProvisionalFinalMessages() self.pruneRunMessageScopes() self.sessionId = payload.sessionId @@ -610,6 +612,7 @@ public final class OpenClawChatViewModel { role: message.role, content: sanitizedContent, timestamp: message.timestamp, + idempotencyKey: message.idempotencyKey, toolCallId: message.toolCallId, toolName: message.toolName, usage: message.usage, @@ -640,6 +643,12 @@ public final class OpenClawChatViewModel { let role = message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() guard !role.isEmpty else { return nil } + // The gateway persists this key with the canonical user row. Prefer it + // so a server timestamp change cannot replace the optimistic row's ID. + if let idempotencyKey = Self.normalizedIdempotencyKey(message.idempotencyKey) { + return [role, "idempotency", idempotencyKey].joined(separator: "|") + } + let timestamp: String = { guard let value = message.timestamp, value.isFinite else { return "" } return String(format: "%.3f", value) @@ -672,8 +681,8 @@ public final class OpenClawChatViewModel { guard role == "assistant" else { return nil } // chat.final and session.message can serialize the same final row with - // different timestamps/content ids; the run user-turn scope owns safety - // for repeated same-text replies across turns. + // different timestamps/content ids. Reconciliation prefers the durable + // gateway run key, with user-turn scope as a legacy fallback. let contentFingerprint = Self.finalMessageContentFingerprint(for: message) let toolCallId = (message.toolCallId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) let toolName = (message.toolName ?? "").trimmingCharacters(in: .whitespacesAndNewlines) @@ -688,6 +697,28 @@ public final class OpenClawChatViewModel { return trimmed.isEmpty ? nil : trimmed } + private static func normalizedIdempotencyKey(_ key: String?) -> String? { + let trimmed = key?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed + } + + private static func adoptingCanonicalMessage( + _ incoming: OpenClawChatMessage, + over existing: OpenClawChatMessage) -> OpenClawChatMessage + { + OpenClawChatMessage( + id: existing.id, + role: incoming.role, + content: incoming.content, + timestamp: incoming.timestamp ?? existing.timestamp, + idempotencyKey: incoming.idempotencyKey, + toolCallId: incoming.toolCallId, + toolName: incoming.toolName, + usage: incoming.usage, + stopReason: incoming.stopReason, + errorMessage: incoming.errorMessage) + } + private func currentRunMessageScope() -> RunMessageScope { RunMessageScope( session: self.currentSessionSnapshot(), @@ -709,6 +740,9 @@ public final class OpenClawChatViewModel { case (nil, nil): return true case let (lhs?, rhs?): + if lhs.idempotencyKey != nil || rhs.idempotencyKey != nil { + return lhs.idempotencyKey == rhs.idempotencyKey + } if let lhsKey = lhs.refreshKey, let rhsKey = rhs.refreshKey { return lhsKey == rhsKey && lhs.occurrence == rhs.occurrence } @@ -727,7 +761,14 @@ public final class OpenClawChatViewModel { -> [OpenClawChatMessage].Index { guard let latestUserTurn else { return messages.startIndex } - if let refreshKey = latestUserTurn.refreshKey { + if let idempotencyKey = latestUserTurn.idempotencyKey, + let index = messages.lastIndex(where: { message in + message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "user" && + Self.normalizedIdempotencyKey(message.idempotencyKey) == idempotencyKey + }) + { + return messages.index(after: index) + } else if let refreshKey = latestUserTurn.refreshKey { var occurrence = 0 for index in messages.indices { guard self.userRefreshIdentityKey(for: messages[index]) == refreshKey else { continue } @@ -750,16 +791,39 @@ public final class OpenClawChatViewModel { }).map { messages.index(after: $0) } ?? messages.startIndex } + private static func messageRange( + after latestUserTurn: LatestUserTurn?, + in messages: [OpenClawChatMessage]) -> Range<[OpenClawChatMessage].Index> + { + let start = self.indexAfterLatestUserTurn(latestUserTurn, in: messages) + guard latestUserTurn != nil, start < messages.endIndex else { + return start.. Bool { guard let key = Self.finalMessageReconciliationKey(for: message) else { return false } guard self.isCurrentSession(scope.session) else { return false } - let searchStart = Self.indexAfterLatestUserTurn(scope.latestUserTurn, in: self.messages) - guard searchStart < self.messages.endIndex else { return false } + if let runId = Self.normalizedIdempotencyKey(message.idempotencyKey) { + return self.messages.contains { existing in + self.provisionalFinalMessagesByID[existing.id] == nil && + Self.normalizedIdempotencyKey(existing.idempotencyKey) == runId + } + } + let searchRange = Self.messageRange(after: scope.latestUserTurn, in: self.messages) + guard !searchRange.isEmpty else { return false } - return self.messages[searchStart...].contains { existing in + return self.messages[searchRange].contains { existing in self.provisionalFinalMessagesByID[existing.id] == nil && Self.finalMessageReconciliationKey(for: existing) == key } @@ -781,6 +845,24 @@ public final class OpenClawChatViewModel { } } + private func clearProvisionalFinalMarkersAdoptedByHistory(_ incoming: [OpenClawChatMessage]) { + let canonicalRunIds = Set(incoming.compactMap { message in + guard Self.isAssistantMessage(message) else { return nil } + return Self.normalizedIdempotencyKey(message.idempotencyKey) + }) + guard !canonicalRunIds.isEmpty else { return } + self.provisionalFinalMessagesByID = self.provisionalFinalMessagesByID.filter { messageID, provisional in + guard let runId = provisional.runId, + canonicalRunIds.contains(runId), + let visible = self.messages.first(where: { $0.id == messageID }), + Self.normalizedIdempotencyKey(visible.idempotencyKey) == runId + else { + return true + } + return false + } + } + private func pruneRunMessageScopes() { self.runMessageScopesByRunID = self.runMessageScopesByRunID.filter { entry in self.isCurrentSession(entry.value.session) @@ -793,12 +875,19 @@ public final class OpenClawChatViewModel { } } - private func adoptPendingLocalUserEcho(incoming: OpenClawChatMessage) -> Bool { - guard let incomingKey = Self.userRefreshIdentityKey(for: incoming) else { return false } - guard let matchIndex = messages.lastIndex(where: { existing in - self.pendingLocalUserEchoMessageIDsByRunID.values.contains(existing.id) - && Self.userRefreshIdentityKey(for: existing) == incomingKey - }) else { + private func adoptCorrelatedUserMessage(incoming: OpenClawChatMessage) -> Bool { + guard incoming.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "user" else { + return false + } + // The final event can clear pending bookkeeping before session.message + // arrives. The persisted key still identifies the exact user turn, so + // adopt the durable row without losing the optimistic row's UI identity. + guard let incomingKey = Self.normalizedIdempotencyKey(incoming.idempotencyKey) else { return false } + let matchIndex = self.messages.lastIndex { existing in + existing.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "user" && + Self.normalizedIdempotencyKey(existing.idempotencyKey) == incomingKey + } + guard let matchIndex else { return false } @@ -807,31 +896,89 @@ public final class OpenClawChatViewModel { $0.value != existing.id } var updated = self.messages - updated[matchIndex] = OpenClawChatMessage( - id: existing.id, - role: incoming.role, - content: incoming.content, - timestamp: incoming.timestamp ?? existing.timestamp, - toolCallId: incoming.toolCallId, - toolName: incoming.toolName, - usage: incoming.usage, - stopReason: incoming.stopReason, - errorMessage: incoming.errorMessage) + updated[matchIndex] = Self.adoptingCanonicalMessage(incoming, over: existing) self.replaceMessages(Self.dedupeMessages(updated)) self.prunePendingLocalUserEchoMessageIDs() return true } - private func adoptProvisionalFinalMessage(incoming: OpenClawChatMessage) -> Bool { - guard let incomingKey = Self.finalMessageReconciliationKey(for: incoming) else { return false } - let canonicalScope = self.currentRunMessageScope() - let searchStart = Self.indexAfterLatestUserTurn(canonicalScope.latestUserTurn, in: self.messages) - guard searchStart < self.messages.endIndex else { return false } + private func rekeyLocalUserEcho( + messageID: UUID?, + runId: String) -> (pendingMessageID: UUID?, scope: RunMessageScope)? + { + guard let messageID, let matchIndex = self.messages.firstIndex(where: { $0.id == messageID }) else { + return nil + } + let existing = self.messages[matchIndex] + let remoteKey = "\(runId):user" + let canonical = self.messages.last { message in + message.id != messageID && + message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "user" && + Self.normalizedIdempotencyKey(message.idempotencyKey) == remoteKey + } + var updated = self.messages + updated[matchIndex] = if let canonical { + Self.adoptingCanonicalMessage(canonical, over: existing) + } else { + OpenClawChatMessage( + id: existing.id, + role: existing.role, + content: existing.content, + timestamp: existing.timestamp, + idempotencyKey: remoteKey, + toolCallId: existing.toolCallId, + toolName: existing.toolName, + usage: existing.usage, + stopReason: existing.stopReason, + errorMessage: existing.errorMessage) + } + self.replaceMessages(Self.dedupeMessages(updated)) + guard let survivingIndex = self.messages.firstIndex(where: { message in + message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "user" && + Self.normalizedIdempotencyKey(message.idempotencyKey) == remoteKey + }) else { + return nil + } + let survivingMessage = self.messages[survivingIndex] + let scope = RunMessageScope( + session: self.currentSessionSnapshot(), + latestUserTurn: Self.userTurn(at: survivingIndex, in: self.messages)) + let pendingMessageID = survivingMessage.id == messageID ? messageID : nil + return (pendingMessageID, scope) + } - guard let matchIndex = messages[searchStart...].lastIndex(where: { existing in + private func rescopeProvisionalFinalMessages(runId: String, scope: RunMessageScope) { + let matchingMessageIDs = self.provisionalFinalMessagesByID.compactMap { messageID, provisional in + provisional.runId == runId ? messageID : nil + } + for messageID in matchingMessageIDs { + self.provisionalFinalMessagesByID[messageID]?.scope = scope + } + } + + private func hasRecordedFinalMessage(runId: String) -> Bool { + if self.provisionalFinalMessagesByID.values.contains(where: { $0.runId == runId }) { + return true + } + return self.messages.contains { message in + Self.isAssistantMessage(message) && + Self.normalizedIdempotencyKey(message.idempotencyKey) == runId + } + } + + private func adoptProvisionalFinalMessage(incoming: OpenClawChatMessage) -> Bool { + let incomingRunId = Self.normalizedIdempotencyKey(incoming.idempotencyKey) + let incomingKey = Self.finalMessageReconciliationKey(for: incoming) + guard incomingRunId != nil || incomingKey != nil else { return false } + let canonicalUserTurn = self.currentRunMessageScope().latestUserTurn + guard let matchIndex = messages.indices.last(where: { index in + let existing = self.messages[index] guard let provisional = self.provisionalFinalMessagesByID[existing.id] else { return false } + if let incomingRunId { + return provisional.runId == incomingRunId + } return provisional.reconciliationKey == incomingKey && - Self.isSameUserTurnBoundary(provisional.scope.latestUserTurn, canonicalScope.latestUserTurn) + Self.isSameUserTurnBoundary(provisional.scope.latestUserTurn, canonicalUserTurn) }) else { return false } @@ -839,16 +986,11 @@ public final class OpenClawChatViewModel { let existing = self.messages[matchIndex] let provisional = self.provisionalFinalMessagesByID[existing.id] var updated = self.messages - updated[matchIndex] = OpenClawChatMessage( - id: existing.id, - role: incoming.role, - content: incoming.content, - timestamp: incoming.timestamp ?? existing.timestamp, - toolCallId: incoming.toolCallId, - toolName: incoming.toolName, - usage: incoming.usage, - stopReason: incoming.stopReason, - errorMessage: incoming.errorMessage) + updated.remove(at: matchIndex) + // The durable session.message arrives at its transcript position. A + // steering row may have been delivered after chat.final, so append the + // adopted row here while retaining the provisional UUID. + updated.append(Self.adoptingCanonicalMessage(incoming, over: existing)) self.provisionalFinalMessagesByID.removeValue(forKey: existing.id) if let runId = provisional?.runId { self.runMessageScopesByRunID.removeValue(forKey: runId) @@ -865,36 +1007,27 @@ public final class OpenClawChatViewModel { { guard !previous.isEmpty, !incoming.isEmpty else { return incoming } - var idsByKey: [String: [UUID]] = [:] + var previousMessagesByKey: [String: [OpenClawChatMessage]] = [:] for message in previous { guard let key = Self.messageIdentityKey(for: message) else { continue } - idsByKey[key, default: []].append(message.id) + previousMessagesByKey[key, default: []].append(message) } return incoming.map { message in guard let key = Self.messageIdentityKey(for: message), - var ids = idsByKey[key], - let reusedId = ids.first + var matches = previousMessagesByKey[key], + let existing = matches.first else { return message } - ids.removeFirst() - if ids.isEmpty { - idsByKey.removeValue(forKey: key) + matches.removeFirst() + if matches.isEmpty { + previousMessagesByKey.removeValue(forKey: key) } else { - idsByKey[key] = ids + previousMessagesByKey[key] = matches } - guard reusedId != message.id else { return message } - return OpenClawChatMessage( - id: reusedId, - role: message.role, - content: message.content, - timestamp: message.timestamp, - toolCallId: message.toolCallId, - toolName: message.toolName, - usage: message.usage, - stopReason: message.stopReason, - errorMessage: message.errorMessage) + guard existing.id != message.id else { return message } + return Self.adoptingCanonicalMessage(message, over: existing) } } @@ -913,6 +1046,9 @@ public final class OpenClawChatViewModel { } var reconciled = Self.reconcileMessageIDs(previous: previous, incoming: incoming) + let incomingIdempotencyKeys = Set(reconciled.compactMap { message in + Self.normalizedIdempotencyKey(message.idempotencyKey) + }) let incomingIdentityKeys = Set(reconciled.compactMap(Self.messageIdentityKey(for:))) var remainingIncomingUserRefreshCounts = countKeys( reconciled.compactMap(Self.userRefreshIdentityKey(for:))) @@ -931,6 +1067,24 @@ public final class OpenClawChatViewModel { remainingIncomingUserRefreshCounts[userKey] = remaining - 1 } + // Exact client correlation owns pending-send adoption. A metadata-free + // row is ambiguous across clients, so retain both rather than lose a turn. + let pendingLocalUsers = previous.filter { message in + guard message.role.lowercased() == "user", pendingLocalUserEchoIDs.contains(message.id) else { + return false + } + let matched = Self.normalizedIdempotencyKey(message.idempotencyKey) + .map(incomingIdempotencyKeys.contains) ?? false + guard matched else { return true } + if let userKey = Self.userRefreshIdentityKey(for: message), + let remaining = remainingIncomingUserRefreshCounts[userKey], + remaining > 0 + { + remainingIncomingUserRefreshCounts[userKey] = remaining - 1 + } + return false + } + let lastCanonicalPreviousIndex = previous.lastIndex { message in guard let identityKey = Self.messageIdentityKey(for: message) else { return false } return incomingIdentityKeys.contains(identityKey) @@ -939,11 +1093,9 @@ public final class OpenClawChatViewModel { previous[previous.index(after: index)...] } ?? [] - let pendingLocalUsers = previous.filter { message in - message.role.lowercased() == "user" && pendingLocalUserEchoIDs.contains(message.id) - } let trailingLocalUsers = trailingLocalCandidates.filter { message in guard message.role.lowercased() == "user" else { return false } + guard !pendingLocalUserEchoIDs.contains(message.id) else { return false } guard let identityKey = Self.messageIdentityKey(for: message) else { return true } guard !incomingIdentityKeys.contains(identityKey) else { return false } guard let userKey = Self.userRefreshIdentityKey(for: message) else { return true } @@ -995,6 +1147,9 @@ public final class OpenClawChatViewModel { } private static func dedupeKey(for message: OpenClawChatMessage) -> String? { + if let idempotencyKey = normalizedIdempotencyKey(message.idempotencyKey) { + return "\(message.role)|idempotency|\(idempotencyKey)" + } guard let timestamp = message.timestamp else { return nil } let text = message.content.compactMap(\.text).joined(separator: "\n") .trimmingCharacters(in: .whitespacesAndNewlines) @@ -1311,7 +1466,8 @@ public final class OpenClawChatViewModel { id: userMessageID, role: "user", content: userContent, - timestamp: userMessageTimestamp)) + timestamp: userMessageTimestamp, + idempotencyKey: "\(runId):user")) self.pendingLocalUserEchoMessageIDsByRunID[runId] = userMessageID self.runMessageScopesByRunID[runId] = self.currentRunMessageScope() @@ -1335,21 +1491,39 @@ public final class OpenClawChatViewModel { self.logDiagnostic( "chat.ui transport send accepted sessionKey=\(sessionKey) " + "localRunId=\(runId) remoteRunId=\(response.runId)") + var reusedRunAlreadyFinal = false if response.runId != runId { let pendingUserMessageID = self.pendingLocalUserEchoMessageIDsByRunID.removeValue(forKey: runId) - let runScope = self.runMessageScopesByRunID.removeValue(forKey: runId) + let localRunScope = self.runMessageScopesByRunID.removeValue(forKey: runId) self.clearPendingRun(runId) self.pendingRuns.insert(response.runId) - self.pendingLocalUserEchoMessageIDsByRunID[response.runId] = pendingUserMessageID - self.runMessageScopesByRunID[response.runId] = runScope - self.armPendingRunTimeout(runId: response.runId) + // 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( + messageID: pendingUserMessageID, + runId: response.runId) + self.pendingLocalUserEchoMessageIDsByRunID[response.runId] = rekeyedUserEcho?.pendingMessageID + let remoteRunScope = rekeyedUserEcho?.scope ?? localRunScope ?? self.currentRunMessageScope() + self.runMessageScopesByRunID[response.runId] = remoteRunScope + self.rescopeProvisionalFinalMessages(runId: response.runId, scope: remoteRunScope) + reusedRunAlreadyFinal = self.hasRecordedFinalMessage(runId: response.runId) + if reusedRunAlreadyFinal { + self.clearPendingRun(response.runId) + self.pendingToolCallsById = [:] + self.updateStreamingAssistantText(nil) + } else { + self.armPendingRunTimeout(runId: response.runId) + } } if response.status == "ok" { let historyContext = self.beginHistoryRequest(for: sessionSnapshot) await self.refreshHistoryAfterRun(historyRequest: historyContext) guard self.isCurrentSession(sessionSnapshot) else { return } self.finishPendingRunAfterTerminalOkSendAck(response) - } else if !self.finishPendingRunIfTerminalSendAck(response) { + } else if !self.finishPendingRunIfTerminalSendAck(response), + !reusedRunAlreadyFinal + { let historyContext = self.beginHistoryRequest(for: sessionSnapshot) await self.refreshHistoryAfterRun(historyRequest: historyContext) guard self.isCurrentSession(sessionSnapshot) else { return } @@ -1999,9 +2173,10 @@ public final class OpenClawChatViewModel { // just sent. performSend already appended an optimistic row carrying a // local client timestamp, while the echo carries a server timestamp, so // the timestamp-keyed identity/dedupe paths below never collapse them. - // Adopt the server record only onto a still-visible row created by this - // client's pending send; same-content user turns from other clients must append. - if self.adoptPendingLocalUserEcho(incoming: sanitized) { + // 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) { return } if self.adoptProvisionalFinalMessage(incoming: sanitized) { @@ -2133,6 +2308,7 @@ public final class OpenClawChatViewModel { role: message.role, content: message.content, timestamp: Date().timeIntervalSince1970 * 1000, + idempotencyKey: message.idempotencyKey, toolCallId: message.toolCallId, toolName: message.toolName, usage: message.usage, @@ -2362,20 +2538,34 @@ public final class OpenClawChatViewModel { guard let lastUserIndex = messages.lastIndex(where: { $0.role.lowercased() == "user" }) else { return nil } - guard let refreshKey = userRefreshIdentityKey(for: messages[lastUserIndex]) else { + return self.userTurn(at: lastUserIndex, in: messages) + } + + private static func userTurn( + at userIndex: [OpenClawChatMessage].Index, + in messages: [OpenClawChatMessage]) -> LatestUserTurn? + { + guard messages.indices.contains(userIndex), + messages[userIndex].role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "user" + else { + return nil + } + guard let refreshKey = userRefreshIdentityKey(for: messages[userIndex]) else { return LatestUserTurn( + idempotencyKey: self.normalizedIdempotencyKey(messages[userIndex].idempotencyKey), refreshKey: nil, occurrence: 0, - timestamp: messages[lastUserIndex].timestamp) + timestamp: messages[userIndex].timestamp) } - let occurrence = messages[...lastUserIndex].reduce(into: 0) { count, message in + let occurrence = messages[...userIndex].reduce(into: 0) { count, message in guard self.userRefreshIdentityKey(for: message) == refreshKey else { return } count += 1 } return LatestUserTurn( + idempotencyKey: Self.normalizedIdempotencyKey(messages[userIndex].idempotencyKey), refreshKey: refreshKey, occurrence: occurrence, - timestamp: messages[lastUserIndex].timestamp) + timestamp: messages[userIndex].timestamp) } private static func hasAnsweredUser( @@ -2383,6 +2573,17 @@ public final class OpenClawChatViewModel { in messages: [OpenClawChatMessage]) -> Bool { + // Hooks may transform persisted user content while preserving this key. + // Prefer the durable turn identity so a completed refresh rejects older history. + if let idempotencyKey = user.idempotencyKey { + guard let userIndex = messages.lastIndex(where: { message in + message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "user" && + self.normalizedIdempotencyKey(message.idempotencyKey) == idempotencyKey + }) else { + return false + } + return self.hasAssistantMessage(after: userIndex, in: messages) + } guard let refreshKey = user.refreshKey else { return false } var occurrence = 0 var latestMatchingUserIndex: [OpenClawChatMessage].Index? @@ -2391,14 +2592,7 @@ public final class OpenClawChatViewModel { occurrence += 1 latestMatchingUserIndex = index guard occurrence == user.occurrence else { continue } - let nextIndex = messages.index(after: index) - guard nextIndex < messages.endIndex else { return false } - return messages[nextIndex...].contains { message in - guard message.role.lowercased() == "assistant" else { return false } - let text = message.content.compactMap(\.text).joined(separator: "\n") - .trimmingCharacters(in: .whitespacesAndNewlines) - return !text.isEmpty || message.errorMessage != nil - } + return self.hasAssistantMessage(after: index, in: messages) } guard let latestMatchingUserIndex, messages.lastIndex(where: { $0.role.lowercased() == "user" }) == latestMatchingUserIndex @@ -2411,7 +2605,14 @@ public final class OpenClawChatViewModel { { return false } - let nextIndex = messages.index(after: latestMatchingUserIndex) + return self.hasAssistantMessage(after: latestMatchingUserIndex, in: messages) + } + + private static func hasAssistantMessage( + after userIndex: [OpenClawChatMessage].Index, + in messages: [OpenClawChatMessage]) -> Bool + { + let nextIndex = messages.index(after: userIndex) guard nextIndex < messages.endIndex else { return false } return messages[nextIndex...].contains { message in guard message.role.lowercased() == "assistant" else { return false } diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift index 31da5aca4737..2f38b283e2c1 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift @@ -3,19 +3,34 @@ import OpenClawKit import Testing @testable import OpenClawChatUI -private func chatTextMessage(role: String, text: String, timestamp: Double, contentId: String? = nil) -> AnyCodable { +private func chatTextMessage( + role: String, + text: String, + timestamp: Double, + contentId: String? = nil, + idempotencyKey: String? = nil) -> AnyCodable +{ var content: [String: Any] = ["type": "text", "text": text] if let contentId { content["id"] = contentId } - return AnyCodable([ + var message: [String: Any] = [ "role": role, "content": [content], "timestamp": timestamp, - ]) + ] + if let idempotencyKey { + message["__openclaw"] = ["idempotencyKey": idempotencyKey] + } + return AnyCodable(message) } -private func chatTextModelMessage(role: String, text: String, timestamp: Double) -> OpenClawChatMessage { +private func chatTextModelMessage( + role: String, + text: String, + timestamp: Double, + idempotencyKey: String? = nil) -> OpenClawChatMessage +{ OpenClawChatMessage( role: role, content: [ @@ -26,7 +41,8 @@ private func chatTextModelMessage(role: String, text: String, timestamp: Double) fileName: nil, content: nil), ], - timestamp: timestamp) + timestamp: timestamp, + idempotencyKey: idempotencyKey) } private func chatErrorMessage(role: String, errorMessage: String, timestamp: Double) -> AnyCodable { @@ -39,8 +55,8 @@ private func chatErrorMessage(role: String, errorMessage: String, timestamp: Dou ]) } -fileprivate extension Array where Element == OpenClawChatMessage { - func containsUserText(_ text: String) -> Bool { +extension [OpenClawChatMessage] { + fileprivate func containsUserText(_ text: String) -> Bool { self.contains { message in message.role == "user" && message.content.contains { $0.text == text } @@ -142,6 +158,7 @@ private func makeViewModel( modelResponses: [[OpenClawChatModelChoice]] = [], commandResponses: [[OpenClawChatCommandChoice]] = [], requestHistoryHook: (@Sendable (String) async throws -> Void)? = nil, + historyResponseHook: (@Sendable (String, Int, [String]) async throws -> OpenClawChatHistoryPayload?)? = nil, setActiveSessionHook: (@Sendable (String) async throws -> Void)? = nil, createSessionHook: (@Sendable (String, String?) async throws -> Void)? = nil, resetSessionHook: (@Sendable (String) async throws -> Void)? = nil, @@ -149,6 +166,7 @@ private func makeViewModel( setSessionModelHook: (@Sendable (String?) async throws -> Void)? = nil, setSessionThinkingHook: (@Sendable (String) async throws -> Void)? = nil, sendMessageHook: (@Sendable (String) async throws -> OpenClawChatSendResponse)? = nil, + sendMessageStatus: String = "ok", waitForRunCompletionHook: (@Sendable (String, Int) async -> Bool)? = nil, healthResponses: [Bool] = [true], initialThinkingLevel: String? = nil, @@ -162,6 +180,7 @@ private func makeViewModel( modelResponses: modelResponses, commandResponses: commandResponses, requestHistoryHook: requestHistoryHook, + historyResponseHook: historyResponseHook, setActiveSessionHook: setActiveSessionHook, createSessionHook: createSessionHook, resetSessionHook: resetSessionHook, @@ -169,6 +188,7 @@ private func makeViewModel( setSessionModelHook: setSessionModelHook, setSessionThinkingHook: setSessionThinkingHook, sendMessageHook: sendMessageHook, + sendMessageStatus: sendMessageStatus, waitForRunCompletionHook: waitForRunCompletionHook, healthResponses: healthResponses) let vm = await MainActor.run { @@ -315,14 +335,17 @@ private final class CallbackBox { private actor AsyncGate { private var continuation: CheckedContinuation? + private var isOpen = false func wait() async { + guard !self.isOpen else { return } await withCheckedContinuation { continuation in self.continuation = continuation } } func open() { + self.isOpen = true self.continuation?.resume() self.continuation = nil } @@ -392,6 +415,8 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor private let modelResponses: [[OpenClawChatModelChoice]] private let commandResponses: [[OpenClawChatCommandChoice]] private let requestHistoryHook: (@Sendable (String) async throws -> Void)? + private let historyResponseHook: + (@Sendable (String, Int, [String]) async throws -> OpenClawChatHistoryPayload?)? private let setActiveSessionHook: (@Sendable (String) async throws -> Void)? private let createSessionHook: (@Sendable (String, String?) async throws -> Void)? private let resetSessionHook: (@Sendable (String) async throws -> Void)? @@ -399,6 +424,7 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor private let setSessionModelHook: (@Sendable (String?) async throws -> Void)? private let setSessionThinkingHook: (@Sendable (String) async throws -> Void)? private let sendMessageHook: (@Sendable (String) async throws -> OpenClawChatSendResponse)? + private let sendMessageStatus: String private let waitForRunCompletionHook: (@Sendable (String, Int) async -> Bool)? private let healthResponses: [Bool] @@ -411,6 +437,7 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor modelResponses: [[OpenClawChatModelChoice]] = [], commandResponses: [[OpenClawChatCommandChoice]] = [], requestHistoryHook: (@Sendable (String) async throws -> Void)? = nil, + historyResponseHook: (@Sendable (String, Int, [String]) async throws -> OpenClawChatHistoryPayload?)? = nil, setActiveSessionHook: (@Sendable (String) async throws -> Void)? = nil, createSessionHook: (@Sendable (String, String?) async throws -> Void)? = nil, resetSessionHook: (@Sendable (String) async throws -> Void)? = nil, @@ -418,6 +445,7 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor setSessionModelHook: (@Sendable (String?) async throws -> Void)? = nil, setSessionThinkingHook: (@Sendable (String) async throws -> Void)? = nil, sendMessageHook: (@Sendable (String) async throws -> OpenClawChatSendResponse)? = nil, + sendMessageStatus: String = "ok", waitForRunCompletionHook: (@Sendable (String, Int) async -> Bool)? = nil, healthResponses: [Bool] = [true]) { @@ -426,6 +454,7 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor self.modelResponses = modelResponses self.commandResponses = commandResponses self.requestHistoryHook = requestHistoryHook + self.historyResponseHook = historyResponseHook self.setActiveSessionHook = setActiveSessionHook self.createSessionHook = createSessionHook self.resetSessionHook = resetSessionHook @@ -433,6 +462,7 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor self.setSessionModelHook = setSessionModelHook self.setSessionThinkingHook = setSessionThinkingHook self.sendMessageHook = sendMessageHook + self.sendMessageStatus = sendMessageStatus self.waitForRunCompletionHook = waitForRunCompletionHook self.healthResponses = healthResponses var cont: AsyncStream.Continuation! @@ -471,6 +501,12 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor if let requestHistoryHook { try await requestHistoryHook(sessionKey) } + if let historyResponseHook { + let sentRunIds = await self.sentRunIds() + if let response = try await historyResponseHook(sessionKey, idx, sentRunIds) { + return response + } + } if idx < self.historyResponses.count { return self.historyResponses[idx] } @@ -495,7 +531,7 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor if let sendMessageHook { return try await sendMessageHook(idempotencyKey) } - return OpenClawChatSendResponse(runId: idempotencyKey, status: "ok") + return OpenClawChatSendResponse(runId: idempotencyKey, status: self.sendMessageStatus) } func abortRun(sessionKey _: String, runId: String) async throws { @@ -623,6 +659,10 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor await self.state.patchedThinkingLevels } + func healthCallCount() async -> Int { + await self.state.healthCallCount + } + func resetSessionKeys() async -> [String] { await self.state.resetSessionKeys } @@ -728,6 +768,27 @@ extension TestChatTransportState { } struct ChatViewModelTests { + @Test func `keeps distinct idempotent user turns with identical timestamps and content`() async throws { + let history = historyPayload( + messages: [ + chatTextMessage( + role: "user", + text: "same words", + timestamp: 1, + idempotencyKey: "client-a:user"), + chatTextMessage( + role: "user", + text: "same words", + timestamp: 1, + idempotencyKey: "client-b:user"), + ]) + let (_, vm) = await makeViewModel(historyResponses: [history]) + + try await loadAndWaitBootstrap(vm: vm) + + #expect(await MainActor.run { vm.messages.count } == 2) + } + @Test func `timeline revision advances when visible history changes`() async throws { let history = historyPayload( sessionId: "revision-session", @@ -840,7 +901,9 @@ struct ChatViewModelTests { timestamp: Date().timeIntervalSince1970 * 1000), ]) - let (transport, vm) = await makeViewModel(historyResponses: [history1, history2]) + let (transport, vm) = await makeViewModel( + historyResponses: [history1, history2], + sendMessageStatus: "pending") try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId) await sendUserMessage(vm) try await waitUntil("pending run starts") { await MainActor.run { vm.pendingRunCount == 1 } } @@ -876,7 +939,9 @@ struct ChatViewModelTests { @Test func `renders final chat event message when history is stale`() async throws { let sessionId = "sess-main" let history = historyPayload(sessionId: sessionId) - let (transport, vm) = await makeViewModel(historyResponses: [history, history]) + let (transport, vm) = await makeViewModel( + historyResponses: [history, history], + sendMessageStatus: "pending") try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId) await sendUserMessage(vm, text: "hello") @@ -919,7 +984,8 @@ struct ChatViewModelTests { if count == 2 { await finalRefreshGate.wait() } - }) + }, + sendMessageStatus: "pending") try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId) await sendUserMessage(vm, text: "hello") @@ -980,7 +1046,8 @@ struct ChatViewModelTests { if count == 2 { await finalRefreshGate.wait() } - }) + }, + sendMessageStatus: "pending") try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId) await sendUserMessage(vm, text: "hello") @@ -1090,6 +1157,7 @@ struct ChatViewModelTests { ]) let (transport, vm) = await makeViewModel( historyResponses: [history1, history2, history3], + sendMessageStatus: "pending", waitForRunCompletionHook: { _, _ in true }) try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId) @@ -1124,7 +1192,9 @@ struct ChatViewModelTests { text: "completed from lifecycle", timestamp: now + 60000), ]) - let (transport, vm) = await makeViewModel(historyResponses: [history1, history2, history3]) + let (transport, vm) = await makeViewModel( + historyResponses: [history1, history2, history3], + sendMessageStatus: "pending") try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId) await sendUserMessage(vm, text: "hello") @@ -1151,7 +1221,9 @@ struct ChatViewModelTests { @Test func `pending run blocks second main send`() async throws { let sessionId = "sess-main" let history = historyPayload(sessionId: sessionId, messages: []) - let (transport, vm) = await makeViewModel(historyResponses: [history, history]) + let (transport, vm) = await makeViewModel( + historyResponses: [history, history], + sendMessageStatus: "pending") try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId) await sendUserMessage(vm, text: "first") @@ -1173,7 +1245,7 @@ struct ChatViewModelTests { #expect(await MainActor.run { vm.input } == "second") } - @Test func terminalOkSendAckClearsPendingRunWithoutWaitingForCompletion() async throws { + @Test func `terminal ok send ack clears pending run without waiting for completion`() async throws { let sessionId = "sess-main" let history = historyPayload(sessionId: sessionId, messages: []) let (transport, vm) = await makeViewModel(historyResponses: [history, history]) @@ -1189,7 +1261,742 @@ struct ChatViewModelTests { #expect(await MainActor.run { vm.messages.containsUserText("cached") }) } - @Test func terminalTimeoutSendAckSurfacesErrorAndAllowsNextSend() async throws { + @Test func `rekeys optimistic user message when gateway reuses active run`() async throws { + let sessionId = "sess-main" + let remoteRunId = "existing-active-run" + let now = Date().timeIntervalSince1970 * 1000 + let responseGate = AsyncGate() + let (_, vm) = await makeViewModel( + historyResponses: [historyPayload(sessionId: sessionId)], + historyResponseHook: { _, index, _ in + guard index == 1 else { return nil } + return historyPayload( + sessionId: sessionId, + messages: [ + chatTextMessage( + role: "user", + text: "same active request", + timestamp: now + 5000, + idempotencyKey: "\(remoteRunId):user"), + ]) + }, + sendMessageHook: { _ in + await responseGate.wait() + return OpenClawChatSendResponse(runId: remoteRunId, status: "in_flight") + }) + try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId) + + await sendUserMessage(vm, text: "same active request") + let optimisticID = try await MainActor.run { + try #require(vm.messages.last(where: { $0.role == "user" })?.id) + } + await responseGate.open() + + try await waitUntil("reused run adopts one canonical user row") { + await MainActor.run { + vm.messages.count(where: { $0.role == "user" }) == 1 && + vm.messages.contains(where: { message in + message.id == optimisticID && + message.timestamp == now + 5000 && + message.idempotencyKey == "\(remoteRunId):user" + }) + } + } + } + + @Test func `reused run preserves canonical event received before acknowledgement`() async throws { + let sessionId = "sess-main" + let remoteRunId = "existing-active-run" + let now = Date().timeIntervalSince1970 * 1000 + let responseGate = AsyncGate() + let (transport, vm) = await makeViewModel( + historyResponses: [ + historyPayload(sessionId: sessionId), + historyPayload(sessionId: sessionId, messages: []), + ], + sendMessageHook: { _ in + await responseGate.wait() + return OpenClawChatSendResponse(runId: remoteRunId, status: "in_flight") + }) + try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId) + + await sendUserMessage(vm, text: "same active request") + let optimisticID = try await MainActor.run { + try #require(vm.messages.last(where: { $0.role == "user" })?.id) + } + let canonicalTimestamp = now + 5000 + transport.emit( + .sessionMessage( + OpenClawSessionMessageEventPayload( + sessionKey: "agent:main:main", + message: OpenClawChatMessage( + role: "user", + content: [ + OpenClawChatMessageContent( + type: "text", + text: "canonical active request", + mimeType: nil, + fileName: nil, + content: nil), + ], + timestamp: canonicalTimestamp, + idempotencyKey: "\(remoteRunId):user"), + messageId: "srv-reused-run-user", + messageSeq: 1))) + try await waitUntil("canonical event arrives before send acknowledgement") { + await MainActor.run { vm.messages.count(where: { $0.role == "user" }) == 2 } + } + await responseGate.open() + + try await waitUntil("reused run preserves canonical event data") { + await MainActor.run { + vm.messages.count(where: { $0.role == "user" }) == 1 && + vm.messages.contains(where: { message in + message.id == optimisticID && + message.content.first?.text == "canonical active request" && + message.timestamp == canonicalTimestamp && + message.idempotencyKey == "\(remoteRunId):user" + }) + } + } + } + + @Test func `reused run final stays scoped to surviving canonical user turn`() async throws { + let sessionId = "sess-main" + let remoteRunId = "existing-active-run" + let now = Date().timeIntervalSince1970 * 1000 + let activeUser = chatTextMessage( + role: "user", + text: "same active request", + timestamp: now + 1, + idempotencyKey: "\(remoteRunId):user") + let activeReply = chatTextMessage( + role: "assistant", + text: "active reply", + timestamp: now + 2, + idempotencyKey: remoteRunId) + let newerUser = chatTextMessage( + role: "user", + text: "newer request from another client", + timestamp: now + 3, + idempotencyKey: "other-client-run:user") + let initialHistory = historyPayload(sessionId: sessionId, messages: [activeUser]) + let canonicalHistory = historyPayload( + sessionId: sessionId, + messages: [activeUser, activeReply, newerUser]) + let responseGate = AsyncGate() + let (transport, vm) = await makeViewModel( + historyResponses: [initialHistory, canonicalHistory, canonicalHistory], + sendMessageHook: { _ in + await responseGate.wait() + return OpenClawChatSendResponse(runId: remoteRunId, status: "in_flight") + }) + try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId) + + await sendUserMessage(vm, text: "same active request") + transport.emit( + .sessionMessage( + OpenClawSessionMessageEventPayload( + sessionKey: "agent:main:main", + message: chatTextModelMessage( + role: "assistant", + text: "active reply", + timestamp: now + 2, + idempotencyKey: remoteRunId), + messageId: "srv-active-reply", + messageSeq: 2))) + transport.emit( + .sessionMessage( + OpenClawSessionMessageEventPayload( + sessionKey: "agent:main:main", + message: chatTextModelMessage( + role: "user", + text: "newer request from another client", + timestamp: now + 3, + idempotencyKey: "other-client-run:user"), + messageId: "srv-newer-user", + messageSeq: 3))) + try await waitUntil("newer user arrives before reused-run acknowledgement") { + await MainActor.run { vm.messages.containsUserText("newer request from another client") } + } + await responseGate.open() + try await waitUntil("reused run collapses onto earlier canonical user") { + await MainActor.run { + vm.messages.count(where: { $0.role == "user" && $0.content.first?.text == "same active request" }) == 1 + } + } + + transport.emit( + .chat( + OpenClawChatEventPayload( + runId: remoteRunId, + sessionKey: "main", + state: "final", + message: activeReply, + errorMessage: nil))) + + try await waitUntil("reused final does not duplicate earlier canonical reply") { + await MainActor.run { + vm.pendingRunCount == 0 && + vm.messages + .count(where: { $0.role == "assistant" && $0.content.first?.text == "active reply" }) == 1 + } + } + } + + @Test func `newer identical reply does not suppress reused run final`() async throws { + let sessionId = "sess-main" + let remoteRunId = "existing-active-run" + let now = Date().timeIntervalSince1970 * 1000 + let responseGate = AsyncGate() + let finalRefreshGate = SessionSubscribeGate() + let historyCount = AsyncCounter() + let history = historyPayload( + sessionId: sessionId, + messages: [ + chatTextMessage( + role: "user", + text: "same active request", + timestamp: now - 3000, + idempotencyKey: "\(remoteRunId):user"), + chatTextMessage( + role: "user", + text: "newer request from another client", + timestamp: now - 2000, + idempotencyKey: "other-client-run:user"), + chatTextMessage( + role: "assistant", + text: "OK", + timestamp: now - 1000), + ]) + let (transport, vm) = await makeViewModel( + historyResponses: [history, history], + requestHistoryHook: { _ in + let count = await historyCount.increment() + if count == 3 { + await finalRefreshGate.wait() + } + }, + sendMessageHook: { _ in + await responseGate.wait() + return OpenClawChatSendResponse(runId: remoteRunId, status: "in_flight") + }) + try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId) + + await sendUserMessage(vm, text: "same active request") + try await waitUntil("duplicate request is optimistic") { + await MainActor.run { + vm.messages.count(where: { $0.role == "user" && $0.content.first?.text == "same active request" }) == 2 + } + } + await responseGate.open() + try await waitUntil("duplicate request collapses onto older active user") { + guard await historyCount.current() >= 2 else { return false } + return await MainActor.run { + vm.messages.count(where: { + $0.role == "user" && $0.content.first?.text == "same active request" + }) == 1 + } + } + + transport.emit( + .chat( + OpenClawChatEventPayload( + runId: remoteRunId, + sessionKey: "main", + state: "final", + message: chatTextMessage( + role: "assistant", + text: "OK", + timestamp: now + 4, + idempotencyKey: remoteRunId), + errorMessage: nil))) + + try await waitUntil("reused final remains distinct from newer turn reply") { + await MainActor.run { + vm.pendingRunCount == 0 && + vm.messages.count(where: { $0.role == "assistant" && $0.content.first?.text == "OK" }) == 2 + } + } + await finalRefreshGate.release() + } + + @Test func `correlated reply after metadata free steering suppresses reused final duplicate`() async throws { + let sessionId = "sess-main" + let remoteRunId = "existing-active-run" + let now = Date().timeIntervalSince1970 * 1000 + let responseGate = AsyncGate() + let history = historyPayload( + sessionId: sessionId, + messages: [ + chatTextMessage( + role: "user", + text: "same active request", + timestamp: now - 3000, + idempotencyKey: "\(remoteRunId):user"), + chatTextMessage( + role: "user", + text: "steer the active run", + timestamp: now - 2000), + chatTextMessage( + role: "assistant", + text: "steered reply", + timestamp: now - 1000, + idempotencyKey: remoteRunId), + ]) + let (transport, vm) = await makeViewModel( + historyResponses: [history, history], + sendMessageHook: { _ in + await responseGate.wait() + return OpenClawChatSendResponse(runId: remoteRunId, status: "in_flight") + }) + try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId) + + await sendUserMessage(vm, text: "same active request") + try await waitUntil("steered duplicate request is optimistic") { + await MainActor.run { + vm.messages.count(where: { $0.role == "user" && $0.content.first?.text == "same active request" }) == 2 + } + } + await responseGate.open() + try await waitUntil("steered duplicate request collapses onto active user") { + await MainActor.run { + vm.messages.count(where: { + $0.role == "user" && $0.content.first?.text == "same active request" + }) == 1 + } + } + + transport.emit( + .chat( + OpenClawChatEventPayload( + runId: remoteRunId, + sessionKey: "main", + state: "final", + message: chatTextMessage( + role: "assistant", + text: "steered reply", + timestamp: now + 1, + idempotencyKey: remoteRunId), + errorMessage: nil))) + + try await waitUntil("steering row remains inside reused run scope") { + await MainActor.run { + vm.pendingRunCount == 0 && + vm.messages.count(where: { + $0.role == "assistant" && $0.content.first?.text == "steered reply" + }) == 1 + } + } + } + + @Test func `canonical projected reply after steering adopts reused provisional final`() async throws { + let sessionId = "sess-main" + let remoteRunId = "existing-active-run" + let now = Date().timeIntervalSince1970 * 1000 + let finalRefreshGate = SessionSubscribeGate() + let historyCount = AsyncCounter() + let history = historyPayload( + sessionId: sessionId, + messages: [ + chatTextMessage( + role: "user", + text: "same active request", + timestamp: now - 1000, + idempotencyKey: "\(remoteRunId):user"), + ]) + let (transport, vm) = await makeViewModel( + historyResponses: [history, history], + requestHistoryHook: { _ in + let count = await historyCount.increment() + if count == 3 { + await finalRefreshGate.wait() + } + }, + sendMessageHook: { _ in + OpenClawChatSendResponse(runId: remoteRunId, status: "in_flight") + }) + try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId) + + await sendUserMessage(vm, text: "same active request") + try await waitUntil("steering adoption send refresh completes") { + await historyCount.current() >= 2 + } + transport.emit( + .chat( + OpenClawChatEventPayload( + runId: remoteRunId, + sessionKey: "main", + state: "final", + message: chatTextMessage( + role: "assistant", + text: "steered reply", + timestamp: now + 1, + idempotencyKey: remoteRunId), + errorMessage: nil))) + try await waitUntil("provisional reply precedes steering row") { + await MainActor.run { + vm.messages.contains { $0.role == "assistant" && $0.content.first?.text == "steered reply" } + } + } + let provisionalID = try await MainActor.run { + try #require(vm.messages.first(where: { + $0.role == "assistant" && $0.content.first?.text == "steered reply" + })?.id) + } + + transport.emit( + .sessionMessage( + OpenClawSessionMessageEventPayload( + sessionKey: "agent:main:main", + message: chatTextModelMessage( + role: "user", + text: "steer the active run", + timestamp: now + 2), + messageId: "srv-steering-user", + messageSeq: 2))) + transport.emit( + .sessionMessage( + OpenClawSessionMessageEventPayload( + sessionKey: "agent:main:main", + message: chatTextModelMessage( + role: "assistant", + text: "canonical steered reply", + timestamp: now + 3, + idempotencyKey: remoteRunId), + messageId: "srv-steered-reply", + messageSeq: 3))) + + try await waitUntil("canonical reply after steering adopts provisional row") { + await MainActor.run { + guard vm.messages.count(where: { $0.role == "assistant" }) == 1, + let reply = vm.messages.first(where: { $0.id == provisionalID }), + reply.content.first?.text == "canonical steered reply", + reply.timestamp == now + 3, + let steeringIndex = vm.messages.firstIndex(where: { + $0.role == "user" && $0.content.first?.text == "steer the active run" + }), + let replyIndex = vm.messages.firstIndex(where: { $0.id == provisionalID }) + else { + return false + } + return steeringIndex < replyIndex + } + } + await finalRefreshGate.release() + } + + @Test func `metadata free channel turn does not adopt reused provisional final`() async throws { + let sessionId = "sess-main" + let remoteRunId = "existing-active-run" + let now = Date().timeIntervalSince1970 * 1000 + let finalRefreshGate = SessionSubscribeGate() + let historyCount = AsyncCounter() + let history = historyPayload( + sessionId: sessionId, + messages: [ + chatTextMessage( + role: "user", + text: "same active request", + timestamp: now - 1000, + idempotencyKey: "\(remoteRunId):user"), + ]) + let (transport, vm) = await makeViewModel( + historyResponses: [history, history], + requestHistoryHook: { _ in + let count = await historyCount.increment() + if count == 3 { + await finalRefreshGate.wait() + } + }, + sendMessageHook: { _ in + OpenClawChatSendResponse(runId: remoteRunId, status: "in_flight") + }) + try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId) + + await sendUserMessage(vm, text: "same active request") + try await waitUntil("channel boundary send refresh completes") { + await historyCount.current() >= 2 + } + transport.emit( + .chat( + OpenClawChatEventPayload( + runId: remoteRunId, + sessionKey: "main", + state: "final", + message: chatTextMessage( + role: "assistant", + text: "same reply", + timestamp: now + 1, + idempotencyKey: remoteRunId), + errorMessage: nil))) + try await waitUntil("reused provisional reply is visible") { + await MainActor.run { + vm.messages.contains { $0.role == "assistant" && $0.content.first?.text == "same reply" } + } + } + let provisionalID = try await MainActor.run { + try #require(vm.messages.first(where: { + $0.role == "assistant" && $0.content.first?.text == "same reply" + })?.id) + } + + transport.emit( + .sessionMessage( + OpenClawSessionMessageEventPayload( + sessionKey: "agent:main:main", + message: chatTextModelMessage( + role: "user", + text: "independent channel request", + timestamp: now + 2), + messageId: "srv-channel-user", + messageSeq: 2))) + transport.emit( + .sessionMessage( + OpenClawSessionMessageEventPayload( + sessionKey: "agent:main:main", + message: chatTextModelMessage( + role: "assistant", + text: "same reply", + timestamp: now + 3), + messageId: "srv-channel-reply", + messageSeq: 3))) + + try await waitUntil("independent channel reply remains distinct") { + await MainActor.run { + let replies = vm.messages.filter { + $0.role == "assistant" && $0.content.first?.text == "same reply" + } + guard replies.count == 2, replies.first?.id == provisionalID else { return false } + guard let userIndex = vm.messages.firstIndex(where: { + $0.role == "user" && $0.content.first?.text == "independent channel request" + }), + let canonicalIndex = vm.messages.firstIndex(where: { $0.id == replies[1].id }) + else { + return false + } + return userIndex < canonicalIndex + } + } + await finalRefreshGate.release() + } + + @Test func `late transformed canonical user keeps reused run final scope`() async throws { + let sessionId = "sess-main" + let remoteRunId = "existing-active-run" + let now = Date().timeIntervalSince1970 * 1000 + let responseGate = AsyncGate() + let finalRefreshGate = SessionSubscribeGate() + let historyCount = AsyncCounter() + let emptyHistory = historyPayload(sessionId: sessionId) + let (transport, vm) = await makeViewModel( + historyResponses: [emptyHistory, emptyHistory], + requestHistoryHook: { _ in + let count = await historyCount.increment() + if count == 3 { + await finalRefreshGate.wait() + } + }, + sendMessageHook: { _ in + await responseGate.wait() + return OpenClawChatSendResponse(runId: remoteRunId, status: "in_flight") + }) + try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId) + + await sendUserMessage(vm, text: "same active request") + await responseGate.open() + try await waitUntil("optimistic user adopts reused run identity") { + await MainActor.run { + vm.messages.contains(where: { message in + message.role == "user" && message.idempotencyKey == "\(remoteRunId):user" + }) + } + } + try await waitUntil("post-ack history refresh completes") { + await historyCount.current() >= 2 + } + + transport.emit( + .sessionMessage( + OpenClawSessionMessageEventPayload( + sessionKey: "agent:main:main", + message: chatTextModelMessage( + role: "user", + text: "canonical redacted request", + timestamp: now + 1, + idempotencyKey: "\(remoteRunId):user"), + messageId: "srv-transformed-user", + messageSeq: 1))) + transport.emit( + .sessionMessage( + OpenClawSessionMessageEventPayload( + sessionKey: "agent:main:main", + message: chatTextModelMessage( + role: "assistant", + text: "active reply", + timestamp: now + 2, + idempotencyKey: remoteRunId), + messageId: "srv-active-reply", + messageSeq: 2))) + transport.emit( + .sessionMessage( + OpenClawSessionMessageEventPayload( + sessionKey: "agent:main:main", + message: chatTextModelMessage( + role: "user", + text: "newer request from another client", + timestamp: now + 3, + idempotencyKey: "other-client-run:user"), + messageId: "srv-newer-user", + messageSeq: 3))) + try await waitUntil("canonical user transformation and newer turn arrive") { + await MainActor.run { + vm.messages.containsUserText("canonical redacted request") && + vm.messages.containsUserText("newer request from another client") + } + } + + transport.emit( + .chat( + OpenClawChatEventPayload( + runId: remoteRunId, + sessionKey: "main", + state: "final", + message: chatTextMessage( + role: "assistant", + text: "active reply", + timestamp: now + 4, + idempotencyKey: remoteRunId), + errorMessage: nil))) + + try await waitUntil("late canonical adoption does not duplicate reused final") { + await MainActor.run { + vm.pendingRunCount == 0 && + vm.messages + .count(where: { $0.role == "assistant" && $0.content.first?.text == "active reply" }) == 1 + } + } + await finalRefreshGate.release() + } + + @Test func `history reconciled early final stays in canonical order after delayed event`() async throws { + let sessionId = "sess-main" + let remoteRunId = "existing-active-run" + let now = Date().timeIntervalSince1970 * 1000 + let responseGate = AsyncGate() + let historyGate = AsyncGate() + let emptyHistory = historyPayload(sessionId: sessionId) + let canonicalHistory = historyPayload( + sessionId: sessionId, + messages: [ + chatTextMessage( + role: "user", + text: "same active request", + timestamp: now, + idempotencyKey: "\(remoteRunId):user"), + chatTextMessage( + role: "assistant", + text: "early final reply", + timestamp: now + 2, + idempotencyKey: remoteRunId), + chatTextMessage( + role: "user", + text: "newer channel request", + timestamp: now + 3), + ]) + let (transport, vm) = await makeViewModel( + historyResponses: [emptyHistory, canonicalHistory], + historyResponseHook: { _, index, _ in + guard index == 1 else { return nil } + await historyGate.wait() + return canonicalHistory + }, + sendMessageHook: { _ in + await responseGate.wait() + return OpenClawChatSendResponse(runId: remoteRunId, status: "in_flight") + }) + try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId) + + await sendUserMessage(vm, text: "same active request") + transport.emit( + .chat( + OpenClawChatEventPayload( + runId: remoteRunId, + sessionKey: "main", + state: "final", + message: chatTextMessage( + role: "assistant", + text: "early final reply", + timestamp: now + 1, + idempotencyKey: remoteRunId), + errorMessage: nil))) + try await waitUntil("early final is visible before acknowledgement") { + await MainActor.run { + vm.messages.contains { + $0.role == "assistant" && $0.content.first?.text == "early final reply" + } + } + } + let provisionalID = try await MainActor.run { + try #require(vm.messages.first(where: { + $0.role == "assistant" && $0.content.first?.text == "early final reply" + })?.id) + } + + await historyGate.open() + try await waitUntil("history adopts early final before newer user") { + await MainActor.run { + guard let replyIndex = vm.messages.firstIndex(where: { $0.id == provisionalID }), + let newerUserIndex = vm.messages.firstIndex(where: { + $0.role == "user" && $0.content.first?.text == "newer channel request" + }) + else { + return false + } + return replyIndex < newerUserIndex && vm.messages[replyIndex].timestamp == now + 2 + } + } + + await responseGate.open() + try await waitUntil("early final run acknowledgement rekeys optimistic user") { + await MainActor.run { + vm.pendingRunCount == 0 && + vm.messages.count(where: { + $0.role == "user" && $0.idempotencyKey == "\(remoteRunId):user" + }) == 1 + } + } + + transport.emit( + .sessionMessage( + OpenClawSessionMessageEventPayload( + sessionKey: "agent:main:main", + message: chatTextModelMessage( + role: "assistant", + text: "early final reply", + timestamp: now + 4, + idempotencyKey: remoteRunId), + messageId: "srv-early-final-reply", + messageSeq: 2))) + + try await waitUntil("delayed canonical event preserves history order") { + await MainActor.run { + guard vm.messages.count(where: { + $0.role == "assistant" && $0.idempotencyKey == remoteRunId + }) == 1, + let replyIndex = vm.messages.firstIndex(where: { $0.id == provisionalID }), + let newerUserIndex = vm.messages.firstIndex(where: { + $0.role == "user" && $0.content.first?.text == "newer channel request" + }) + else { + return false + } + return replyIndex < newerUserIndex && vm.messages[replyIndex].timestamp == now + 2 + } + } + } + + @Test func `terminal timeout send ack surfaces error and allows next send`() async throws { let sessionId = "sess-main" let history = historyPayload(sessionId: sessionId, messages: []) let sendCount = AsyncCounter() @@ -1230,7 +2037,9 @@ struct ChatViewModelTests { timestamp: now + 1), ]) - let (transport, vm) = await makeViewModel(historyResponses: [history1, history2]) + let (transport, vm) = await makeViewModel( + historyResponses: [history1, history2], + sendMessageStatus: "pending") try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId) try await sendMessageAndEmitFinal( transport: transport, @@ -1253,7 +2062,9 @@ struct ChatViewModelTests { let history1 = historyPayload(sessionId: sessionId) let history2 = historyPayload(sessionId: sessionId, messages: []) - let (transport, vm) = await makeViewModel(historyResponses: [history1, history2]) + let (transport, vm) = await makeViewModel( + historyResponses: [history1, history2], + sendMessageStatus: "pending") try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId) try await sendMessageAndEmitFinal( transport: transport, @@ -1273,28 +2084,52 @@ struct ChatViewModelTests { @Test func `does not duplicate user message when refresh returns canonical timestamp`() async throws { let sessionId = "sess-main" let now = Date().timeIntervalSince1970 * 1000 - let history1 = historyPayload(sessionId: sessionId) - let history2 = historyPayload( + let refreshGate = AsyncGate() + let historyCallCount = AsyncCounter() + let history1 = historyPayload( sessionId: sessionId, messages: [ - chatTextMessage( - role: "user", - text: "hello from mac webchat", - timestamp: now + 5000), chatTextMessage( role: "assistant", - text: "final answer", - timestamp: now + 6000), + text: "earlier answer", + timestamp: now + 1000), ]) - - let (transport, vm) = await makeViewModel(historyResponses: [history1, history2]) + let (_, vm) = await makeViewModel( + historyResponses: [history1], + requestHistoryHook: { _ in + if await historyCallCount.increment() == 2 { + await refreshGate.wait() + } + }, + historyResponseHook: { _, index, sentRunIds in + guard index == 1, let runId = sentRunIds.last else { return nil } + return historyPayload( + sessionId: sessionId, + messages: [ + chatTextMessage( + role: "assistant", + text: "earlier answer", + timestamp: now + 1000), + chatTextMessage( + role: "user", + text: "hello from mac webchat", + timestamp: now + 5000, + idempotencyKey: "\(runId):user"), + chatTextMessage( + role: "assistant", + text: "final answer", + timestamp: now + 6000), + ]) + }) try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId) - try await sendMessageAndEmitFinal( - transport: transport, - vm: vm, - text: "hello from mac webchat") + await sendUserMessage(vm, text: "hello from mac webchat") + try await waitUntil("canonical refresh starts") { await historyCallCount.current() == 2 } + let optimisticID = try await MainActor.run { + try #require(vm.messages.last(where: { $0.role == "user" })?.id) + } + await refreshGate.open() - try await waitUntil("canonical refresh keeps one user message") { + try await waitUntil("send acknowledgement refresh keeps one user message") { await MainActor.run { let userMessages = vm.messages.filter { message in message.role == "user" && @@ -1307,26 +2142,116 @@ struct ChatViewModelTests { return hasAssistant && userMessages.count == 1 } } + #expect(await MainActor.run { vm.messages.last(where: { $0.role == "user" })?.id } == optimisticID) + } + + @Test func `metadata free canonical refresh keeps ambiguous user turns distinct`() async throws { + let sessionId = "sess-main" + let now = Date().timeIntervalSince1970 * 1000 + let refreshGate = AsyncGate() + let historyCallCount = AsyncCounter() + let (_, vm) = await makeViewModel( + historyResponses: [historyPayload(sessionId: sessionId)], + requestHistoryHook: { _ in + if await historyCallCount.increment() == 2 { + await refreshGate.wait() + } + }, + historyResponseHook: { _, index, _ in + guard index == 1 else { return nil } + return historyPayload( + sessionId: sessionId, + messages: [ + chatTextMessage( + role: "user", + text: "legacy echo", + timestamp: now + 5000), + chatTextMessage( + role: "assistant", + text: "legacy answer", + timestamp: now + 6000), + ]) + }) + try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId) + await sendUserMessage(vm, text: "legacy echo") + try await waitUntil("legacy refresh starts") { await historyCallCount.current() == 2 } + let optimisticID = try await MainActor.run { + try #require(vm.messages.last(where: { $0.role == "user" })?.id) + } + await refreshGate.open() + + try await waitUntil("metadata free canonical refresh preserves both user rows") { + await MainActor.run { + vm.messages.count(where: { message in + message.role == "user" && + message.content.compactMap(\.text).joined(separator: "\n") == "legacy echo" + }) == 2 && vm.messages.contains(where: { message in + message.role == "assistant" && + message.content.compactMap(\.text).joined(separator: "\n") == "legacy answer" + }) + } + } + #expect(await MainActor.run { vm.messages.contains(where: { $0.id == optimisticID }) }) + } + + @Test func `preserves local echo when another client sends identical text during refresh`() async throws { + let sessionId = "sess-main" + let now = Date().timeIntervalSince1970 * 1000 + let (transport, vm) = await makeViewModel( + historyResponses: [historyPayload(sessionId: sessionId)], + historyResponseHook: { _, index, _ in + guard index == 1 else { return nil } + return historyPayload( + sessionId: sessionId, + messages: [ + chatTextMessage( + role: "user", + text: "same words", + timestamp: now + 5000, + idempotencyKey: "other-client:user"), + chatTextMessage( + role: "assistant", + text: "other client's answer", + timestamp: now + 6000), + ]) + }) + try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId) + await sendUserMessage(vm, text: "same words") + + try await waitUntil("foreign identical turn and local echo both survive") { + await MainActor.run { + vm.pendingRunCount == 0 && + vm.messages.count(where: { message in + message.role == "user" && + message.content.compactMap(\.text).joined(separator: "\n") == "same words" + }) == 2 + } + } + #expect(await transport.sentRunIds().count == 1) } @Test func `preserves repeated optimistic user messages with identical content during refresh`() async throws { let sessionId = "sess-main" let now = Date().timeIntervalSince1970 * 1000 let history1 = historyPayload(sessionId: sessionId) - let history2 = historyPayload( - sessionId: sessionId, - messages: [ - chatTextMessage( - role: "user", - text: "retry", - timestamp: now + 5000), - chatTextMessage( - role: "assistant", - text: "first answer", - timestamp: now + 6000), - ]) - - let (transport, vm) = await makeViewModel(historyResponses: [history1, history2, history2]) + let (transport, vm) = await makeViewModel( + historyResponses: [history1], + historyResponseHook: { _, index, sentRunIds in + guard index > 0, let firstRunId = sentRunIds.first else { return nil } + return historyPayload( + sessionId: sessionId, + messages: [ + chatTextMessage( + role: "user", + text: "retry", + timestamp: now + 5000, + idempotencyKey: "\(firstRunId):user"), + chatTextMessage( + role: "assistant", + text: "first answer", + timestamp: now + 6000), + ]) + }) try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId) try await sendMessageAndEmitFinal( transport: transport, @@ -1454,6 +2379,72 @@ struct ChatViewModelTests { }) } + @Test @MainActor func `transformed canonical reply invalidates older stale refresh`() async throws { + let staleRefreshGate = SessionSubscribeGate() + let historyCount = AsyncCounter() + let now = Date().timeIntervalSince1970 * 1000 + let staleTurn = [ + chatTextMessage(role: "user", text: "older question", timestamp: now - 2), + chatTextMessage(role: "assistant", text: "older answer", timestamp: now - 1), + ] + let (transport, vm) = await makeViewModel( + historyResponses: [ + historyPayload(sessionId: "sess-bootstrap", messages: staleTurn), + historyPayload(sessionId: "sess-stale", messages: staleTurn), + ], + requestHistoryHook: { sessionKey in + guard sessionKey == "main" else { return } + let count = await historyCount.increment() + if count == 2 { + await staleRefreshGate.wait() + } + }, + historyResponseHook: { _, index, sentRunIds in + guard index == 2, let runId = sentRunIds.first else { return nil } + return historyPayload( + sessionId: "sess-canonical", + messages: [ + chatTextMessage( + role: "user", + text: "canonical redacted request", + timestamp: now, + idempotencyKey: "\(runId):user"), + chatTextMessage( + role: "assistant", + text: "canonical answer", + timestamp: now + 1, + idempotencyKey: runId), + ]) + }) + try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-bootstrap") + + transport.emit(OpenClawChatTransportEvent.seqGap) + try await waitUntil("stale transformed refresh is in flight") { + await historyCount.current() == 2 + } + + vm.input = "original request" + vm.send() + _ = try await waitForLastSentRunId(transport) + try await waitUntil("transformed canonical answer applies") { + await MainActor.run { + vm.sessionId == "sess-canonical" && + vm.messages.containsUserText("canonical redacted request") && + vm.messages.contains { $0.content.contains { $0.text == "canonical answer" } } + } + } + + let healthCallsBeforeRelease = await transport.healthCallCount() + await staleRefreshGate.release() + try await waitUntil("older transformed refresh completes") { + await transport.healthCallCount() > healthCallsBeforeRelease + } + + #expect(vm.sessionId == "sess-canonical") + #expect(vm.messages.containsUserText("canonical redacted request")) + #expect(vm.messages.contains { $0.content.contains { $0.text == "canonical answer" } }) + } + @Test func `accepts canonical session key events for own pending run`() async throws { let history1 = historyPayload() let history2 = historyPayload( @@ -1464,7 +2455,9 @@ struct ChatViewModelTests { timestamp: Date().timeIntervalSince1970 * 1000), ]) - let (transport, vm) = await makeViewModel(historyResponses: [history1, history2]) + let (transport, vm) = await makeViewModel( + historyResponses: [history1, history2], + sendMessageStatus: "pending") try await loadAndWaitBootstrap(vm: vm) await sendUserMessage(vm) try await waitUntil("pending run starts") { await MainActor.run { vm.pendingRunCount == 1 } } @@ -1496,7 +2489,9 @@ struct ChatViewModelTests { timestamp: now), ]) - let (transport, vm) = await makeViewModel(historyResponses: [history1, history2]) + let (transport, vm) = await makeViewModel( + historyResponses: [history1, history2], + sendMessageStatus: "pending") try await loadAndWaitBootstrap(vm: vm) await sendUserMessage(vm) @@ -1694,7 +2689,9 @@ struct ChatViewModelTests { @Test func `appends external session assistant message while run pending`() async throws { let now = Date().timeIntervalSince1970 * 1000 - let (transport, vm) = await makeViewModel(historyResponses: [historyPayload()]) + let (transport, vm) = await makeViewModel( + historyResponses: [historyPayload()], + sendMessageStatus: "pending") await MainActor.run { vm.load() } try await waitUntil("bootstrap history loaded") { await MainActor.run { vm.messages.isEmpty } } @@ -1741,6 +2738,7 @@ struct ChatViewModelTests { try await waitUntil("bootstrap history loaded") { await MainActor.run { vm.messages.isEmpty } } await sendUserMessage(vm, text: "echo me") + let runId = try await waitForLastSentRunId(transport) try await waitUntil("optimistic user message visible") { await MainActor.run { vm.messages.count == 1 && vm.messages.first?.content.first?.text == "echo me" @@ -1763,7 +2761,8 @@ struct ChatViewModelTests { fileName: nil, content: nil), ], - timestamp: Date().timeIntervalSince1970 * 1000 + 5000), + timestamp: Date().timeIntervalSince1970 * 1000 + 5000, + idempotencyKey: "\(runId):user"), messageId: "srv-echo-1", messageSeq: 1))) @@ -1775,6 +2774,141 @@ struct ChatViewModelTests { }) } + @Test func `late correlated user echo replaces optimistic row after final`() async throws { + let history = historyPayload() + let (transport, vm) = await makeViewModel( + historyResponses: [history, history], + sendMessageStatus: "pending") + + await MainActor.run { vm.load() } + try await waitUntil("bootstrap history loaded") { await MainActor.run { vm.messages.isEmpty } } + + await sendUserMessage(vm, text: "sensitive draft") + let runId = try await waitForLastSentRunId(transport) + let optimisticID = try await MainActor.run { + try #require(vm.messages.last(where: { $0.role == "user" })?.id) + } + + transport.emit( + .chat( + OpenClawChatEventPayload( + runId: runId, + sessionKey: "main", + state: "final", + message: nil, + errorMessage: nil))) + try await waitUntil("final clears pending correlation bookkeeping") { + await MainActor.run { vm.pendingRunCount == 0 } + } + + let canonicalTimestamp = Date().timeIntervalSince1970 * 1000 + 5000 + transport.emit( + .sessionMessage( + OpenClawSessionMessageEventPayload( + sessionKey: "agent:main:main", + message: OpenClawChatMessage( + role: "user", + content: [ + OpenClawChatMessageContent( + type: "text", + text: "redacted canonical text", + mimeType: nil, + fileName: nil, + content: nil), + ], + timestamp: canonicalTimestamp, + idempotencyKey: "\(runId):user"), + messageId: "srv-late-user-echo", + messageSeq: 2))) + + try await waitUntil("late canonical echo replaces optimistic row") { + await MainActor.run { + vm.messages.count(where: { $0.role == "user" }) == 1 && + vm.messages.contains(where: { message in + message.id == optimisticID && + message.content.first?.text == "redacted canonical text" && + message.timestamp == canonicalTimestamp + }) + } + } + } + + @Test func `metadata free same text event cannot consume pending local identity`() async throws { + let (transport, vm) = await makeViewModel( + historyResponses: [historyPayload()], + sendMessageStatus: "pending") + + await MainActor.run { vm.load() } + try await waitUntil("bootstrap history loaded") { await MainActor.run { vm.messages.isEmpty } } + + await sendUserMessage(vm, text: "legacy echo") + let runId = try await waitForLastSentRunId(transport) + let optimisticID = try await MainActor.run { + try #require(vm.messages.last(where: { $0.role == "user" })?.id) + } + let canonicalTimestamp = Date().timeIntervalSince1970 * 1000 + 5000 + + transport.emit( + .sessionMessage( + OpenClawSessionMessageEventPayload( + sessionKey: "agent:main:main", + message: OpenClawChatMessage( + role: "user", + content: [ + OpenClawChatMessageContent( + type: "text", + text: "legacy echo", + mimeType: nil, + fileName: nil, + content: nil), + ], + timestamp: canonicalTimestamp), + messageId: "srv-legacy-echo-1", + messageSeq: 1))) + + try await waitUntil("ambiguous metadata free event remains distinct") { + await MainActor.run { + vm.messages.count(where: { message in + message.role == "user" && message.content.first?.text == "legacy echo" + }) == 2 + } + } + + let localCanonicalTimestamp = canonicalTimestamp + 1000 + transport.emit( + .sessionMessage( + OpenClawSessionMessageEventPayload( + sessionKey: "agent:main:main", + message: OpenClawChatMessage( + role: "user", + content: [ + OpenClawChatMessageContent( + type: "text", + text: "legacy echo", + mimeType: nil, + fileName: nil, + content: nil), + ], + timestamp: localCanonicalTimestamp, + idempotencyKey: "\(runId):user"), + messageId: "srv-local-echo-1", + messageSeq: 2))) + + try await waitUntil("later correlated local echo adopts only the optimistic row") { + await MainActor.run { + vm.messages.count(where: { message in + message.role == "user" && message.content.first?.text == "legacy echo" + }) == 2 && vm.messages.contains(where: { message in + message.id == optimisticID && + message.timestamp == localCanonicalTimestamp && + message.idempotencyKey == "\(runId):user" + }) && vm.messages.contains(where: { message in + message.timestamp == canonicalTimestamp && message.idempotencyKey == nil + }) + } + } + } + @Test func `appends same content user transcript when it is not local echo`() async throws { let now = Date().timeIntervalSince1970 * 1000 let (transport, vm) = await makeViewModel( @@ -1895,7 +3029,9 @@ struct ChatViewModelTests { text: "resynced after gap", timestamp: now)]) - let (transport, vm) = await makeViewModel(historyResponses: [history1, history2]) + let (transport, vm) = await makeViewModel( + historyResponses: [history1, history2], + sendMessageStatus: "pending") try await loadAndWaitBootstrap(vm: vm) @@ -3441,7 +4577,8 @@ struct ChatViewModelTests { await staleFallbackGate.wait() _ = await staleFallbackReleasedCount.increment() } - }) + }, + sendMessageStatus: "pending") try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main") @@ -4331,7 +5468,9 @@ struct ChatViewModelTests { @Test func `abort requests do not clear pending until aborted event`() async throws { let sessionId = "sess-main" let history = historyPayload(sessionId: sessionId) - let (transport, vm) = await makeViewModel(historyResponses: [history, history]) + let (transport, vm) = await makeViewModel( + historyResponses: [history, history], + sendMessageStatus: "pending") try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId) await sendUserMessage(vm) diff --git a/scripts/apple-app-i18n.ts b/scripts/apple-app-i18n.ts index 2717b279a6aa..c7f941c97067 100644 --- a/scripts/apple-app-i18n.ts +++ b/scripts/apple-app-i18n.ts @@ -42,6 +42,15 @@ const CATALOGS = [ "Sent to OpenClaw.", ], "apps/ios/Sources/Design/SettingsChannelsDestination.swift": ["Logout"], + "apps/ios/Sources/Design/ChatProTab.swift": [ + "Check OpenClaw status", + "Help me start a realtime voice session from this phone.", + "Help me start voice chat", + "Show me which phone controls and device capabilities are available right now.", + "Summarize the current OpenClaw status and tell me what needs attention.", + "What can I control here?", + "What would you like to work on?", + ], "apps/ios/Sources/Gateway/GatewayProblemView.swift": ["Done"], "apps/ios/Sources/Gateway/GatewayQuickSetupSheet.swift": [ "Close", diff --git a/scripts/native-app-i18n.ts b/scripts/native-app-i18n.ts index 621f745f7032..10af7d1ef368 100644 --- a/scripts/native-app-i18n.ts +++ b/scripts/native-app-i18n.ts @@ -76,6 +76,8 @@ const APPLE_EXTENSIONS = new Set([".swift", ".plist"]); const NATIVE_FORMAT_RE = /%(?:\d+\$)?[@a-z]/giu; const APPLE_UI_MULTILINE_CALLS = /(?:Text|Label|Button|TextField|SecureField|Picker|Section|LabeledContent|Toggle|Menu|ShareLink|Link|TextEditor|ProgressView|Gauge|DisclosureGroup|ControlGroup|DatePicker|Stepper)\s*\(\s*"""([\s\S]*?)"""/gu; +const APPLE_LOCALIZED_STRING_CALLS = + /\b(?:String\s*\(\s*localized:|LocalizedStringResource\s*\()\s*"((?:\\.|[^"\\])*)"/gu; const APPLE_CALL_START = /\b([A-Za-z_][A-Za-z0-9_]*)\s*\(\s*/gu; const APPLE_MODIFIER_CALLS = /\.(?:navigationTitle|accessibilityLabel|accessibilityHint|help|alert|confirmationDialog)\s*\(\s*"((?:\\.|[^"\\])*)"/gu; @@ -626,6 +628,7 @@ function extractCandidates( surface === "apple" ? [ [APPLE_UI_MULTILINE_CALLS, "ui-call-multiline"], + [APPLE_LOCALIZED_STRING_CALLS, "ui-localized-call"], [APPLE_MODIFIER_CALLS, "ui-modifier"], [APPLE_MODIFIER_MULTILINE_CALLS, "ui-modifier-multiline"], ...CONDITIONAL_BRANCHES.map((pattern) => [pattern, "conditional-branch"] as const), diff --git a/test/scripts/native-app-i18n.test.ts b/test/scripts/native-app-i18n.test.ts index 68fe429d0dca..54d7d436d685 100644 --- a/test/scripts/native-app-i18n.test.ts +++ b/test/scripts/native-app-i18n.test.ts @@ -74,6 +74,29 @@ describe("native app i18n inventory", () => { expect(entries.some((entry) => entry.source === "Searching…")).toBe(true); expect(entries.some((entry) => entry.source === "Run now")).toBe(true); expect(entries.some((entry) => entry.source === "Loading chat")).toBe(true); + expect(entries.some((entry) => entry.source === "What would you like to work on?")).toBe(true); + expect(entries.some((entry) => entry.source === "Check OpenClaw status")).toBe(true); + expect(entries.some((entry) => entry.source === "What can I control here?")).toBe(true); + expect(entries.some((entry) => entry.source === "Help me start voice chat")).toBe(true); + expect( + entries.some( + (entry) => + entry.source === + "Summarize the current OpenClaw status and tell me what needs attention.", + ), + ).toBe(true); + expect( + entries.some( + (entry) => + entry.source === + "Show me which phone controls and device capabilities are available right now.", + ), + ).toBe(true); + expect( + entries.some( + (entry) => entry.source === "Help me start a realtime voice session from this phone.", + ), + ).toBe(true); expect(entries.some((entry) => entry.source === "DIARY")).toBe(true); expect(entries.some((entry) => entry.source === "ask OpenClaw $prompt")).toBe(true); expect(entries.some((entry) => entry.source === "OpenClaw is paused")).toBe(true);