fix(ios): harden Talk voice-session lifecycle from review (#111424)

- Close+clear the adopted logical voice session when a config/provider change
  invalidates a realtime prefetch (a live call is preserved); prevents the
  gateway rejecting a stale voiceSessionId resume under a new provider.
- Retry talk.client.close (0/500/2000ms) before forgetting the session, matching
  the web client, so a transient close does not orphan it until the 6h sweep.
- Surface exhausted transcript-persist failures through the session delegate so a
  dropped affirmation transcript no longer silently blocks spoken confirmation.
- Localize the "Confirmation needed" status through the presentation switches;
  add its native locale translations.
This commit is contained in:
Peter Steinberger
2026-07-19 07:01:28 -07:00
committed by GitHub
parent 7ea4e6701e
commit da534dc506
27 changed files with 469 additions and 70 deletions
@@ -1281,6 +1281,38 @@
"指令"
]
},
{
"locale": "ar",
"source": "Confirmation needed",
"translations": [
"التأكيد مطلوب",
"يلزم التأكيد"
]
},
{
"locale": "fa",
"source": "Confirmation needed",
"translations": [
"تأیید لازم است",
"نیاز به تأیید"
]
},
{
"locale": "th",
"source": "Confirmation needed",
"translations": [
"ต้องการการยืนยัน",
"ต้องยืนยัน"
]
},
{
"locale": "tr",
"source": "Confirmation needed",
"translations": [
"Onay gerekiyor",
"Onay gerekli"
]
},
{
"locale": "fr",
"source": "Connect",
+63 -55
View File
@@ -25763,7 +25763,7 @@
},
{
"kind": "ui-localized-call",
"line": 611,
"line": 614,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Gateway relay ready",
"surface": "apple",
@@ -25771,7 +25771,7 @@
},
{
"kind": "ui-localized-call",
"line": 613,
"line": 616,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Realtime Voice, Gateway relay ready",
"surface": "apple",
@@ -25779,7 +25779,7 @@
},
{
"kind": "ui-localized-call",
"line": 615,
"line": 618,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Listening starts from this phone",
"surface": "apple",
@@ -25787,7 +25787,7 @@
},
{
"kind": "ui-localized-call",
"line": 861,
"line": 864,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Off",
"surface": "apple",
@@ -25795,7 +25795,7 @@
},
{
"kind": "ui-localized-call",
"line": 864,
"line": 867,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Not active",
"surface": "apple",
@@ -25803,7 +25803,7 @@
},
{
"kind": "ui-localized-call",
"line": 1052,
"line": 1055,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Requesting permissions…",
"surface": "apple",
@@ -25811,7 +25811,7 @@
},
{
"kind": "ui-localized-call",
"line": 1081,
"line": 1084,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Start failed: %@",
"surface": "apple",
@@ -25819,7 +25819,7 @@
},
{
"kind": "ui-localized-call",
"line": 1337,
"line": 1340,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Microphone permission denied",
"surface": "apple",
@@ -25827,7 +25827,7 @@
},
{
"kind": "ui-localized-call",
"line": 1347,
"line": 1350,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Speech recognition",
"surface": "apple",
@@ -25835,7 +25835,7 @@
},
{
"kind": "ui-localized-call",
"line": 1478,
"line": 1481,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Offline",
"surface": "apple",
@@ -25843,7 +25843,7 @@
},
{
"kind": "ui-localized-call",
"line": 1677,
"line": 1680,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Speech error: %@",
"surface": "apple",
@@ -25851,7 +25851,7 @@
},
{
"kind": "ui-localized-call",
"line": 1992,
"line": 1995,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Gateway not connected",
"surface": "apple",
@@ -25859,7 +25859,7 @@
},
{
"kind": "conditional-branch",
"line": 2036,
"line": 2039,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Aborted",
"surface": "apple",
@@ -25867,7 +25867,7 @@
},
{
"kind": "conditional-branch",
"line": 2036,
"line": 2039,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Chat error",
"surface": "apple",
@@ -25875,7 +25875,7 @@
},
{
"kind": "ui-localized-call",
"line": 2064,
"line": 2067,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Talk failed: %@",
"surface": "apple",
@@ -25883,7 +25883,7 @@
},
{
"kind": "ui-localized-call",
"line": 2168,
"line": 2171,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "No reply",
"surface": "apple",
@@ -25891,7 +25891,7 @@
},
{
"kind": "ui-localized-call",
"line": 2340,
"line": 2343,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Paused",
"surface": "apple",
@@ -25899,7 +25899,7 @@
},
{
"kind": "ui-localized-call",
"line": 2869,
"line": 2929,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Generating voice…",
"surface": "apple",
@@ -25907,7 +25907,7 @@
},
{
"kind": "ui-localized-call",
"line": 3007,
"line": 3067,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Speak failed: %@",
"surface": "apple",
@@ -25915,7 +25915,7 @@
},
{
"kind": "ui-localized-call",
"line": 3106,
"line": 3166,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Speaking (System)…",
"surface": "apple",
@@ -25923,7 +25923,7 @@
},
{
"kind": "ui-localized-call",
"line": 3987,
"line": 4047,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "iOS System Voice",
"surface": "apple",
@@ -25931,7 +25931,7 @@
},
{
"kind": "ui-localized-call",
"line": 3989,
"line": 4049,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Realtime Voice",
"surface": "apple",
@@ -25939,7 +25939,7 @@
},
{
"kind": "ui-localized-call",
"line": 3993,
"line": 4053,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Gateway Default",
"surface": "apple",
@@ -25947,7 +25947,7 @@
},
{
"kind": "ui-localized-call",
"line": 4052,
"line": 4114,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Listening (Realtime)",
"surface": "apple",
@@ -25955,7 +25955,7 @@
},
{
"kind": "ui-localized-call",
"line": 4054,
"line": 4116,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Thinking",
"surface": "apple",
@@ -25963,7 +25963,7 @@
},
{
"kind": "ui-localized-call",
"line": 4056,
"line": 4118,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Thinking…",
"surface": "apple",
@@ -25971,7 +25971,7 @@
},
{
"kind": "ui-localized-call",
"line": 4058,
"line": 4120,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Asking OpenClaw",
"surface": "apple",
@@ -25979,7 +25979,7 @@
},
{
"kind": "ui-localized-call",
"line": 4060,
"line": 4122,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Still asking OpenClaw",
"surface": "apple",
@@ -25987,7 +25987,7 @@
},
{
"kind": "ui-localized-call",
"line": 4062,
"line": 4124,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Updating OpenClaw",
"surface": "apple",
@@ -25995,7 +25995,7 @@
},
{
"kind": "ui-localized-call",
"line": 4064,
"line": 4126,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Speaking",
"surface": "apple",
@@ -26003,7 +26003,7 @@
},
{
"kind": "ui-localized-call",
"line": 4066,
"line": 4128,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Speaking…",
"surface": "apple",
@@ -26011,7 +26011,7 @@
},
{
"kind": "ui-localized-call",
"line": 4068,
"line": 4130,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Connecting",
"surface": "apple",
@@ -26019,7 +26019,7 @@
},
{
"kind": "ui-localized-call",
"line": 4070,
"line": 4132,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Connecting realtime…",
"surface": "apple",
@@ -26027,7 +26027,7 @@
},
{
"kind": "ui-localized-call",
"line": 4072,
"line": 4134,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Waiting for realtime…",
"surface": "apple",
@@ -26035,7 +26035,7 @@
},
{
"kind": "ui-localized-call",
"line": 4074,
"line": 4136,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Ready",
"surface": "apple",
@@ -26043,7 +26043,7 @@
},
{
"kind": "ui-localized-call",
"line": 4076,
"line": 4138,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Reconnecting",
"surface": "apple",
@@ -26051,7 +26051,7 @@
},
{
"kind": "ui-localized-call",
"line": 4078,
"line": 4140,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Reconnecting…",
"surface": "apple",
@@ -26059,7 +26059,7 @@
},
{
"kind": "ui-localized-call",
"line": 4080,
"line": 4142,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Realtime failed before connecting",
"surface": "apple",
@@ -26067,7 +26067,7 @@
},
{
"kind": "ui-localized-call",
"line": 4082,
"line": 4144,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Realtime disconnected",
"surface": "apple",
@@ -26075,7 +26075,7 @@
},
{
"kind": "ui-localized-call",
"line": 4084,
"line": 4146,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "OpenClaw unavailable",
"surface": "apple",
@@ -26083,7 +26083,15 @@
},
{
"kind": "ui-localized-call",
"line": 4124,
"line": 4148,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Confirmation needed",
"surface": "apple",
"id": "native.apple.f4adf2762402e92a"
},
{
"kind": "ui-localized-call",
"line": 4188,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "iOS Speech + TTS",
"surface": "apple",
@@ -26091,7 +26099,7 @@
},
{
"kind": "ui-localized-call",
"line": 4126,
"line": 4190,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Listening",
"surface": "apple",
@@ -26099,7 +26107,7 @@
},
{
"kind": "ui-localized-call",
"line": 4130,
"line": 4194,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "iOS Speech fallback",
"surface": "apple",
@@ -26107,7 +26115,7 @@
},
{
"kind": "ui-localized-call",
"line": 4391,
"line": 4455,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Gateway Relay",
"surface": "apple",
@@ -26115,7 +26123,7 @@
},
{
"kind": "ui-localized-call",
"line": 4393,
"line": 4457,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Native WebRTC",
"surface": "apple",
@@ -26123,7 +26131,7 @@
},
{
"kind": "ui-localized-call",
"line": 4394,
"line": 4458,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Native",
"surface": "apple",
@@ -26131,7 +26139,7 @@
},
{
"kind": "ui-localized-call",
"line": 4455,
"line": 4519,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Gateway permission required",
"surface": "apple",
@@ -26139,7 +26147,7 @@
},
{
"kind": "ui-localized-call",
"line": 4477,
"line": 4541,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Not loaded",
"surface": "apple",
@@ -26147,7 +26155,7 @@
},
{
"kind": "ui-localized-call",
"line": 4495,
"line": 4559,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Approval requested",
"surface": "apple",
@@ -26155,7 +26163,7 @@
},
{
"kind": "ui-localized-call",
"line": 4934,
"line": 5079,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Realtime unavailable",
"surface": "apple",
@@ -26163,7 +26171,7 @@
},
{
"kind": "ui-localized-call",
"line": 4956,
"line": 5101,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Listening (PTT)",
"surface": "apple",
@@ -26171,7 +26179,7 @@
},
{
"kind": "conditional-branch",
"line": 616,
"line": 634,
"path": "apps/ios/Sources/Voice/TalkRealtimeWebRTCSession.swift",
"source": "Asking OpenClaw",
"surface": "apple",
@@ -26179,7 +26187,7 @@
},
{
"kind": "conditional-branch",
"line": 616,
"line": 634,
"path": "apps/ios/Sources/Voice/TalkRealtimeWebRTCSession.swift",
"source": "Updating OpenClaw",
"surface": "apple",
@@ -26187,7 +26195,7 @@
},
{
"kind": "conditional-branch",
"line": 705,
"line": 723,
"path": "apps/ios/Sources/Voice/TalkRealtimeWebRTCSession.swift",
"source": "Confirmation needed",
"surface": "apple",
@@ -26195,7 +26203,7 @@
},
{
"kind": "conditional-branch",
"line": 705,
"line": 723,
"path": "apps/ios/Sources/Voice/TalkRealtimeWebRTCSession.swift",
"source": "OpenClaw unavailable",
"surface": "apple",
+5
View File
@@ -16303,6 +16303,11 @@
"source": "OpenClaw unavailable",
"translated": "OpenClaw غير متاح"
},
{
"id": "native.apple.f4adf2762402e92a",
"source": "Confirmation needed",
"translated": "يلزم التأكيد"
},
{
"id": "native.apple.f703391677039fa3",
"source": "iOS Speech + TTS",
+5
View File
@@ -16303,6 +16303,11 @@
"source": "OpenClaw unavailable",
"translated": "OpenClaw nicht verfügbar"
},
{
"id": "native.apple.f4adf2762402e92a",
"source": "Confirmation needed",
"translated": "Bestätigung erforderlich"
},
{
"id": "native.apple.f703391677039fa3",
"source": "iOS Speech + TTS",
+5
View File
@@ -16303,6 +16303,11 @@
"source": "OpenClaw unavailable",
"translated": "OpenClaw no está disponible"
},
{
"id": "native.apple.f4adf2762402e92a",
"source": "Confirmation needed",
"translated": "Se necesita confirmación"
},
{
"id": "native.apple.f703391677039fa3",
"source": "iOS Speech + TTS",
+5
View File
@@ -16303,6 +16303,11 @@
"source": "OpenClaw unavailable",
"translated": "OpenClaw در دسترس نیست"
},
{
"id": "native.apple.f4adf2762402e92a",
"source": "Confirmation needed",
"translated": "تأیید لازم است"
},
{
"id": "native.apple.f703391677039fa3",
"source": "iOS Speech + TTS",
+5
View File
@@ -16303,6 +16303,11 @@
"source": "OpenClaw unavailable",
"translated": "OpenClaw indisponible"
},
{
"id": "native.apple.f4adf2762402e92a",
"source": "Confirmation needed",
"translated": "Confirmation requise"
},
{
"id": "native.apple.f703391677039fa3",
"source": "iOS Speech + TTS",
+5
View File
@@ -16303,6 +16303,11 @@
"source": "OpenClaw unavailable",
"translated": "OpenClaw उपलब्ध नहीं है"
},
{
"id": "native.apple.f4adf2762402e92a",
"source": "Confirmation needed",
"translated": "पुष्टि आवश्यक है"
},
{
"id": "native.apple.f703391677039fa3",
"source": "iOS Speech + TTS",
+5
View File
@@ -16303,6 +16303,11 @@
"source": "OpenClaw unavailable",
"translated": "OpenClaw tidak tersedia"
},
{
"id": "native.apple.f4adf2762402e92a",
"source": "Confirmation needed",
"translated": "Konfirmasi diperlukan"
},
{
"id": "native.apple.f703391677039fa3",
"source": "iOS Speech + TTS",
+5
View File
@@ -16303,6 +16303,11 @@
"source": "OpenClaw unavailable",
"translated": "OpenClaw non disponibile"
},
{
"id": "native.apple.f4adf2762402e92a",
"source": "Confirmation needed",
"translated": "Conferma necessaria"
},
{
"id": "native.apple.f703391677039fa3",
"source": "iOS Speech + TTS",
+5
View File
@@ -16303,6 +16303,11 @@
"source": "OpenClaw unavailable",
"translated": "OpenClawは利用できません"
},
{
"id": "native.apple.f4adf2762402e92a",
"source": "Confirmation needed",
"translated": "確認が必要です"
},
{
"id": "native.apple.f703391677039fa3",
"source": "iOS Speech + TTS",
+5
View File
@@ -16303,6 +16303,11 @@
"source": "OpenClaw unavailable",
"translated": "OpenClaw를 사용할 수 없습니다"
},
{
"id": "native.apple.f4adf2762402e92a",
"source": "Confirmation needed",
"translated": "확인이 필요합니다"
},
{
"id": "native.apple.f703391677039fa3",
"source": "iOS Speech + TTS",
+5
View File
@@ -16303,6 +16303,11 @@
"source": "OpenClaw unavailable",
"translated": "OpenClaw niet beschikbaar"
},
{
"id": "native.apple.f4adf2762402e92a",
"source": "Confirmation needed",
"translated": "Bevestiging vereist"
},
{
"id": "native.apple.f703391677039fa3",
"source": "iOS Speech + TTS",
+5
View File
@@ -16303,6 +16303,11 @@
"source": "OpenClaw unavailable",
"translated": "OpenClaw jest niedostępny"
},
{
"id": "native.apple.f4adf2762402e92a",
"source": "Confirmation needed",
"translated": "Wymagane potwierdzenie"
},
{
"id": "native.apple.f703391677039fa3",
"source": "iOS Speech + TTS",
+5
View File
@@ -16303,6 +16303,11 @@
"source": "OpenClaw unavailable",
"translated": "OpenClaw indisponível"
},
{
"id": "native.apple.f4adf2762402e92a",
"source": "Confirmation needed",
"translated": "Confirmação necessária"
},
{
"id": "native.apple.f703391677039fa3",
"source": "iOS Speech + TTS",
+5
View File
@@ -16303,6 +16303,11 @@
"source": "OpenClaw unavailable",
"translated": "OpenClaw недоступен"
},
{
"id": "native.apple.f4adf2762402e92a",
"source": "Confirmation needed",
"translated": "Требуется подтверждение"
},
{
"id": "native.apple.f703391677039fa3",
"source": "iOS Speech + TTS",
+5
View File
@@ -16303,6 +16303,11 @@
"source": "OpenClaw unavailable",
"translated": "OpenClaw är inte tillgängligt"
},
{
"id": "native.apple.f4adf2762402e92a",
"source": "Confirmation needed",
"translated": "Bekräftelse krävs"
},
{
"id": "native.apple.f703391677039fa3",
"source": "iOS Speech + TTS",
+5
View File
@@ -16303,6 +16303,11 @@
"source": "OpenClaw unavailable",
"translated": "OpenClaw ไม่พร้อมใช้งาน"
},
{
"id": "native.apple.f4adf2762402e92a",
"source": "Confirmation needed",
"translated": "ต้องการการยืนยัน"
},
{
"id": "native.apple.f703391677039fa3",
"source": "iOS Speech + TTS",
+5
View File
@@ -16303,6 +16303,11 @@
"source": "OpenClaw unavailable",
"translated": "OpenClaw kullanılamıyor"
},
{
"id": "native.apple.f4adf2762402e92a",
"source": "Confirmation needed",
"translated": "Onay gerekiyor"
},
{
"id": "native.apple.f703391677039fa3",
"source": "iOS Speech + TTS",
+5
View File
@@ -16303,6 +16303,11 @@
"source": "OpenClaw unavailable",
"translated": "OpenClaw недоступний"
},
{
"id": "native.apple.f4adf2762402e92a",
"source": "Confirmation needed",
"translated": "Потрібне підтвердження"
},
{
"id": "native.apple.f703391677039fa3",
"source": "iOS Speech + TTS",
+5
View File
@@ -16303,6 +16303,11 @@
"source": "OpenClaw unavailable",
"translated": "OpenClaw không khả dụng"
},
{
"id": "native.apple.f4adf2762402e92a",
"source": "Confirmation needed",
"translated": "Cần xác nhận"
},
{
"id": "native.apple.f703391677039fa3",
"source": "iOS Speech + TTS",
+5
View File
@@ -16303,6 +16303,11 @@
"source": "OpenClaw unavailable",
"translated": "OpenClaw 不可用"
},
{
"id": "native.apple.f4adf2762402e92a",
"source": "Confirmation needed",
"translated": "需要确认"
},
{
"id": "native.apple.f703391677039fa3",
"source": "iOS Speech + TTS",
+5
View File
@@ -16303,6 +16303,11 @@
"source": "OpenClaw unavailable",
"translated": "OpenClaw 無法使用"
},
{
"id": "native.apple.f4adf2762402e92a",
"source": "Confirmation needed",
"translated": "需要確認"
},
{
"id": "native.apple.f703391677039fa3",
"source": "iOS Speech + TTS",
+153 -8
View File
@@ -208,6 +208,7 @@ final class TalkModeManager: NSObject {
private static let realtimeStableSessionSeconds: TimeInterval = 30
private static let realtimeRestartDelaysNanoseconds: [UInt64] = [500_000_000, 2_000_000_000]
private static let realtimeVoiceSessionCloseRetryDelaysNanoseconds: [UInt64] = [0, 500_000_000, 2_000_000_000]
private var isStarting = false
private var startAttemptID = 0
@@ -335,6 +336,8 @@ final class TalkModeManager: NSObject {
@ObservationIgnored private var testPTTFinalizerHandler: (@MainActor () async -> Void)?
@ObservationIgnored private var testPTTOnceStartedHandler: (@MainActor () async -> Void)?
@ObservationIgnored private var testPTTReservedHandler: (@MainActor () async -> Void)?
@ObservationIgnored private var testRealtimeVoiceSessionCloseRequest:
(@MainActor (_ method: String, _ paramsJSON: String?) async throws -> Void)?
#endif
private let logger = Logger(subsystem: "ai.openclawfoundation.app", category: "TalkMode")
@@ -545,7 +548,7 @@ final class TalkModeManager: NSObject {
self.gatewayTalkActiveModeTitle = String(localized: "Not active")
self.gatewayTalkActiveModeSubtitle = nil
self.cancelRealtimePrefetch()
self.prefetchedRealtimeSession = nil
self.invalidatePrefetchedRealtimeSession()
}
}
@@ -2591,7 +2594,19 @@ final class TalkModeManager: NSObject {
}
}
private func closeLogicalRealtimeVoiceSessions() {
@discardableResult
private func invalidatePrefetchedRealtimeSession() -> Task<Void, Never>? {
guard self.realtimeSession == nil else {
// A config reload may overlap a live direct call. Discard only an unused prefetch;
// the active transport still owns its logical session and transcript queue.
self.prefetchedRealtimeSession = nil
return nil
}
return self.closeLogicalRealtimeVoiceSessions()
}
@discardableResult
private func closeLogicalRealtimeVoiceSessions() -> Task<Void, Never>? {
// A close boundary invalidates every in-flight transport that captured the old owner.
self.realtimeVoiceSessionGeneration &+= 1
let voiceSessionIds = Set([
@@ -2601,11 +2616,11 @@ final class TalkModeManager: NSObject {
].compactMap(\.self))
self.activeRealtimeVoiceSessionId = nil
self.prefetchedRealtimeSession = nil
guard !voiceSessionIds.isEmpty else { return }
guard !voiceSessionIds.isEmpty else { return nil }
let gateway = self.gateway
let sessionKey = self.mainSessionKey
let transcriptStore = self.realtimeTranscriptStore
Task { @MainActor in
return Task { @MainActor in
defer { transcriptStore.remove(voiceSessionIds) }
for voiceSessionId in voiceSessionIds.sorted() {
await transcriptStore.flush(voiceSessionId: voiceSessionId)
@@ -2617,7 +2632,7 @@ final class TalkModeManager: NSObject {
continue
}
do {
try await Self.closeRealtimeVoiceSession(
try await self.closeRealtimeVoiceSession(
gateway: gateway,
sessionKey: sessionKey,
voiceSessionId: voiceSessionId)
@@ -2630,7 +2645,46 @@ final class TalkModeManager: NSObject {
}
}
private static func closeRealtimeVoiceSession(
private static func retryRealtimeVoiceSessionClose(
retryDelaysNanoseconds: [UInt64],
sleep: @escaping @MainActor (UInt64) async throws -> Void = { delay in
try await Task.sleep(nanoseconds: delay)
},
operation: @escaping @MainActor () async throws -> Void) async throws
{
var finalError: Error?
for delay in retryDelaysNanoseconds {
if delay > 0 {
try await sleep(delay)
}
do {
try await operation()
return
} catch {
finalError = error
}
}
throw finalError ?? NSError(domain: "TalkRealtimeVoiceSession", code: 2, userInfo: [
NSLocalizedDescriptionKey: "Voice session close failed",
])
}
private func closeRealtimeVoiceSession(
gateway: GatewayNodeSession,
sessionKey: String,
voiceSessionId: String) async throws
{
try await Self.retryRealtimeVoiceSessionClose(
retryDelaysNanoseconds: Self.realtimeVoiceSessionCloseRetryDelaysNanoseconds)
{
try await self.requestRealtimeVoiceSessionClose(
gateway: gateway,
sessionKey: sessionKey,
voiceSessionId: voiceSessionId)
}
}
private func requestRealtimeVoiceSessionClose(
gateway: GatewayNodeSession,
sessionKey: String,
voiceSessionId: String) async throws
@@ -2644,6 +2698,12 @@ final class TalkModeManager: NSObject {
NSLocalizedDescriptionKey: "Failed to encode close request",
])
}
#if DEBUG
if let testRealtimeVoiceSessionCloseRequest {
try await testRealtimeVoiceSessionCloseRequest("talk.client.close", json)
return
}
#endif
_ = try await gateway.request(
method: "talk.client.close",
paramsJSON: json,
@@ -2660,7 +2720,7 @@ final class TalkModeManager: NSObject {
defer { transcriptStore.remove([voiceSessionId]) }
await transcriptStore.flush(voiceSessionId: voiceSessionId)
do {
try await Self.closeRealtimeVoiceSession(
try await self.closeRealtimeVoiceSession(
gateway: gateway,
sessionKey: sessionKey,
voiceSessionId: voiceSessionId)
@@ -4039,6 +4099,8 @@ extension TalkModeManager {
.localized("Realtime disconnected")
case "OpenClaw unavailable":
.localized("OpenClaw unavailable")
case "Confirmation needed":
.localized("Confirmation needed")
default:
.verbatim(status)
}
@@ -4082,6 +4144,8 @@ extension TalkModeManager {
String(localized: "Realtime disconnected")
case "OpenClaw unavailable":
String(localized: "OpenClaw unavailable")
case "Confirmation needed":
String(localized: "Confirmation needed")
default:
status
}
@@ -4227,7 +4291,7 @@ extension TalkModeManager {
}
guard shouldApply() else { return }
self.pcmFormatUnavailable = false
self.prefetchedRealtimeSession = nil
self.invalidatePrefetchedRealtimeSession()
let parsed = TalkModeGatewayConfigParser.parse(
config: loaded.config,
defaultProvider: Self.defaultTalkProvider,
@@ -4739,6 +4803,18 @@ extension TalkModeManager: TalkRealtimeWebRTCSessionDelegate {
self.lastSpokenText = trimmed
}
func realtimeSession(
_ session: TalkRealtimeWebRTCSession,
didFailTranscriptPersistenceForEntry _: String,
error _: Error)
{
guard session === self.realtimeSession else { return }
self.setStatus(
String(localized: "Chat error"),
phase: self.phase,
watchPresentation: .localized("Chat error"))
}
func realtimeSessionDidFinish(_ session: TalkRealtimeWebRTCSession) {
guard session === self.realtimeSession else { return }
self.realtimeSession = nil
@@ -4748,6 +4824,75 @@ extension TalkModeManager: TalkRealtimeWebRTCSessionDelegate {
#if DEBUG
extension TalkModeManager {
func _test_preparePrefetchedRealtimeVoiceSession(_ voiceSessionId: String) {
self.activeRealtimeVoiceSessionId = voiceSessionId
self.prefetchedRealtimeSession = TalkRealtimeClientSession(
provider: "openai",
transport: "webrtc",
voiceSessionId: voiceSessionId,
clientSecret: "test-client-secret",
offerUrl: nil,
offerHeaders: nil,
model: "gpt-realtime-2",
voice: "marin",
expiresAt: nil)
}
func _test_prepareLiveRealtimeVoiceSession(
gateway: GatewayNodeSession,
voiceSessionId: String,
prefetchedVoiceSessionId: String)
{
self.activeRealtimeVoiceSessionId = voiceSessionId
self.prefetchedRealtimeSession = TalkRealtimeClientSession(
provider: "openai",
transport: "webrtc",
voiceSessionId: prefetchedVoiceSessionId,
clientSecret: "test-prefetch-secret",
offerUrl: nil,
offerHeaders: nil,
model: "gpt-realtime-2",
voice: "marin",
expiresAt: nil)
self.realtimeSession = TalkRealtimeWebRTCSession(
gateway: gateway,
sessionKey: self.mainSessionKey,
voiceSessionId: voiceSessionId,
transcriptStore: self.realtimeTranscriptStore,
delegate: self)
}
func _test_invalidatePrefetchedRealtimeSession() async {
await self.invalidatePrefetchedRealtimeSession()?.value
}
func _test_activeRealtimeVoiceSessionId() -> String? {
self.activeRealtimeVoiceSessionId
}
func _test_hasPrefetchedRealtimeSession() -> Bool {
self.prefetchedRealtimeSession != nil
}
func _test_clearRealtimeSession() {
self.realtimeSession = nil
}
func _test_setRealtimeVoiceSessionCloseRequest(
_ handler: (@MainActor (_ method: String, _ paramsJSON: String?) async throws -> Void)?)
{
self.testRealtimeVoiceSessionCloseRequest = handler
}
static func _test_retryRealtimeVoiceSessionClose(
operation: @escaping @MainActor () async throws -> Void) async throws
{
try await self.retryRealtimeVoiceSessionClose(
retryDelaysNanoseconds: [0, 1, 1],
sleep: { _ in },
operation: operation)
}
static func _test_shouldRestartRealtimeSession(
isEnabled: Bool,
gatewayConnected: Bool,
@@ -15,6 +15,10 @@ protocol TalkRealtimeWebRTCSessionDelegate: AnyObject {
func realtimeSession(_ session: TalkRealtimeWebRTCSession, didUpdateAudioLevels input: Double?, output: Double?)
func realtimeSession(_ session: TalkRealtimeWebRTCSession, didReceiveUserTranscript text: String)
func realtimeSession(_ session: TalkRealtimeWebRTCSession, didReceiveAssistantTranscript text: String)
func realtimeSession(
_ session: TalkRealtimeWebRTCSession,
didFailTranscriptPersistenceForEntry entryId: String,
error: Error)
func realtimeSessionDidFinish(_ session: TalkRealtimeWebRTCSession)
}
@@ -440,12 +444,26 @@ final class TalkRealtimeWebRTCSession: NSObject {
paramsJSON: json,
timeoutSeconds: 5)
},
failureLog: { entryId, error in
GatewayDiagnostics.log(
"talk transcript persist FAILED entryId=\(entryId) error=\(error.localizedDescription)")
failureLog: { [weak self] entryId, error in
self?.reportTranscriptPersistenceFailure(entryId: entryId, error: error)
})
}
private func reportTranscriptPersistenceFailure(entryId: String, error: Error) {
GatewayDiagnostics.log(
"talk transcript persist FAILED entryId=\(entryId) error=\(error.localizedDescription)")
self.delegate?.realtimeSession(
self,
didFailTranscriptPersistenceForEntry: entryId,
error: error)
}
#if DEBUG
func _test_reportTranscriptPersistenceFailure(entryId: String, error: Error) {
self.reportTranscriptPersistenceFailure(entryId: entryId, error: error)
}
#endif
func flushTranscriptWrites() async {
guard let voiceSessionId = self.voiceSessionId else { return }
await self.transcriptStore.flush(voiceSessionId: voiceSessionId)
@@ -6,6 +6,8 @@ import Testing
@MainActor
struct TalkModeManagerTests {
private struct CloseError: Error {}
@Test func `encodes realtime client voice session identity`() throws {
let params = TalkRealtimeClientCreateParams(
sessionKey: "agent:main:main",
@@ -36,6 +38,63 @@ struct TalkModeManagerTests {
#expect(serverOwned.voiceSessionId == nil)
}
@Test func `config invalidation closes and clears an adopted realtime prefetch`() async throws {
let manager = TalkModeManager(allowSimulatorCapture: true)
let gateway = GatewayNodeSession()
manager.attachGateway(gateway)
var closeRequests: [(method: String, paramsJSON: String?)] = []
manager._test_setRealtimeVoiceSessionCloseRequest { method, paramsJSON in
closeRequests.append((method, paramsJSON))
}
manager._test_preparePrefetchedRealtimeVoiceSession("vs-A")
await manager._test_invalidatePrefetchedRealtimeSession()
#expect(manager._test_activeRealtimeVoiceSessionId() == nil)
#expect(!manager._test_hasPrefetchedRealtimeSession())
let request = try #require(closeRequests.first)
#expect(closeRequests.count == 1)
#expect(request.method == "talk.client.close")
let json = try #require(request.paramsJSON?.data(using: .utf8))
let params = try #require(JSONSerialization.jsonObject(with: json) as? [String: String])
#expect(params["voiceSessionId"] == "vs-A")
#expect(params["sessionKey"] == "main")
}
@Test func `config invalidation preserves a live realtime voice session`() async {
let manager = TalkModeManager(allowSimulatorCapture: true)
let gateway = GatewayNodeSession()
manager.attachGateway(gateway)
var closeRequestCount = 0
manager._test_setRealtimeVoiceSessionCloseRequest { _, _ in
closeRequestCount += 1
}
manager._test_prepareLiveRealtimeVoiceSession(
gateway: gateway,
voiceSessionId: "vs-live",
prefetchedVoiceSessionId: "vs-unused")
await manager._test_invalidatePrefetchedRealtimeSession()
#expect(manager._test_activeRealtimeVoiceSessionId() == "vs-live")
#expect(!manager._test_hasPrefetchedRealtimeSession())
#expect(closeRequestCount == 0)
manager._test_clearRealtimeSession()
}
@Test func `retries realtime voice session close three times`() async throws {
var attempts = 0
try await TalkModeManager._test_retryRealtimeVoiceSessionClose {
attempts += 1
if attempts < 3 {
throw CloseError()
}
}
#expect(attempts == 3)
}
@Test func `encodes transcript entry id as a decimal string`() throws {
let params = TalkRealtimeTranscriptParams(
sessionKey: "agent:main:main",
@@ -459,6 +518,10 @@ struct TalkModeManagerTests {
manager._test_handleRealtimeRelayStatus("Backend rejected realtime request")
#expect(manager.watchPresentation == .verbatim("Backend rejected realtime request"))
manager._test_handleRealtimeRelayStatus("Confirmation needed")
#expect(manager.statusText == String(localized: "Confirmation needed"))
#expect(manager.watchPresentation == .localized("Confirmation needed"))
manager._test_handleRealtimeRelayStatus("Reconnecting")
#expect(manager.phase == .connecting)
#expect(manager.watchPresentation == .phase)
@@ -1,4 +1,5 @@
import Foundation
import OpenClawKit
import Testing
@testable import OpenClaw
@@ -8,8 +9,14 @@ struct TalkRealtimeTranscriptWriteQueueTests {
@Test func `retries writes in order and continues after final failure`() async {
var attempts: [String] = []
var failures: [String] = []
let store = TalkRealtimeTranscriptStore(retryDelaysNanoseconds: [0, 0, 0, 0])
let delegate = RecordingTranscriptFailureDelegate()
let session = TalkRealtimeWebRTCSession(
gateway: GatewayNodeSession(),
sessionKey: "agent:main:main",
voiceSessionId: "voice-1",
transcriptStore: store,
delegate: delegate)
for _ in 1...3 {
store.enqueue(
@@ -27,14 +34,14 @@ struct TalkRealtimeTranscriptWriteQueueTests {
throw PersistError()
}
},
failureLog: { entryId, _ in
failures.append(entryId)
failureLog: { entryId, error in
session._test_reportTranscriptPersistenceFailure(entryId: entryId, error: error)
})
}
await store.flush(voiceSessionId: "voice-1")
#expect(attempts == ["1", "1", "1", "2", "2", "2", "3"])
#expect(failures == ["2"])
#expect(delegate.failedEntryIds == ["2"])
}
@Test func `shares ordering across transport replacements`() async {
@@ -79,3 +86,24 @@ struct TalkRealtimeTranscriptWriteQueueTests {
#expect(persisted.filter { $0.hasPrefix("voice-b:") } == ["voice-b:1"])
}
}
@MainActor
private final class RecordingTranscriptFailureDelegate: TalkRealtimeWebRTCSessionDelegate {
var failedEntryIds: [String] = []
func realtimeSession(_: TalkRealtimeWebRTCSession, didChangeStatus _: String) {}
func realtimeSession(_: TalkRealtimeWebRTCSession, didDetectInputSpeech _: Bool) {}
func realtimeSession(_: TalkRealtimeWebRTCSession, didUpdateAudioLevels _: Double?, output _: Double?) {}
func realtimeSession(_: TalkRealtimeWebRTCSession, didReceiveUserTranscript _: String) {}
func realtimeSession(_: TalkRealtimeWebRTCSession, didReceiveAssistantTranscript _: String) {}
func realtimeSession(
_: TalkRealtimeWebRTCSession,
didFailTranscriptPersistenceForEntry entryId: String,
error _: Error)
{
self.failedEntryIds.append(entryId)
}
func realtimeSessionDidFinish(_: TalkRealtimeWebRTCSession) {}
}