feat(ios): adopt Talk voice sessions (#111369)

* feat(ios): adopt Talk voice sessions

* docs(agents): fix unrelated red-main breakage in the landing PR

* chore(i18n): translate Talk voice-session strings across native locales
This commit is contained in:
Peter Steinberger
2026-07-19 06:28:18 -07:00
committed by GitHub
parent 0cc182844a
commit 4e008cd713
30 changed files with 1010 additions and 67 deletions
+1
View File
@@ -172,6 +172,7 @@ Skills own workflows; root owns hard policy and routing.
- Autoreview staged/uncommitted diff: use `--mode uncommitted`; no `staged` mode.
- If proof is blocked, say exactly what is missing and why.
- Do not land related failing format/lint/type/build/tests. If unrelated on latest `origin/main`, say so with scoped proof.
- Landing PR onto red `main` (unrelated breakage blocks the merge gate): fix the breakage in the same landing PR; note it in the PR body; never land onto red or bypass the gate. Prefer the smallest correct fix (e.g. register a missing source file, add a dropped export).
- Docs/changelog-only and CI/workflow metadata-only: `git diff --check` plus relevant docs/workflow sanity; escalate only if scripts/config/generated/package/runtime behavior changed.
- Prompt snapshots: CI truth is Linux Node 24. If macOS local passes but CI drifts, reproduce/generate in Linux before rerun.
@@ -6196,6 +6196,14 @@
"OpenClaw не може визначити поточний дозвіл iOS на доступ до геолокації."
]
},
{
"locale": "ko",
"source": "OpenClaw unavailable",
"translations": [
"OpenClaw를 사용할 수 없습니다",
"OpenClaw을 사용할 수 없습니다"
]
},
{
"locale": "ar",
"source": "Opens Settings / Gateway",
+69 -53
View File
@@ -25763,7 +25763,7 @@
},
{
"kind": "ui-localized-call",
"line": 608,
"line": 611,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Gateway relay ready",
"surface": "apple",
@@ -25771,7 +25771,7 @@
},
{
"kind": "ui-localized-call",
"line": 610,
"line": 613,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Realtime Voice, Gateway relay ready",
"surface": "apple",
@@ -25779,7 +25779,7 @@
},
{
"kind": "ui-localized-call",
"line": 612,
"line": 615,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Listening starts from this phone",
"surface": "apple",
@@ -25787,7 +25787,7 @@
},
{
"kind": "ui-localized-call",
"line": 852,
"line": 861,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Off",
"surface": "apple",
@@ -25795,7 +25795,7 @@
},
{
"kind": "ui-localized-call",
"line": 855,
"line": 864,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Not active",
"surface": "apple",
@@ -25803,7 +25803,7 @@
},
{
"kind": "ui-localized-call",
"line": 1041,
"line": 1052,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Requesting permissions…",
"surface": "apple",
@@ -25811,7 +25811,7 @@
},
{
"kind": "ui-localized-call",
"line": 1070,
"line": 1081,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Start failed: %@",
"surface": "apple",
@@ -25819,7 +25819,7 @@
},
{
"kind": "ui-localized-call",
"line": 1326,
"line": 1337,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Microphone permission denied",
"surface": "apple",
@@ -25827,7 +25827,7 @@
},
{
"kind": "ui-localized-call",
"line": 1336,
"line": 1347,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Speech recognition",
"surface": "apple",
@@ -25835,7 +25835,7 @@
},
{
"kind": "ui-localized-call",
"line": 1467,
"line": 1478,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Offline",
"surface": "apple",
@@ -25843,7 +25843,7 @@
},
{
"kind": "ui-localized-call",
"line": 1666,
"line": 1677,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Speech error: %@",
"surface": "apple",
@@ -25851,7 +25851,7 @@
},
{
"kind": "ui-localized-call",
"line": 1981,
"line": 1992,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Gateway not connected",
"surface": "apple",
@@ -25859,7 +25859,7 @@
},
{
"kind": "conditional-branch",
"line": 2025,
"line": 2036,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Aborted",
"surface": "apple",
@@ -25867,7 +25867,7 @@
},
{
"kind": "conditional-branch",
"line": 2025,
"line": 2036,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Chat error",
"surface": "apple",
@@ -25875,7 +25875,7 @@
},
{
"kind": "ui-localized-call",
"line": 2053,
"line": 2064,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Talk failed: %@",
"surface": "apple",
@@ -25883,7 +25883,7 @@
},
{
"kind": "ui-localized-call",
"line": 2157,
"line": 2168,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "No reply",
"surface": "apple",
@@ -25891,7 +25891,7 @@
},
{
"kind": "ui-localized-call",
"line": 2252,
"line": 2340,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Paused",
"surface": "apple",
@@ -25899,7 +25899,7 @@
},
{
"kind": "ui-localized-call",
"line": 2664,
"line": 2869,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Generating voice…",
"surface": "apple",
@@ -25907,7 +25907,7 @@
},
{
"kind": "ui-localized-call",
"line": 2802,
"line": 3007,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Speak failed: %@",
"surface": "apple",
@@ -25915,7 +25915,7 @@
},
{
"kind": "ui-localized-call",
"line": 2901,
"line": 3106,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Speaking (System)…",
"surface": "apple",
@@ -25923,7 +25923,7 @@
},
{
"kind": "ui-localized-call",
"line": 3782,
"line": 3987,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "iOS System Voice",
"surface": "apple",
@@ -25931,7 +25931,7 @@
},
{
"kind": "ui-localized-call",
"line": 3784,
"line": 3989,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Realtime Voice",
"surface": "apple",
@@ -25939,7 +25939,7 @@
},
{
"kind": "ui-localized-call",
"line": 3788,
"line": 3993,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Gateway Default",
"surface": "apple",
@@ -25947,7 +25947,7 @@
},
{
"kind": "ui-localized-call",
"line": 3847,
"line": 4052,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Listening (Realtime)",
"surface": "apple",
@@ -25955,7 +25955,7 @@
},
{
"kind": "ui-localized-call",
"line": 3849,
"line": 4054,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Thinking",
"surface": "apple",
@@ -25963,7 +25963,7 @@
},
{
"kind": "ui-localized-call",
"line": 3851,
"line": 4056,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Thinking…",
"surface": "apple",
@@ -25971,7 +25971,7 @@
},
{
"kind": "ui-localized-call",
"line": 3853,
"line": 4058,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Asking OpenClaw",
"surface": "apple",
@@ -25979,7 +25979,7 @@
},
{
"kind": "ui-localized-call",
"line": 3855,
"line": 4060,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Still asking OpenClaw",
"surface": "apple",
@@ -25987,7 +25987,7 @@
},
{
"kind": "ui-localized-call",
"line": 3857,
"line": 4062,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Updating OpenClaw",
"surface": "apple",
@@ -25995,7 +25995,7 @@
},
{
"kind": "ui-localized-call",
"line": 3859,
"line": 4064,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Speaking",
"surface": "apple",
@@ -26003,7 +26003,7 @@
},
{
"kind": "ui-localized-call",
"line": 3861,
"line": 4066,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Speaking…",
"surface": "apple",
@@ -26011,7 +26011,7 @@
},
{
"kind": "ui-localized-call",
"line": 3863,
"line": 4068,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Connecting",
"surface": "apple",
@@ -26019,7 +26019,7 @@
},
{
"kind": "ui-localized-call",
"line": 3865,
"line": 4070,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Connecting realtime…",
"surface": "apple",
@@ -26027,7 +26027,7 @@
},
{
"kind": "ui-localized-call",
"line": 3867,
"line": 4072,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Waiting for realtime…",
"surface": "apple",
@@ -26035,7 +26035,7 @@
},
{
"kind": "ui-localized-call",
"line": 3869,
"line": 4074,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Ready",
"surface": "apple",
@@ -26043,7 +26043,7 @@
},
{
"kind": "ui-localized-call",
"line": 3871,
"line": 4076,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Reconnecting",
"surface": "apple",
@@ -26051,7 +26051,7 @@
},
{
"kind": "ui-localized-call",
"line": 3873,
"line": 4078,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Reconnecting…",
"surface": "apple",
@@ -26059,7 +26059,7 @@
},
{
"kind": "ui-localized-call",
"line": 3875,
"line": 4080,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Realtime failed before connecting",
"surface": "apple",
@@ -26067,7 +26067,7 @@
},
{
"kind": "ui-localized-call",
"line": 3877,
"line": 4082,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Realtime disconnected",
"surface": "apple",
@@ -26075,7 +26075,7 @@
},
{
"kind": "ui-localized-call",
"line": 3879,
"line": 4084,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "OpenClaw unavailable",
"surface": "apple",
@@ -26083,7 +26083,7 @@
},
{
"kind": "ui-localized-call",
"line": 3919,
"line": 4124,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "iOS Speech + TTS",
"surface": "apple",
@@ -26091,7 +26091,7 @@
},
{
"kind": "ui-localized-call",
"line": 3921,
"line": 4126,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Listening",
"surface": "apple",
@@ -26099,7 +26099,7 @@
},
{
"kind": "ui-localized-call",
"line": 3925,
"line": 4130,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "iOS Speech fallback",
"surface": "apple",
@@ -26107,7 +26107,7 @@
},
{
"kind": "ui-localized-call",
"line": 4186,
"line": 4391,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Gateway Relay",
"surface": "apple",
@@ -26115,7 +26115,7 @@
},
{
"kind": "ui-localized-call",
"line": 4188,
"line": 4393,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Native WebRTC",
"surface": "apple",
@@ -26123,7 +26123,7 @@
},
{
"kind": "ui-localized-call",
"line": 4189,
"line": 4394,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Native",
"surface": "apple",
@@ -26131,7 +26131,7 @@
},
{
"kind": "ui-localized-call",
"line": 4250,
"line": 4455,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Gateway permission required",
"surface": "apple",
@@ -26139,7 +26139,7 @@
},
{
"kind": "ui-localized-call",
"line": 4272,
"line": 4477,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Not loaded",
"surface": "apple",
@@ -26147,7 +26147,7 @@
},
{
"kind": "ui-localized-call",
"line": 4290,
"line": 4495,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Approval requested",
"surface": "apple",
@@ -26155,7 +26155,7 @@
},
{
"kind": "ui-localized-call",
"line": 4729,
"line": 4934,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Realtime unavailable",
"surface": "apple",
@@ -26163,7 +26163,7 @@
},
{
"kind": "ui-localized-call",
"line": 4751,
"line": 4956,
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
"source": "Listening (PTT)",
"surface": "apple",
@@ -26171,7 +26171,7 @@
},
{
"kind": "conditional-branch",
"line": 446,
"line": 616,
"path": "apps/ios/Sources/Voice/TalkRealtimeWebRTCSession.swift",
"source": "Asking OpenClaw",
"surface": "apple",
@@ -26179,12 +26179,28 @@
},
{
"kind": "conditional-branch",
"line": 446,
"line": 616,
"path": "apps/ios/Sources/Voice/TalkRealtimeWebRTCSession.swift",
"source": "Updating OpenClaw",
"surface": "apple",
"id": "native.apple.d2db26b3bb266491"
},
{
"kind": "conditional-branch",
"line": 705,
"path": "apps/ios/Sources/Voice/TalkRealtimeWebRTCSession.swift",
"source": "Confirmation needed",
"surface": "apple",
"id": "native.apple.4b89dccea20c58fa"
},
{
"kind": "conditional-branch",
"line": 705,
"path": "apps/ios/Sources/Voice/TalkRealtimeWebRTCSession.swift",
"source": "OpenClaw unavailable",
"surface": "apple",
"id": "native.apple.8a62f0e9416188e3"
},
{
"kind": "ui-localized-call",
"line": 47,
+10
View File
@@ -16368,6 +16368,16 @@
"source": "Updating OpenClaw",
"translated": "جارٍ تحديث OpenClaw"
},
{
"id": "native.apple.4b89dccea20c58fa",
"source": "Confirmation needed",
"translated": "التأكيد مطلوب"
},
{
"id": "native.apple.8a62f0e9416188e3",
"source": "OpenClaw unavailable",
"translated": "OpenClaw غير متاح"
},
{
"id": "native.apple.8a944b06087fe8ef",
"source": "Microphone input format unavailable",
+10
View File
@@ -16368,6 +16368,16 @@
"source": "Updating OpenClaw",
"translated": "OpenClaw wird aktualisiert"
},
{
"id": "native.apple.4b89dccea20c58fa",
"source": "Confirmation needed",
"translated": "Bestätigung erforderlich"
},
{
"id": "native.apple.8a62f0e9416188e3",
"source": "OpenClaw unavailable",
"translated": "OpenClaw nicht verfügbar"
},
{
"id": "native.apple.8a944b06087fe8ef",
"source": "Microphone input format unavailable",
+10
View File
@@ -16368,6 +16368,16 @@
"source": "Updating OpenClaw",
"translated": "Actualizando OpenClaw"
},
{
"id": "native.apple.4b89dccea20c58fa",
"source": "Confirmation needed",
"translated": "Se necesita confirmación"
},
{
"id": "native.apple.8a62f0e9416188e3",
"source": "OpenClaw unavailable",
"translated": "OpenClaw no está disponible"
},
{
"id": "native.apple.8a944b06087fe8ef",
"source": "Microphone input format unavailable",
+10
View File
@@ -16368,6 +16368,16 @@
"source": "Updating OpenClaw",
"translated": "در حال به‌روزرسانی OpenClaw"
},
{
"id": "native.apple.4b89dccea20c58fa",
"source": "Confirmation needed",
"translated": "نیاز به تأیید"
},
{
"id": "native.apple.8a62f0e9416188e3",
"source": "OpenClaw unavailable",
"translated": "OpenClaw در دسترس نیست"
},
{
"id": "native.apple.8a944b06087fe8ef",
"source": "Microphone input format unavailable",
+10
View File
@@ -16368,6 +16368,16 @@
"source": "Updating OpenClaw",
"translated": "Mise à jour dOpenClaw"
},
{
"id": "native.apple.4b89dccea20c58fa",
"source": "Confirmation needed",
"translated": "Confirmation requise"
},
{
"id": "native.apple.8a62f0e9416188e3",
"source": "OpenClaw unavailable",
"translated": "OpenClaw indisponible"
},
{
"id": "native.apple.8a944b06087fe8ef",
"source": "Microphone input format unavailable",
+10
View File
@@ -16368,6 +16368,16 @@
"source": "Updating OpenClaw",
"translated": "OpenClaw अपडेट किया जा रहा है"
},
{
"id": "native.apple.4b89dccea20c58fa",
"source": "Confirmation needed",
"translated": "पुष्टि आवश्यक है"
},
{
"id": "native.apple.8a62f0e9416188e3",
"source": "OpenClaw unavailable",
"translated": "OpenClaw उपलब्ध नहीं है"
},
{
"id": "native.apple.8a944b06087fe8ef",
"source": "Microphone input format unavailable",
+10
View File
@@ -16368,6 +16368,16 @@
"source": "Updating OpenClaw",
"translated": "Memperbarui OpenClaw"
},
{
"id": "native.apple.4b89dccea20c58fa",
"source": "Confirmation needed",
"translated": "Konfirmasi diperlukan"
},
{
"id": "native.apple.8a62f0e9416188e3",
"source": "OpenClaw unavailable",
"translated": "OpenClaw tidak tersedia"
},
{
"id": "native.apple.8a944b06087fe8ef",
"source": "Microphone input format unavailable",
+10
View File
@@ -16368,6 +16368,16 @@
"source": "Updating OpenClaw",
"translated": "Aggiornamento di OpenClaw"
},
{
"id": "native.apple.4b89dccea20c58fa",
"source": "Confirmation needed",
"translated": "Conferma necessaria"
},
{
"id": "native.apple.8a62f0e9416188e3",
"source": "OpenClaw unavailable",
"translated": "OpenClaw non disponibile"
},
{
"id": "native.apple.8a944b06087fe8ef",
"source": "Microphone input format unavailable",
+10
View File
@@ -16368,6 +16368,16 @@
"source": "Updating OpenClaw",
"translated": "OpenClaw を更新中"
},
{
"id": "native.apple.4b89dccea20c58fa",
"source": "Confirmation needed",
"translated": "確認が必要です"
},
{
"id": "native.apple.8a62f0e9416188e3",
"source": "OpenClaw unavailable",
"translated": "OpenClawは利用できません"
},
{
"id": "native.apple.8a944b06087fe8ef",
"source": "Microphone input format unavailable",
+10
View File
@@ -16368,6 +16368,16 @@
"source": "Updating OpenClaw",
"translated": "OpenClaw 업데이트 중"
},
{
"id": "native.apple.4b89dccea20c58fa",
"source": "Confirmation needed",
"translated": "확인이 필요합니다"
},
{
"id": "native.apple.8a62f0e9416188e3",
"source": "OpenClaw unavailable",
"translated": "OpenClaw을 사용할 수 없습니다"
},
{
"id": "native.apple.8a944b06087fe8ef",
"source": "Microphone input format unavailable",
+10
View File
@@ -16368,6 +16368,16 @@
"source": "Updating OpenClaw",
"translated": "OpenClaw bijwerken"
},
{
"id": "native.apple.4b89dccea20c58fa",
"source": "Confirmation needed",
"translated": "Bevestiging vereist"
},
{
"id": "native.apple.8a62f0e9416188e3",
"source": "OpenClaw unavailable",
"translated": "OpenClaw niet beschikbaar"
},
{
"id": "native.apple.8a944b06087fe8ef",
"source": "Microphone input format unavailable",
+10
View File
@@ -16368,6 +16368,16 @@
"source": "Updating OpenClaw",
"translated": "Aktualizowanie OpenClaw"
},
{
"id": "native.apple.4b89dccea20c58fa",
"source": "Confirmation needed",
"translated": "Wymagane potwierdzenie"
},
{
"id": "native.apple.8a62f0e9416188e3",
"source": "OpenClaw unavailable",
"translated": "OpenClaw jest niedostępny"
},
{
"id": "native.apple.8a944b06087fe8ef",
"source": "Microphone input format unavailable",
+10
View File
@@ -16368,6 +16368,16 @@
"source": "Updating OpenClaw",
"translated": "Atualizando o OpenClaw"
},
{
"id": "native.apple.4b89dccea20c58fa",
"source": "Confirmation needed",
"translated": "Confirmação necessária"
},
{
"id": "native.apple.8a62f0e9416188e3",
"source": "OpenClaw unavailable",
"translated": "OpenClaw indisponível"
},
{
"id": "native.apple.8a944b06087fe8ef",
"source": "Microphone input format unavailable",
+10
View File
@@ -16368,6 +16368,16 @@
"source": "Updating OpenClaw",
"translated": "Обновление OpenClaw"
},
{
"id": "native.apple.4b89dccea20c58fa",
"source": "Confirmation needed",
"translated": "Требуется подтверждение"
},
{
"id": "native.apple.8a62f0e9416188e3",
"source": "OpenClaw unavailable",
"translated": "OpenClaw недоступен"
},
{
"id": "native.apple.8a944b06087fe8ef",
"source": "Microphone input format unavailable",
+10
View File
@@ -16368,6 +16368,16 @@
"source": "Updating OpenClaw",
"translated": "Uppdaterar OpenClaw"
},
{
"id": "native.apple.4b89dccea20c58fa",
"source": "Confirmation needed",
"translated": "Bekräftelse krävs"
},
{
"id": "native.apple.8a62f0e9416188e3",
"source": "OpenClaw unavailable",
"translated": "OpenClaw är inte tillgängligt"
},
{
"id": "native.apple.8a944b06087fe8ef",
"source": "Microphone input format unavailable",
+10
View File
@@ -16368,6 +16368,16 @@
"source": "Updating OpenClaw",
"translated": "กำลังอัปเดต OpenClaw"
},
{
"id": "native.apple.4b89dccea20c58fa",
"source": "Confirmation needed",
"translated": "ต้องยืนยัน"
},
{
"id": "native.apple.8a62f0e9416188e3",
"source": "OpenClaw unavailable",
"translated": "OpenClaw ไม่พร้อมใช้งาน"
},
{
"id": "native.apple.8a944b06087fe8ef",
"source": "Microphone input format unavailable",
+10
View File
@@ -16368,6 +16368,16 @@
"source": "Updating OpenClaw",
"translated": "OpenClaw güncelleniyor"
},
{
"id": "native.apple.4b89dccea20c58fa",
"source": "Confirmation needed",
"translated": "Onay gerekli"
},
{
"id": "native.apple.8a62f0e9416188e3",
"source": "OpenClaw unavailable",
"translated": "OpenClaw kullanılamıyor"
},
{
"id": "native.apple.8a944b06087fe8ef",
"source": "Microphone input format unavailable",
+10
View File
@@ -16368,6 +16368,16 @@
"source": "Updating OpenClaw",
"translated": "Оновлення OpenClaw"
},
{
"id": "native.apple.4b89dccea20c58fa",
"source": "Confirmation needed",
"translated": "Потрібне підтвердження"
},
{
"id": "native.apple.8a62f0e9416188e3",
"source": "OpenClaw unavailable",
"translated": "OpenClaw недоступний"
},
{
"id": "native.apple.8a944b06087fe8ef",
"source": "Microphone input format unavailable",
+10
View File
@@ -16368,6 +16368,16 @@
"source": "Updating OpenClaw",
"translated": "Đang cập nhật OpenClaw"
},
{
"id": "native.apple.4b89dccea20c58fa",
"source": "Confirmation needed",
"translated": "Cần xác nhận"
},
{
"id": "native.apple.8a62f0e9416188e3",
"source": "OpenClaw unavailable",
"translated": "OpenClaw không khả dụng"
},
{
"id": "native.apple.8a944b06087fe8ef",
"source": "Microphone input format unavailable",
+10
View File
@@ -16368,6 +16368,16 @@
"source": "Updating OpenClaw",
"translated": "正在更新 OpenClaw"
},
{
"id": "native.apple.4b89dccea20c58fa",
"source": "Confirmation needed",
"translated": "需要确认"
},
{
"id": "native.apple.8a62f0e9416188e3",
"source": "OpenClaw unavailable",
"translated": "OpenClaw 不可用"
},
{
"id": "native.apple.8a944b06087fe8ef",
"source": "Microphone input format unavailable",
+10
View File
@@ -16368,6 +16368,16 @@
"source": "Updating OpenClaw",
"translated": "正在更新 OpenClaw"
},
{
"id": "native.apple.4b89dccea20c58fa",
"source": "Confirmation needed",
"translated": "需要確認"
},
{
"id": "native.apple.8a62f0e9416188e3",
"source": "OpenClaw unavailable",
"translated": "OpenClaw 無法使用"
},
{
"id": "native.apple.8a944b06087fe8ef",
"source": "Microphone input format unavailable",
+136
View File
@@ -34681,6 +34681,142 @@
}
}
},
"Confirmation needed": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Confirmation needed"
}
},
"zh-CN": {
"stringUnit": {
"state": "translated",
"value": "需要确认"
}
},
"zh-TW": {
"stringUnit": {
"state": "translated",
"value": "需要確認"
}
},
"pt-BR": {
"stringUnit": {
"state": "translated",
"value": "Confirmação necessária"
}
},
"de": {
"stringUnit": {
"state": "translated",
"value": "Bestätigung erforderlich"
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "Se necesita confirmación"
}
},
"ja-JP": {
"stringUnit": {
"state": "translated",
"value": "確認が必要です"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "확인이 필요합니다"
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Confirmation requise"
}
},
"hi": {
"stringUnit": {
"state": "translated",
"value": "पुष्टि आवश्यक है"
}
},
"ar": {
"stringUnit": {
"state": "translated",
"value": "التأكيد مطلوب"
}
},
"it": {
"stringUnit": {
"state": "translated",
"value": "Conferma necessaria"
}
},
"tr": {
"stringUnit": {
"state": "translated",
"value": "Onay gerekli"
}
},
"uk": {
"stringUnit": {
"state": "translated",
"value": "Потрібне підтвердження"
}
},
"id": {
"stringUnit": {
"state": "translated",
"value": "Konfirmasi diperlukan"
}
},
"pl": {
"stringUnit": {
"state": "translated",
"value": "Wymagane potwierdzenie"
}
},
"th": {
"stringUnit": {
"state": "translated",
"value": "ต้องยืนยัน"
}
},
"vi": {
"stringUnit": {
"state": "translated",
"value": "Cần xác nhận"
}
},
"nl": {
"stringUnit": {
"state": "translated",
"value": "Bevestiging vereist"
}
},
"fa": {
"stringUnit": {
"state": "translated",
"value": "نیاز به تأیید"
}
},
"ru": {
"stringUnit": {
"state": "translated",
"value": "Требуется подтверждение"
}
},
"sv": {
"stringUnit": {
"state": "translated",
"value": "Bekräftelse krävs"
}
}
}
},
"Connect": {
"localizations": {
"en": {
+214 -9
View File
@@ -241,6 +241,9 @@ final class TalkModeManager: NSObject {
private var recognitionGeneration: UInt64 = 0
private var silenceTask: Task<Void, Never>?
private var realtimeSession: TalkRealtimeWebRTCSession?
private var activeRealtimeVoiceSessionId: String?
private var realtimeVoiceSessionGeneration: UInt64 = 0
private let realtimeTranscriptStore = TalkRealtimeTranscriptStore()
private var realtimeSessionReadyAt: Date?
private var rapidRealtimeRestartCount = 0
private var bypassRealtimeOnNextStart = false
@@ -541,9 +544,7 @@ final class TalkModeManager: NSObject {
}
self.gatewayTalkActiveModeTitle = String(localized: "Not active")
self.gatewayTalkActiveModeSubtitle = nil
self.realtimePrefetchGeneration &+= 1
self.realtimePrefetchTask?.cancel()
self.realtimePrefetchTask = nil
self.cancelRealtimePrefetch()
self.prefetchedRealtimeSession = nil
}
}
@@ -561,6 +562,7 @@ final class TalkModeManager: NSObject {
_ = self.cancelPushToTalk(captureId: captureId)
}
self.cancelFinishingPushToTalk()
self.cancelRealtimePrefetch()
if hasTalkOwner {
// Session identity owns every in-flight relay/capture generation, even while
// Talk is disabled. Leaving one alive can publish work into the replacement session.
@@ -570,6 +572,7 @@ final class TalkModeManager: NSObject {
self.stopNativeCaptureAndDiscardTranscript()
deactivateAudioSession()
}
self.closeLogicalRealtimeVoiceSessions()
self.mainSessionKey = trimmed
if shouldRestartTalk, self.gatewayConnected, self.isEnabled {
Task { await self.start() }
@@ -806,6 +809,12 @@ final class TalkModeManager: NSObject {
self.isStarting = false
}
private func cancelRealtimePrefetch() {
self.realtimePrefetchGeneration &+= 1
self.realtimePrefetchTask?.cancel()
self.realtimePrefetchTask = nil
}
private var talkProviderSelection: TalkModeProviderSelection {
TalkModeProviderSelection.resolved(
UserDefaults.standard.string(forKey: TalkModeProviderSelection.storageKey))
@@ -860,7 +869,9 @@ final class TalkModeManager: NSObject {
self.silenceTask?.cancel()
self.silenceTask = nil
self.resetRealtimeRestartState()
self.cancelRealtimePrefetch()
self.stopRealtimeSession()
self.closeLogicalRealtimeVoiceSessions()
self.stopRecognition()
self.stopSpeaking()
self.lastInterruptedAtSeconds = nil
@@ -2198,10 +2209,17 @@ final class TalkModeManager: NSObject {
}
guard self.isCurrentStartAttempt(attemptID) else { return .ignored }
let prefetchedSession = self.consumePrefetchedRealtimeSession()
GatewayDiagnostics.log("talk.timeline realtime start attempt sessionKey=\(self.mainSessionKey)")
if let prefetchedSession {
self.activeRealtimeVoiceSessionId = prefetchedSession.voiceSessionId
}
let sessionKey = self.mainSessionKey
let voiceSessionGeneration = self.realtimeVoiceSessionGeneration
GatewayDiagnostics.log("talk.timeline realtime start attempt sessionKey=\(sessionKey)")
let session = TalkRealtimeWebRTCSession(
gateway: gateway,
sessionKey: mainSessionKey,
sessionKey: sessionKey,
voiceSessionId: self.activeRealtimeVoiceSessionId,
transcriptStore: self.realtimeTranscriptStore,
delegate: self)
self.realtimeSession = session
// WebRTC owns the shared AVAudioSession internally; track the attempt
@@ -2214,9 +2232,24 @@ final class TalkModeManager: NSObject {
voice: self.realtimeVoiceId,
prefetchedSession: prefetchedSession)
guard self.realtimeSession === session, self.isCurrentStartAttempt(attemptID) else {
session.stop()
self.finishStaleRealtimeStart(
session,
gateway: gateway,
sessionKey: sessionKey,
voiceSessionGeneration: voiceSessionGeneration)
return .ignored
}
guard let voiceSessionId = session.voiceSessionId,
self.adoptRealtimeVoiceSessionId(
voiceSessionId,
gateway: gateway,
sessionKey: sessionKey)
else {
self.stopRealtimeSession()
return .unavailable(realtimeIssue(
message: "Gateway returned a conflicting realtime voice session",
phase: "start"))
}
// WebRTC configures the shared session and may force speaker + built-in mic.
// Apply the user's input last so the explicit microphone selection wins.
Self.applyPreferredInput(self.preferredInputDeviceID, to: AVAudioSession.sharedInstance())
@@ -2227,9 +2260,19 @@ final class TalkModeManager: NSObject {
return .started
} catch {
guard self.realtimeSession === session, self.isCurrentStartAttempt(attemptID) else {
session.stop()
self.finishStaleRealtimeStart(
session,
gateway: gateway,
sessionKey: sessionKey,
voiceSessionGeneration: voiceSessionGeneration)
return .ignored
}
if let voiceSessionId = session.voiceSessionId {
_ = self.adoptRealtimeVoiceSessionId(
voiceSessionId,
gateway: gateway,
sessionKey: sessionKey)
}
self.stopRealtimeSession()
let issue = realtimeIssue(from: error, phase: "start")
GatewayDiagnostics
@@ -2241,6 +2284,51 @@ final class TalkModeManager: NSObject {
}
}
private func finishStaleRealtimeStart(
_ session: TalkRealtimeWebRTCSession,
gateway: GatewayNodeSession,
sessionKey: String,
voiceSessionGeneration: UInt64)
{
if let voiceSessionId = session.voiceSessionId {
if self.activeRealtimeVoiceSessionId == nil,
self.realtimeSession == nil,
self.isEnabled,
self.mainSessionKey == sessionKey,
self.realtimeVoiceSessionGeneration == voiceSessionGeneration
{
// A transport restart keeps the logical voice session alive.
self.activeRealtimeVoiceSessionId = voiceSessionId
} else if self.activeRealtimeVoiceSessionId != voiceSessionId {
self.closeOrphanedRealtimeVoiceSession(
gateway: gateway,
sessionKey: sessionKey,
voiceSessionId: voiceSessionId)
}
}
session.stop()
}
@discardableResult
private func adoptRealtimeVoiceSessionId(
_ voiceSessionId: String,
gateway: GatewayNodeSession,
sessionKey: String) -> Bool
{
if let activeRealtimeVoiceSessionId, activeRealtimeVoiceSessionId != voiceSessionId {
GatewayDiagnostics.log(
"talk realtime voice session mismatch active=\(activeRealtimeVoiceSessionId) "
+ "returned=\(voiceSessionId)")
self.closeOrphanedRealtimeVoiceSession(
gateway: gateway,
sessionKey: sessionKey,
voiceSessionId: voiceSessionId)
return false
}
self.activeRealtimeVoiceSessionId = voiceSessionId
return true
}
private func startRealtimeRelayIfAvailable(attemptID: Int) async -> RealtimeStartResult {
guard let gateway else {
return .unavailable(realtimeIssue(message: "Gateway not connected", phase: "start"))
@@ -2376,6 +2464,8 @@ final class TalkModeManager: NSObject {
GatewayDiagnostics.log("talk.timeline realtime prefetch scheduled reason=\(reason)")
self.realtimePrefetchGeneration &+= 1
let prefetchGeneration = self.realtimePrefetchGeneration
let sessionKey = self.mainSessionKey
let requestedVoiceSessionId = self.activeRealtimeVoiceSessionId
self.realtimePrefetchTask = Task { @MainActor [weak self] in
guard let self else { return }
defer {
@@ -2391,10 +2481,30 @@ final class TalkModeManager: NSObject {
let session = try await self.createRealtimeClientSession(
gateway: gateway,
route: route,
sessionKey: sessionKey,
voiceSessionId: requestedVoiceSessionId,
provider: self.realtimeProvider,
model: self.realtimeModelId,
voice: self.realtimeVoiceId)
guard !Task.isCancelled, shouldApply() else { return }
guard let voiceSessionId = session.voiceSessionId else {
throw NSError(domain: "TalkRealtimeVoiceSession", code: 2, userInfo: [
NSLocalizedDescriptionKey: "Gateway did not return a realtime voice session",
])
}
guard !Task.isCancelled, shouldApply(), self.mainSessionKey == sessionKey else {
if self.activeRealtimeVoiceSessionId != voiceSessionId {
self.closeOrphanedRealtimeVoiceSession(
gateway: gateway,
sessionKey: sessionKey,
voiceSessionId: voiceSessionId)
}
return
}
guard self.adoptRealtimeVoiceSessionId(
voiceSessionId,
gateway: gateway,
sessionKey: sessionKey)
else { return }
self.prefetchedRealtimeSession = session
GatewayDiagnostics.log(
"talk.timeline realtime prefetch ready elapsedMs=\(Self.elapsedMs(since: startedAt)) "
@@ -2411,11 +2521,19 @@ final class TalkModeManager: NSObject {
private func createRealtimeClientSession(
gateway: GatewayNodeSession,
route: GatewayNodeSessionRoute,
sessionKey: String,
voiceSessionId: String?,
provider: String?,
model: String?,
voice: String?) async throws -> TalkRealtimeClientSession
{
let params = TalkRealtimeClientCreateParams(provider: provider, model: model, voice: voice)
let params = TalkRealtimeClientCreateParams(
sessionKey: sessionKey,
voiceSessionId: voiceSessionId,
provider: provider,
model: model,
voice: voice,
capabilities: ["voice-transcript"])
let data = try JSONEncoder().encode(params)
let json = String(data: data, encoding: .utf8)
let res = try await gateway.request(
@@ -2447,6 +2565,12 @@ final class TalkModeManager: NSObject {
private func stopRealtimeSession() {
let realtimeSession = self.realtimeSession
if self.activeRealtimeVoiceSessionId == nil,
let voiceSessionId = realtimeSession?.voiceSessionId
{
// Capture ownership before stopping the transport so close can drain its queue.
self.activeRealtimeVoiceSessionId = voiceSessionId
}
let hadRealtimeOwner = realtimeSession != nil ||
self.realtimeRelaySession != nil ||
self.realtimeRelayStartGeneration != nil
@@ -2467,6 +2591,87 @@ final class TalkModeManager: NSObject {
}
}
private func closeLogicalRealtimeVoiceSessions() {
// A close boundary invalidates every in-flight transport that captured the old owner.
self.realtimeVoiceSessionGeneration &+= 1
let voiceSessionIds = Set([
self.activeRealtimeVoiceSessionId,
self.prefetchedRealtimeSession?.voiceSessionId,
self.realtimeSession?.voiceSessionId,
].compactMap(\.self))
self.activeRealtimeVoiceSessionId = nil
self.prefetchedRealtimeSession = nil
guard !voiceSessionIds.isEmpty else { return }
let gateway = self.gateway
let sessionKey = self.mainSessionKey
let transcriptStore = self.realtimeTranscriptStore
Task { @MainActor in
defer { transcriptStore.remove(voiceSessionIds) }
for voiceSessionId in voiceSessionIds.sorted() {
await transcriptStore.flush(voiceSessionId: voiceSessionId)
}
for voiceSessionId in voiceSessionIds.sorted() {
guard let gateway else {
GatewayDiagnostics.log(
"talk voice session close FAILED voiceSessionId=\(voiceSessionId) error=gateway unavailable")
continue
}
do {
try await Self.closeRealtimeVoiceSession(
gateway: gateway,
sessionKey: sessionKey,
voiceSessionId: voiceSessionId)
} catch {
GatewayDiagnostics.log(
"talk voice session close FAILED voiceSessionId=\(voiceSessionId) "
+ "error=\(error.localizedDescription)")
}
}
}
}
private static func closeRealtimeVoiceSession(
gateway: GatewayNodeSession,
sessionKey: String,
voiceSessionId: String) async throws
{
let params = TalkRealtimeClientCloseParams(
sessionKey: sessionKey,
voiceSessionId: voiceSessionId)
let data = try JSONEncoder().encode(params)
guard let json = String(data: data, encoding: .utf8) else {
throw NSError(domain: "TalkRealtimeVoiceSession", code: 1, userInfo: [
NSLocalizedDescriptionKey: "Failed to encode close request",
])
}
_ = try await gateway.request(
method: "talk.client.close",
paramsJSON: json,
timeoutSeconds: 10)
}
private func closeOrphanedRealtimeVoiceSession(
gateway: GatewayNodeSession,
sessionKey: String,
voiceSessionId: String)
{
let transcriptStore = self.realtimeTranscriptStore
Task { @MainActor in
defer { transcriptStore.remove([voiceSessionId]) }
await transcriptStore.flush(voiceSessionId: voiceSessionId)
do {
try await Self.closeRealtimeVoiceSession(
gateway: gateway,
sessionKey: sessionKey,
voiceSessionId: voiceSessionId)
} catch {
GatewayDiagnostics.log(
"talk voice session close FAILED voiceSessionId=\(voiceSessionId) "
+ "error=\(error.localizedDescription)")
}
}
}
private func buildPrompt(transcript: String) -> String {
let interrupted = self.lastInterruptedAtSeconds
self.lastInterruptedAtSeconds = nil
@@ -1,17 +1,21 @@
import Foundation
struct TalkRealtimeClientCreateParams: Encodable {
var sessionKey: String?
var voiceSessionId: String?
var mode = "realtime"
var provider: String?
var transport = "webrtc"
var brain = "agent-consult"
var model: String?
var voice: String?
var capabilities: [String]
}
struct TalkRealtimeClientSession: Decodable {
let provider: String
let transport: String
let voiceSessionId: String?
let clientSecret: String
let offerUrl: String?
let offerHeaders: [String: String]?
@@ -24,6 +28,25 @@ struct TalkRealtimeClientSession: Decodable {
}
}
enum TalkRealtimeTranscriptRole: String, Encodable {
case user
case assistant
}
struct TalkRealtimeTranscriptParams: Encodable {
let sessionKey: String
let voiceSessionId: String
let entryId: String
let role: TalkRealtimeTranscriptRole
let text: String
let timestamp: Double?
}
struct TalkRealtimeClientCloseParams: Encodable {
let sessionKey: String
let voiceSessionId: String
}
struct TalkRealtimeToolCallResponse: Decodable {
let runId: String?
let idempotencyKey: String?
@@ -18,6 +18,114 @@ protocol TalkRealtimeWebRTCSessionDelegate: AnyObject {
func realtimeSessionDidFinish(_ session: TalkRealtimeWebRTCSession)
}
@MainActor
final class TalkRealtimeTranscriptWriteQueue {
typealias Persist = @MainActor (TalkRealtimeTranscriptParams) async throws -> Void
typealias FailureLog = @MainActor (String, Error) -> Void
private let retryDelaysNanoseconds: [UInt64]
private var tail: Task<Void, Never>?
private var generation = 0
init(retryDelaysNanoseconds: [UInt64] = [100_000_000, 250_000_000]) {
self.retryDelaysNanoseconds = retryDelaysNanoseconds
}
func enqueue(
_ params: TalkRealtimeTranscriptParams,
persist: @escaping Persist,
failureLog: @escaping FailureLog)
{
let previous = self.tail
self.generation += 1
let retryDelaysNanoseconds = Array(self.retryDelaysNanoseconds.prefix(2))
self.tail = Task { @MainActor in
await previous?.value
var finalError: Error?
for attempt in 0...retryDelaysNanoseconds.count {
do {
try await persist(params)
return
} catch {
finalError = error
guard attempt < retryDelaysNanoseconds.count else { break }
do {
try await Task.sleep(nanoseconds: retryDelaysNanoseconds[attempt])
} catch {
finalError = error
break
}
}
}
failureLog(
params.entryId,
finalError ?? NSError(
domain: "TalkRealtimeTranscript",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Transcript persistence failed"]))
}
}
func flush() async {
while let tail = self.tail {
let generation = self.generation
await tail.value
if self.generation == generation { return }
}
}
}
@MainActor
final class TalkRealtimeTranscriptStore {
private let retryDelaysNanoseconds: [UInt64]
private var lastEntryIdByVoiceSession: [String: Int] = [:]
private var queuesByVoiceSession: [String: TalkRealtimeTranscriptWriteQueue] = [:]
init(retryDelaysNanoseconds: [UInt64] = [100_000_000, 250_000_000]) {
self.retryDelaysNanoseconds = retryDelaysNanoseconds
}
@discardableResult
func enqueue(
sessionKey: String,
voiceSessionId: String,
role: TalkRealtimeTranscriptRole,
text: String,
timestamp: Double,
persist: @escaping TalkRealtimeTranscriptWriteQueue.Persist,
failureLog: @escaping TalkRealtimeTranscriptWriteQueue.FailureLog) -> String
{
let nextEntryId = (self.lastEntryIdByVoiceSession[voiceSessionId] ?? 0) + 1
self.lastEntryIdByVoiceSession[voiceSessionId] = nextEntryId
let entryId = String(nextEntryId)
let queue = self.queuesByVoiceSession[voiceSessionId] ?? TalkRealtimeTranscriptWriteQueue(
retryDelaysNanoseconds: self.retryDelaysNanoseconds)
self.queuesByVoiceSession[voiceSessionId] = queue
queue.enqueue(
TalkRealtimeTranscriptParams(
sessionKey: sessionKey,
voiceSessionId: voiceSessionId,
entryId: entryId,
role: role,
text: text,
timestamp: timestamp),
persist: persist,
failureLog: failureLog)
return entryId
}
func flush(voiceSessionId: String) async {
await self.queuesByVoiceSession[voiceSessionId]?.flush()
}
func remove(_ voiceSessionIds: Set<String>) {
for voiceSessionId in voiceSessionIds {
self.lastEntryIdByVoiceSession.removeValue(forKey: voiceSessionId)
self.queuesByVoiceSession.removeValue(forKey: voiceSessionId)
}
}
}
@MainActor
final class TalkRealtimeWebRTCSession: NSObject {
private static let logger = Logger(subsystem: "ai.openclawfoundation.app", category: "TalkRealtimeWebRTC")
@@ -37,7 +145,9 @@ final class TalkRealtimeWebRTCSession: NSObject {
private let gateway: GatewayNodeSession
private let sessionKey: String
private let transcriptStore: TalkRealtimeTranscriptStore
private weak var delegate: TalkRealtimeWebRTCSessionDelegate?
private var adoptedVoiceSessionId: String?
private var factory: RTCPeerConnectionFactory?
private var peerConnection: RTCPeerConnection?
@@ -72,13 +182,25 @@ final class TalkRealtimeWebRTCSession: NSObject {
let providerStarted: Bool?
}
init(gateway: GatewayNodeSession, sessionKey: String, delegate: TalkRealtimeWebRTCSessionDelegate) {
init(
gateway: GatewayNodeSession,
sessionKey: String,
voiceSessionId: String? = nil,
transcriptStore: TalkRealtimeTranscriptStore,
delegate: TalkRealtimeWebRTCSessionDelegate)
{
self.gateway = gateway
self.sessionKey = sessionKey
self.adoptedVoiceSessionId = voiceSessionId
self.transcriptStore = transcriptStore
self.delegate = delegate
super.init()
}
var voiceSessionId: String? {
self.adoptedVoiceSessionId
}
func start(
provider: String?,
model: String?,
@@ -107,6 +229,18 @@ final class TalkRealtimeWebRTCSession: NSObject {
} else {
session = try await self.createClientSession(provider: provider, model: model, voice: voice)
}
guard let returnedVoiceSessionId = session.voiceSessionId else {
throw NSError(domain: "TalkRealtimeWebRTC", code: 11, userInfo: [
NSLocalizedDescriptionKey: "Gateway did not return a realtime voice session",
])
}
let requestedVoiceSessionId = self.adoptedVoiceSessionId
self.adoptedVoiceSessionId = returnedVoiceSessionId
if let requestedVoiceSessionId, requestedVoiceSessionId != returnedVoiceSessionId {
throw NSError(domain: "TalkRealtimeWebRTC", code: 10, userInfo: [
NSLocalizedDescriptionKey: "Gateway returned a conflicting realtime voice session",
])
}
let sessionModel = session.model ?? "unknown"
let sessionVoice = session.voice ?? "unknown"
Self.logger.info(
@@ -284,7 +418,41 @@ final class TalkRealtimeWebRTCSession: NSObject {
}
}
private func recordFinalTranscript(role: TalkRealtimeTranscriptRole, text: String) {
guard let voiceSessionId = self.voiceSessionId else { return }
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
self.transcriptStore.enqueue(
sessionKey: self.sessionKey,
voiceSessionId: voiceSessionId,
role: role,
text: trimmed,
timestamp: Date().timeIntervalSince1970 * 1000,
persist: { [gateway] params in
let data = try JSONEncoder().encode(params)
guard let json = String(data: data, encoding: .utf8) else {
throw NSError(domain: "TalkRealtimeTranscript", code: 2, userInfo: [
NSLocalizedDescriptionKey: "Failed to encode transcript request",
])
}
_ = try await gateway.request(
method: "talk.client.transcript",
paramsJSON: json,
timeoutSeconds: 5)
},
failureLog: { entryId, error in
GatewayDiagnostics.log(
"talk transcript persist FAILED entryId=\(entryId) error=\(error.localizedDescription)")
})
}
func flushTranscriptWrites() async {
guard let voiceSessionId = self.voiceSessionId else { return }
await self.transcriptStore.flush(voiceSessionId: voiceSessionId)
}
private func handleRealtimeEvent(_ event: TalkRealtimeServerEvent) {
guard !self.stopped else { return }
if !self.seenRealtimeEventTypes.contains(event.type) {
self.seenRealtimeEventTypes.insert(event.type)
self.trace("event first type=\(event.type)")
@@ -306,6 +474,7 @@ final class TalkRealtimeWebRTCSession: NSObject {
"conversation.item.input_audio_transcription.completed":
if let text = event.transcript ?? event.text {
self.delegate?.realtimeSession(self, didReceiveUserTranscript: text)
self.recordFinalTranscript(role: .user, text: text)
}
case "conversation.output_transcript.delta",
"response.output_text.delta",
@@ -324,6 +493,7 @@ final class TalkRealtimeWebRTCSession: NSObject {
"response.output_audio_transcript.done":
if let text = event.transcript ?? event.text {
self.delegate?.realtimeSession(self, didReceiveAssistantTranscript: text)
self.recordFinalTranscript(role: .assistant, text: text)
}
case "response.function_call_arguments.delta":
self.bufferToolDelta(event)
@@ -473,12 +643,18 @@ final class TalkRealtimeWebRTCSession: NSObject {
}
do {
let args = try Self.decodeJSONObject(argsJSON)
let params: [String: Any] = [
await self.flushTranscriptWrites()
try Task.checkCancellation()
try self.checkNotStopped()
var params: [String: Any] = [
"sessionKey": sessionKey,
"callId": callId,
"name": Self.consultToolName,
"args": args,
]
if let voiceSessionId = self.voiceSessionId {
params["voiceSessionId"] = voiceSessionId
}
let historySince = Date().timeIntervalSince1970
let data = try JSONSerialization.data(withJSONObject: params)
guard let json = String(data: data, encoding: .utf8) else {
@@ -523,8 +699,11 @@ final class TalkRealtimeWebRTCSession: NSObject {
if let runId = activeToolRunIds[callId] {
await self.abortChatRun(runId: runId)
}
self.delegate?.realtimeSession(self, didChangeStatus: "OpenClaw unavailable")
let fallbackMessage = [
let confirmationInstruction = Self.voiceConfirmationInstruction(from: error)
self.delegate?.realtimeSession(
self,
didChangeStatus: confirmationInstruction == nil ? "OpenClaw unavailable" : "Confirmation needed")
let fallbackMessage = confirmationInstruction ?? [
"OpenClaw consult did not finish quickly enough.",
"Give a brief spoken fallback from the realtime conversation",
"and ask the user to try again if they need OpenClaw-specific context.",
@@ -601,6 +780,28 @@ final class TalkRealtimeWebRTCSession: NSObject {
return trimmed.isEmpty ? nil : trimmed
}
static func voiceConfirmationInstruction(from error: Error) -> String? {
let messages: [String] = if let responseError = error as? GatewayResponseError {
[responseError.message, responseError.detailsReason].compactMap(\.self)
} else {
[error.localizedDescription]
}
let marker = "VOICE_CONFIRMATION_REQUIRED:"
for message in messages {
guard let markerRange = message.range(of: marker) else { continue }
let suffix = message[markerRange.upperBound...]
guard let confirmationId = suffix.split(whereSeparator: { $0.isWhitespace }).first,
!confirmationId.isEmpty
else { continue }
return [
"\(marker)\(confirmationId) The requested action was not executed.",
"Ask the user for explicit spoken confirmation, then call openclaw_agent_consult again",
"with confirmationId \(confirmationId).",
].joined(separator: " ")
}
return nil
}
private static func controlResultMessage(from data: Data) -> String? {
guard let object = try? JSONSerialization.jsonObject(with: data),
let record = object as? [String: Any]
@@ -882,7 +1083,13 @@ extension TalkRealtimeWebRTCSession {
{
self.trace("gateway talk.client.create start")
let startedAt = ProcessInfo.processInfo.systemUptime
let params = TalkRealtimeClientCreateParams(provider: provider, model: model, voice: voice)
let params = TalkRealtimeClientCreateParams(
sessionKey: self.sessionKey,
voiceSessionId: self.adoptedVoiceSessionId,
provider: provider,
model: model,
voice: voice,
capabilities: ["voice-transcript"])
let data = try JSONEncoder().encode(params)
let json = String(data: data, encoding: .utf8)
let res = try await gateway.request(method: "talk.client.create", paramsJSON: json, timeoutSeconds: 12)
@@ -6,6 +6,62 @@ import Testing
@MainActor
struct TalkModeManagerTests {
@Test func `encodes realtime client voice session identity`() throws {
let params = TalkRealtimeClientCreateParams(
sessionKey: "agent:main:main",
voiceSessionId: "voice-1",
provider: "openai",
model: "gpt-realtime-2",
voice: "marin",
capabilities: ["voice-transcript"])
let object = try #require(
JSONSerialization.jsonObject(with: JSONEncoder().encode(params)) as? [String: Any])
#expect(object["sessionKey"] as? String == "agent:main:main")
#expect(object["voiceSessionId"] as? String == "voice-1")
#expect(object["capabilities"] as? [String] == ["voice-transcript"])
}
@Test func `decodes optional realtime client voice session id`() throws {
let session = try JSONDecoder().decode(
TalkRealtimeClientSession.self,
from: Data(
#"{"provider":"openai","transport":"webrtc","voiceSessionId":"voice-1","clientSecret":"secret"}"#.utf8))
#expect(session.voiceSessionId == "voice-1")
let serverOwned = try JSONDecoder().decode(
TalkRealtimeClientSession.self,
from: Data(#"{"provider":"openai","transport":"gateway-relay","clientSecret":"secret"}"#.utf8))
#expect(serverOwned.voiceSessionId == nil)
}
@Test func `encodes transcript entry id as a decimal string`() throws {
let params = TalkRealtimeTranscriptParams(
sessionKey: "agent:main:main",
voiceSessionId: "voice-1",
entryId: "1",
role: .assistant,
text: "hello",
timestamp: 1234)
let object = try #require(
JSONSerialization.jsonObject(with: JSONEncoder().encode(params)) as? [String: Any])
#expect(object["entryId"] as? String == "1")
}
@Test func `surfaces voice confirmation id for a follow up consult`() throws {
let error = GatewayResponseError(
method: "talk.client.toolCall",
code: "INVALID_REQUEST",
message: "VOICE_CONFIRMATION_REQUIRED:confirm_123 Ask the user to confirm.",
details: nil)
let instruction = try #require(TalkRealtimeWebRTCSession.voiceConfirmationInstruction(from: error))
#expect(instruction.contains("VOICE_CONFIRMATION_REQUIRED:confirm_123"))
#expect(instruction.contains("confirmationId confirm_123"))
}
@Test func `recognizes open AI maximum duration errors as terminal`() throws {
let event = try JSONDecoder().decode(
TalkRealtimeServerEvent.self,
@@ -0,0 +1,81 @@
import Foundation
import Testing
@testable import OpenClaw
@MainActor
struct TalkRealtimeTranscriptWriteQueueTests {
private struct PersistError: Error {}
@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])
for _ in 1...3 {
store.enqueue(
sessionKey: "agent:main:main",
voiceSessionId: "voice-1",
role: .user,
text: "hello",
timestamp: 1234,
persist: { params in
attempts.append(params.entryId)
if params.entryId == "1", attempts.filter({ $0 == "1" }).count < 3 {
throw PersistError()
}
if params.entryId == "2" {
throw PersistError()
}
},
failureLog: { entryId, _ in
failures.append(entryId)
})
}
await store.flush(voiceSessionId: "voice-1")
#expect(attempts == ["1", "1", "1", "2", "2", "2", "3"])
#expect(failures == ["2"])
}
@Test func `shares ordering across transport replacements`() async {
var persisted: [String] = []
let store = TalkRealtimeTranscriptStore(retryDelaysNanoseconds: [])
let persist: TalkRealtimeTranscriptWriteQueue.Persist = { params in
persisted.append("\(params.voiceSessionId):\(params.entryId)")
}
let failureLog: TalkRealtimeTranscriptWriteQueue.FailureLog = { _, _ in }
let firstTransportEntry = store.enqueue(
sessionKey: "agent:main:main",
voiceSessionId: "voice-a",
role: .user,
text: "one",
timestamp: 1,
persist: persist,
failureLog: failureLog)
let replacementTransportEntry = store.enqueue(
sessionKey: "agent:main:main",
voiceSessionId: "voice-a",
role: .assistant,
text: "two",
timestamp: 2,
persist: persist,
failureLog: failureLog)
let otherVoiceSessionEntry = store.enqueue(
sessionKey: "agent:main:main",
voiceSessionId: "voice-b",
role: .user,
text: "other",
timestamp: 3,
persist: persist,
failureLog: failureLog)
await store.flush(voiceSessionId: "voice-a")
await store.flush(voiceSessionId: "voice-b")
#expect(firstTransportEntry == "1")
#expect(replacementTransportEntry == "2")
#expect(otherVoiceSessionEntry == "1")
#expect(persisted.filter { $0.hasPrefix("voice-a:") } == ["voice-a:1", "voice-a:2"])
#expect(persisted.filter { $0.hasPrefix("voice-b:") } == ["voice-b:1"])
}
}