refactor(mobile): consolidate native gateway and voice lifecycles (#114671)

* refactor(mobile): consolidate native lifecycle ownership

* fix(ci): cover Codex prewarm and chat lifecycle proof
This commit is contained in:
Peter Steinberger
2026-07-27 14:52:02 -04:00
committed by GitHub
parent e2fbd3b1ed
commit f85a458a45
8 changed files with 463 additions and 645 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1671,7 +1671,7 @@ class NodeRuntime private constructor(
nodeStatusText = "Connected"
}
notificationOutbox.onConnected()
showLocalCanvasOnConnect()
resetLocalCanvas()
publishNodePresenceAliveBeacon(NodePresenceAliveBeacon.Trigger.Connect)
val endpoint = connectedEndpoint
val auth = activeGatewayAuth
@@ -1692,7 +1692,7 @@ class NodeRuntime private constructor(
nodeStatusText = message
nodeConnectionProblem = gatewayProblemAfterDisconnect(nodeConnectionProblem, message)
}
showLocalCanvasOnDisconnect()
resetLocalCanvas()
},
onConnectFailure = { error, pauseReconnect ->
updateStatus {
@@ -2201,18 +2201,15 @@ class NodeRuntime private constructor(
return if (trimmed.isEmpty()) "main" else trimmed
}
private fun showLocalCanvasOnConnect() {
private fun resetLocalCanvas() {
_canvasA2uiHydrated.value = false
_canvasRehydratePending.value = false
_canvasRehydrateErrorText.value = null
canvas.navigate("")
}
private fun showLocalCanvasOnDisconnect() {
_canvasA2uiHydrated.value = false
_canvasRehydratePending.value = false
_canvasRehydrateErrorText.value = null
canvas.navigate("")
private fun launchGatewayRefresh(refresh: suspend () -> Unit) {
if (mode != NodeRuntimeMode.ScreenshotFixture) scope.launch { refresh() }
}
fun refreshHomeCanvasOverviewIfConnected() {
@@ -2237,38 +2234,15 @@ class NodeRuntime private constructor(
}
}
fun refreshModelCatalog() {
if (mode == NodeRuntimeMode.ScreenshotFixture) return
scope.launch {
refreshModelCatalogFromGateway()
}
}
fun refreshModelCatalog() = launchGatewayRefresh { refreshModelCatalogFromGateway() }
fun refreshProviderModels() {
if (mode == NodeRuntimeMode.ScreenshotFixture) return
scope.launch {
refreshProviderModelsFromGateway()
}
}
fun refreshProviderModels() = launchGatewayRefresh { refreshProviderModelsFromGateway() }
fun refreshTalkSetupReadiness() {
if (mode == NodeRuntimeMode.ScreenshotFixture) return
scope.launch { refreshTalkSetupReadinessFromGateway() }
}
fun refreshTalkSetupReadiness() = launchGatewayRefresh { refreshTalkSetupReadinessFromGateway() }
fun refreshAgents() {
if (mode == NodeRuntimeMode.ScreenshotFixture) return
scope.launch {
refreshAgentsFromGateway()
}
}
fun refreshAgents() = launchGatewayRefresh { refreshAgentsFromGateway() }
fun refreshCronJobs() {
if (mode == NodeRuntimeMode.ScreenshotFixture) return
scope.launch {
refreshCronFromGateway()
}
}
fun refreshCronJobs() = launchGatewayRefresh { refreshCronFromGateway() }
fun loadCronJobDetail(id: String) {
val detailRequest = cronJobDetailRequestGuard.begin(id) ?: return
@@ -2441,19 +2415,9 @@ class NodeRuntime private constructor(
}
}
fun refreshUsage() {
if (mode == NodeRuntimeMode.ScreenshotFixture) return
scope.launch {
refreshUsageFromGateway()
}
}
fun refreshUsage() = launchGatewayRefresh { refreshUsageFromGateway() }
fun refreshSkills() {
if (mode == NodeRuntimeMode.ScreenshotFixture) return
scope.launch {
refreshSkillsFromGateway()
}
}
fun refreshSkills() = launchGatewayRefresh { refreshSkillsFromGateway() }
fun setSkillEnabled(
skillKey: String,
@@ -2576,12 +2540,7 @@ class NodeRuntime private constructor(
_skillWorkshopNoticeText.value = null
}
fun refreshNodesDevices() {
if (mode == NodeRuntimeMode.ScreenshotFixture) return
scope.launch {
refreshNodesDevicesFromGateway()
}
}
fun refreshNodesDevices() = launchGatewayRefresh { refreshNodesDevicesFromGateway() }
fun approveDevicePairing(
requestId: String,
@@ -2628,12 +2587,7 @@ class NodeRuntime private constructor(
}
}
fun refreshExecApprovals() {
if (mode == NodeRuntimeMode.ScreenshotFixture) return
scope.launch {
refreshExecApprovalsFromGateway()
}
}
fun refreshExecApprovals() = launchGatewayRefresh { refreshExecApprovalsFromGateway() }
fun resolveExecApproval(
id: String,
@@ -2654,26 +2608,11 @@ class NodeRuntime private constructor(
_execApprovalsNotice.compareAndSet(expected, null)
}
fun refreshChannels() {
if (mode == NodeRuntimeMode.ScreenshotFixture) return
scope.launch {
refreshChannelsFromGateway()
}
}
fun refreshChannels() = launchGatewayRefresh { refreshChannelsFromGateway() }
fun refreshDreaming() {
if (mode == NodeRuntimeMode.ScreenshotFixture) return
scope.launch {
refreshDreamingFromGateway()
}
}
fun refreshDreaming() = launchGatewayRefresh { refreshDreamingFromGateway() }
fun refreshHealthLogs() {
if (mode == NodeRuntimeMode.ScreenshotFixture) return
scope.launch {
refreshHealthLogsFromGateway()
}
}
fun refreshHealthLogs() = launchGatewayRefresh { refreshHealthLogsFromGateway() }
fun requestCanvasRehydrate(
source: String = "manual",
@@ -5475,6 +5414,36 @@ class NodeRuntime private constructor(
}
}
private suspend fun <T> refreshGatewaySummary(
summary: MutableStateFlow<T>,
refreshing: MutableStateFlow<Boolean>,
errorText: MutableStateFlow<NativeText?>,
disconnectedSummary: T,
failureText: NativeText,
fetch: suspend (GatewayDataScope) -> T,
): Boolean {
val gatewayScope = captureGatewayDataScope() ?: return false
publishGatewayData(gatewayScope) {
refreshing.value = true
errorText.value = null
}
if (!operatorConnected) {
summary.value = disconnectedSummary
refreshing.value = false
return false
}
return try {
val nextSummary = fetch(gatewayScope)
publishGatewayData(gatewayScope) { summary.value = nextSummary }
true
} catch (_: Throwable) {
publishGatewayData(gatewayScope) { errorText.value = failureText }
false
} finally {
publishGatewayData(gatewayScope) { refreshing.value = false }
}
}
/** Publishes approval state only while the response's operator socket still owns the method catalog. */
private inline fun publishGatewayApprovalData(
gatewayScope: GatewayDataScope,
@@ -6076,68 +6045,40 @@ class NodeRuntime private constructor(
}
}
private suspend fun refreshUsageFromGateway() {
val gatewayScope = captureGatewayDataScope() ?: return
publishGatewayData(gatewayScope) {
_usageRefreshing.value = true
_usageErrorText.value = null
private suspend fun refreshUsageFromGateway() =
refreshGatewaySummary(
summary = _usageSummary,
refreshing = _usageRefreshing,
errorText = _usageErrorText,
disconnectedSummary = GatewayUsageSummary(updatedAtMs = null, providers = emptyList()),
failureText = nativeText("Could not load usage."),
) { gatewayScope ->
val root = json.parseToJsonElement(requestGatewayData(gatewayScope, "usage.status", "{}")).asObjectOrNull()
GatewayUsageSummary(
updatedAtMs = root.long("updatedAt"),
providers = parseUsageProviders(root?.get("providers") as? JsonArray),
)
}
if (!operatorConnected) {
_usageSummary.value = GatewayUsageSummary(updatedAtMs = null, providers = emptyList())
_usageRefreshing.value = false
return
}
try {
val res = requestGatewayData(gatewayScope, "usage.status", "{}")
val root = json.parseToJsonElement(res).asObjectOrNull()
val summary =
GatewayUsageSummary(
updatedAtMs = root.long("updatedAt"),
providers = parseUsageProviders(root?.get("providers") as? JsonArray),
)
publishGatewayData(gatewayScope) { _usageSummary.value = summary }
} catch (_: Throwable) {
publishGatewayData(gatewayScope) { _usageErrorText.value = nativeText("Could not load usage.") }
} finally {
publishGatewayData(gatewayScope) { _usageRefreshing.value = false }
}
}
private suspend fun refreshSkillsFromGateway(): Boolean {
val gatewayScope = captureGatewayDataScope() ?: return false
publishGatewayData(gatewayScope) {
_skillsRefreshing.value = true
_skillsErrorText.value = null
private suspend fun refreshSkillsFromGateway(): Boolean =
refreshGatewaySummary(
summary = _skillsSummary,
refreshing = _skillsRefreshing,
errorText = _skillsErrorText,
disconnectedSummary = GatewaySkillsSummary(skills = emptyList()),
failureText = nativeText("Could not load skills."),
) { gatewayScope ->
val root = json.parseToJsonElement(requestGatewayData(gatewayScope, "skills.status", "{}")).asObjectOrNull()
GatewaySkillsSummary(
managedSkillsDirAvailable =
root
?.get("managedSkillsDir")
.asStringOrNull()
?.trim()
?.isNotEmpty() == true,
skills = parseSkillSummaries(root?.get("skills") as? JsonArray),
)
}
if (!operatorConnected) {
_skillsSummary.value = GatewaySkillsSummary(skills = emptyList())
_skillsRefreshing.value = false
return false
}
try {
val res = requestGatewayData(gatewayScope, "skills.status", "{}")
val root = json.parseToJsonElement(res).asObjectOrNull()
val summary =
GatewaySkillsSummary(
managedSkillsDirAvailable =
root
?.get("managedSkillsDir")
.asStringOrNull()
?.trim()
?.isNotEmpty() == true,
skills = parseSkillSummaries(root?.get("skills") as? JsonArray),
)
publishGatewayData(gatewayScope) {
_skillsSummary.value = summary
}
return true
} catch (_: Throwable) {
publishGatewayData(gatewayScope) { _skillsErrorText.value = nativeText("Could not load skills.") }
return false
} finally {
publishGatewayData(gatewayScope) { _skillsRefreshing.value = false }
}
}
private suspend fun setSkillEnabledOnGateway(
skillKey: String,
@@ -7542,101 +7483,64 @@ class NodeRuntime private constructor(
}
}
private suspend fun refreshChannelsFromGateway() {
val gatewayScope = captureGatewayDataScope() ?: return
publishGatewayData(gatewayScope) {
_channelsRefreshing.value = true
_channelsErrorText.value = null
private suspend fun refreshChannelsFromGateway() =
refreshGatewaySummary(
summary = _channelsSummary,
refreshing = _channelsRefreshing,
errorText = _channelsErrorText,
disconnectedSummary = GatewayChannelsSummary(channels = emptyList()),
failureText = nativeText("Could not load channels."),
) { gatewayScope ->
val response = requestGatewayData(gatewayScope, "channels.status", """{"probe":false,"timeoutMs":8000}""")
val root = json.parseToJsonElement(response).asObjectOrNull()
GatewayChannelsSummary(
updatedAtMs = root.long("ts"),
partial = root.boolean("partial"),
warnings = parseStringArray(root?.get("warnings") as? JsonArray),
channels = parseChannelSummaries(root),
)
}
if (!operatorConnected) {
_channelsSummary.value = GatewayChannelsSummary(channels = emptyList())
_channelsRefreshing.value = false
return
}
try {
val res = requestGatewayData(gatewayScope, "channels.status", """{"probe":false,"timeoutMs":8000}""")
val root = json.parseToJsonElement(res).asObjectOrNull()
val summary =
GatewayChannelsSummary(
updatedAtMs = root.long("ts"),
partial = root.boolean("partial"),
warnings = parseStringArray(root?.get("warnings") as? JsonArray),
channels = parseChannelSummaries(root),
)
publishGatewayData(gatewayScope) { _channelsSummary.value = summary }
} catch (_: Throwable) {
publishGatewayData(gatewayScope) { _channelsErrorText.value = nativeText("Could not load channels.") }
} finally {
publishGatewayData(gatewayScope) { _channelsRefreshing.value = false }
}
}
private suspend fun refreshDreamingFromGateway() {
val gatewayScope = captureGatewayDataScope() ?: return
publishGatewayData(gatewayScope) {
_dreamingRefreshing.value = true
_dreamingErrorText.value = null
private suspend fun refreshDreamingFromGateway() =
refreshGatewaySummary(
summary = _dreamingSummary,
refreshing = _dreamingRefreshing,
errorText = _dreamingErrorText,
disconnectedSummary = GatewayDreamingSummary(),
failureText = nativeText("Could not load dreaming."),
) { gatewayScope ->
val statusResponse = requestGatewayData(gatewayScope, "doctor.memory.status", "{}")
val statusRoot = json.parseToJsonElement(statusResponse).asObjectOrNull()
val diaryResponse = requestGatewayData(gatewayScope, "doctor.memory.dreamDiary", "{}")
val diaryRoot = json.parseToJsonElement(diaryResponse).asObjectOrNull()
parseDreamingSummary(dreaming = statusRoot?.get("dreaming").asObjectOrNull(), diary = diaryRoot)
}
if (!operatorConnected) {
_dreamingSummary.value = GatewayDreamingSummary()
_dreamingRefreshing.value = false
return
}
try {
val statusRes = requestGatewayData(gatewayScope, "doctor.memory.status", "{}")
val statusRoot = json.parseToJsonElement(statusRes).asObjectOrNull()
val diaryRes = requestGatewayData(gatewayScope, "doctor.memory.dreamDiary", "{}")
val diaryRoot = json.parseToJsonElement(diaryRes).asObjectOrNull()
val dreaming = statusRoot?.get("dreaming").asObjectOrNull()
val summary =
parseDreamingSummary(
dreaming = dreaming,
diary = diaryRoot,
)
publishGatewayData(gatewayScope) { _dreamingSummary.value = summary }
} catch (_: Throwable) {
publishGatewayData(gatewayScope) { _dreamingErrorText.value = nativeText("Could not load dreaming.") }
} finally {
publishGatewayData(gatewayScope) { _dreamingRefreshing.value = false }
}
}
private suspend fun refreshHealthLogsFromGateway() {
val gatewayScope = captureGatewayDataScope() ?: return
publishGatewayData(gatewayScope) {
_healthLogsRefreshing.value = true
_healthLogsErrorText.value = null
}
if (!operatorConnected) {
_healthLogsSummary.value = GatewayHealthLogsSummary()
_healthLogsRefreshing.value = false
return
}
try {
val res = requestGatewayData(gatewayScope, "logs.tail", """{"limit":40,"maxBytes":65536}""")
val root = json.parseToJsonElement(res).asObjectOrNull()
private suspend fun refreshHealthLogsFromGateway() =
refreshGatewaySummary(
summary = _healthLogsSummary,
refreshing = _healthLogsRefreshing,
errorText = _healthLogsErrorText,
disconnectedSummary = GatewayHealthLogsSummary(),
failureText = nativeText("Could not load gateway logs."),
) { gatewayScope ->
val response = requestGatewayData(gatewayScope, "logs.tail", """{"limit":40,"maxBytes":65536}""")
val root = json.parseToJsonElement(response).asObjectOrNull()
val lines = (root?.get("lines") as? JsonArray)?.mapNotNull { it.asStringOrNull() }.orEmpty()
val summary =
GatewayHealthLogsSummary(
fileName =
root
?.get("file")
.asStringOrNull()
?.trim()
?.takeIf { it.isNotEmpty() }
?.substringAfterLast('/')
?.substringAfterLast('\\'),
cursor = root.long("cursor"),
truncated = root.boolean("truncated"),
entries = lines.map { parseGatewayLogEntry(it) },
)
publishGatewayData(gatewayScope) { _healthLogsSummary.value = summary }
} catch (_: Throwable) {
publishGatewayData(gatewayScope) { _healthLogsErrorText.value = nativeText("Could not load gateway logs.") }
} finally {
publishGatewayData(gatewayScope) { _healthLogsRefreshing.value = false }
GatewayHealthLogsSummary(
fileName =
root
?.get("file")
.asStringOrNull()
?.trim()
?.takeIf { it.isNotEmpty() }
?.substringAfterLast('/')
?.substringAfterLast('\\'),
cursor = root.long("cursor"),
truncated = root.boolean("truncated"),
entries = lines.map { parseGatewayLogEntry(it) },
)
}
}
private fun parseGatewayLogEntry(line: String): GatewayLogEntry {
val sanitizedLine = sanitizeGatewayLogText(line)

View File

@@ -508,9 +508,7 @@ class ChatController internal constructor(
clearOptimisticMessages = false,
preserveDisconnectedOwnership = true,
)
pendingToolCallsById.clear()
publishPendingToolCalls()
_streamingAssistantText.value = null
clearLiveRunUi()
// Older gateways cannot restate plan state, so reconnect retains it until
// recovery proves another run, a terminal state, or an explicit empty snapshot.
_historyLoading.value = false
@@ -617,9 +615,7 @@ class ChatController internal constructor(
if (retireRunState) {
restoreRunStateOnReconnect = false
clearPendingRuns()
pendingToolCallsById.clear()
publishPendingToolCalls()
_streamingAssistantText.value = null
clearLiveRunUi()
}
clearPlanSteps()
appliedMainSessionKey = "main"
@@ -2183,9 +2179,7 @@ class ChatController internal constructor(
_healthOk.value = false
clearLiveHistoryMarker()
clearPendingRuns()
pendingToolCallsById.clear()
publishPendingToolCalls()
_streamingAssistantText.value = null
clearLiveRunUi()
clearPlanSteps()
_sessionId.value = null
_historyLoading.value = markLoading
@@ -2505,9 +2499,7 @@ class ChatController internal constructor(
settleProjectedRun(actualRunId)
if (ack.isTerminalSuccess) {
if (isCapturedOwnerCurrent()) {
pendingToolCallsById.clear()
publishPendingToolCalls()
_streamingAssistantText.value = null
clearLiveRunUi()
clearPlanSteps()
refreshCurrentHistoryBestEffort(runIdsToReconcile = setOf(actualRunId))
}
@@ -2516,9 +2508,7 @@ class ChatController internal constructor(
// Terminal timeout/error means the gateway did not accept a runnable turn.
// Surface failed acceptance instead of letting a cleared composer look successful.
if (isCapturedOwnerCurrent()) {
pendingToolCallsById.clear()
publishPendingToolCalls()
_streamingAssistantText.value = null
clearLiveRunUi()
clearPlanSteps()
updateLocalizedErrorText(nativeText("Chat failed before the run started; try again."))
}
@@ -2871,9 +2861,7 @@ class ChatController internal constructor(
// retain local ownership until the recovery snapshot can reconcile it.
resetSwarmProgress()
if (isSwarmEnabled()) refreshSwarmSessions()
pendingToolCallsById.clear()
publishPendingToolCalls()
_streamingAssistantText.value = null
clearLiveRunUi()
refreshQuestions()
refreshHistoryForRecovery()
}
@@ -5154,9 +5142,7 @@ class ChatController internal constructor(
val hasNewerRun =
synchronized(pendingRuns) { pendingRuns.isNotEmpty() } || unresolvedRepliesByRunId.isNotEmpty()
if (!hasNewerRun) {
pendingToolCallsById.clear()
publishPendingToolCalls()
_streamingAssistantText.value = null
clearLiveRunUi()
clearPlanStepsFor(runId)
updateLocalizedErrorText(
if (state == "error") {
@@ -5188,9 +5174,7 @@ class ChatController internal constructor(
} else {
clearPendingRuns(clearOptimisticMessages = false)
}
pendingToolCallsById.clear()
publishPendingToolCalls()
_streamingAssistantText.value = null
clearLiveRunUi()
clearPlanStepsFor(runId)
val terminalRunIds = runId?.let(::setOf) ?: unresolvedRepliesByRunId.keys.toSet()
refreshCurrentHistoryBestEffort(
@@ -5413,9 +5397,7 @@ class ChatController internal constructor(
"error" -> {
updateLocalizedErrorText(nativeText("Event stream interrupted; try refreshing."))
clearPendingRuns()
pendingToolCallsById.clear()
publishPendingToolCalls()
_streamingAssistantText.value = null
clearLiveRunUi()
clearPlanSteps()
}
}
@@ -5441,6 +5423,12 @@ class ChatController internal constructor(
pendingToolCallsById.values.sortedBy { it.startedAtMs }
}
private fun clearLiveRunUi() {
pendingToolCallsById.clear()
publishPendingToolCalls()
_streamingAssistantText.value = null
}
private fun clearPlanSteps() {
planRunId = null
_planSteps.value = emptyList()
@@ -5586,9 +5574,7 @@ class ChatController internal constructor(
private fun clearTransientRunUiIfIdle(preservePlan: Boolean = false) {
if (synchronized(pendingRuns) { pendingRuns.isNotEmpty() }) return
pendingToolCallsById.clear()
publishPendingToolCalls()
_streamingAssistantText.value = null
clearLiveRunUi()
if (!preservePlan) clearPlanSteps()
}

View File

@@ -3191,33 +3191,12 @@ class TalkModeManager internal constructor(
if (stopRequested) return
_isListening.value = false
_inputLevel.value = 0f
if (activePttCaptureId != null) {
val pushToTalkActive = activePttCaptureId != null
if (pushToTalkActive) {
pttReleaseCompletion?.let {
it.complete(Unit)
return
}
if (error == SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS) {
setStatus(nativeText("Microphone permission required"))
return
}
setStatus(
when (error) {
SpeechRecognizer.ERROR_AUDIO -> nativeText("Audio error")
SpeechRecognizer.ERROR_CLIENT -> nativeText("Client error")
SpeechRecognizer.ERROR_NETWORK -> nativeText("Network error")
SpeechRecognizer.ERROR_NETWORK_TIMEOUT -> nativeText("Network timeout")
SpeechRecognizer.ERROR_NO_MATCH -> nativeText("Listening (PTT)")
SpeechRecognizer.ERROR_RECOGNIZER_BUSY -> nativeText("Recognizer busy")
SpeechRecognizer.ERROR_SERVER -> nativeText("Server error")
SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> nativeText("Listening (PTT)")
else -> nativeText("Speech error (\$error)", error)
},
)
schedulePushToTalkRestart(
delayMs = 600L,
advanceRung = pttRecognitionRung !is PushToTalkRecognitionRung.RestartingSingleSession,
)
return
}
if (error == SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS) {
setStatus(nativeText("Microphone permission required"))
@@ -3230,13 +3209,21 @@ class TalkModeManager internal constructor(
SpeechRecognizer.ERROR_CLIENT -> nativeText("Client error")
SpeechRecognizer.ERROR_NETWORK -> nativeText("Network error")
SpeechRecognizer.ERROR_NETWORK_TIMEOUT -> nativeText("Network timeout")
SpeechRecognizer.ERROR_NO_MATCH -> nativeText("Listening")
SpeechRecognizer.ERROR_NO_MATCH,
SpeechRecognizer.ERROR_SPEECH_TIMEOUT,
-> if (pushToTalkActive) nativeText("Listening (PTT)") else nativeText("Listening")
SpeechRecognizer.ERROR_RECOGNIZER_BUSY -> nativeText("Recognizer busy")
SpeechRecognizer.ERROR_SERVER -> nativeText("Server error")
SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> nativeText("Listening")
else -> nativeText("Speech error (\$error)", error)
},
)
if (pushToTalkActive) {
schedulePushToTalkRestart(
delayMs = 600L,
advanceRung = pttRecognitionRung !is PushToTalkRecognitionRung.RestartingSingleSession,
)
return
}
scheduleRestart(delayMs = 600)
}

View File

@@ -2301,6 +2301,23 @@ final class NodeAppModel {
}
}
private static func successfulInvokeResponse(
_ request: BridgeInvokeRequest,
payload: some Encodable) throws -> BridgeInvokeResponse
{
try BridgeInvokeResponse(
id: request.id,
ok: true,
payloadJSON: self.encodePayload(payload))
}
private static func unknownInvokeResponse(_ request: BridgeInvokeRequest) -> BridgeInvokeResponse {
BridgeInvokeResponse(
id: request.id,
ok: false,
error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
}
private static func scopedWatchNotificationRequest(
_ req: BridgeInvokeRequest,
gatewayStableID: String?) -> BridgeInvokeRequest
@@ -2379,8 +2396,7 @@ final class NodeAppModel {
timestamp: ISO8601DateFormatter().string(from: location.timestamp),
isPrecise: isPrecise,
source: nil)
let json = try Self.encodePayload(payload)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
return try Self.successfulInvokeResponse(req, payload: payload)
}
private func handleCanvasInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
@@ -2431,10 +2447,7 @@ final class NodeAppModel {
])
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
default:
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
return Self.unknownInvokeResponse(req)
}
}
@@ -2507,10 +2520,7 @@ final class NodeAppModel {
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: resultJSON)
default:
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
return Self.unknownInvokeResponse(req)
}
}
@@ -2581,10 +2591,7 @@ final class NodeAppModel {
updateCameraHUD(ownerID: req.id, text: "Clip captured", kind: .success, autoHideSeconds: 1.8)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
default:
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
return Self.unknownInvokeResponse(req)
}
}
@@ -2739,8 +2746,7 @@ final class NodeAppModel {
}
let payload = OpenClawChatPushPayload(messageId: messageId)
let json = try Self.encodePayload(payload)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
return try Self.successfulInvokeResponse(req, payload: payload)
}
private func notificationAuthorizationStatus() async -> NotificationAuthorizationStatus {
@@ -2802,17 +2808,12 @@ final class NodeAppModel {
switch req.command {
case OpenClawDeviceCommand.status.rawValue:
let payload = try await deviceStatusService.status()
let json = try Self.encodePayload(payload)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
return try Self.successfulInvokeResponse(req, payload: payload)
case OpenClawDeviceCommand.info.rawValue:
let payload = self.deviceStatusService.info()
let json = try Self.encodePayload(payload)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
return try Self.successfulInvokeResponse(req, payload: payload)
default:
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
return Self.unknownInvokeResponse(req)
}
}
@@ -2820,8 +2821,7 @@ final class NodeAppModel {
let params = (try? Self.decodeParams(OpenClawPhotosLatestParams.self, from: req.paramsJSON)) ??
OpenClawPhotosLatestParams()
let payload = try await photosService.latest(params: params)
let json = try Self.encodePayload(payload)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
return try Self.successfulInvokeResponse(req, payload: payload)
}
private func handleContactsInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
@@ -2830,18 +2830,13 @@ final class NodeAppModel {
let params = (try? Self.decodeParams(OpenClawContactsSearchParams.self, from: req.paramsJSON)) ??
OpenClawContactsSearchParams()
let payload = try await contactsService.search(params: params)
let json = try Self.encodePayload(payload)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
return try Self.successfulInvokeResponse(req, payload: payload)
case OpenClawContactsCommand.add.rawValue:
let params = try Self.decodeParams(OpenClawContactsAddParams.self, from: req.paramsJSON)
let payload = try await contactsService.add(params: params)
let json = try Self.encodePayload(payload)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
return try Self.successfulInvokeResponse(req, payload: payload)
default:
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
return Self.unknownInvokeResponse(req)
}
}
@@ -2851,18 +2846,13 @@ final class NodeAppModel {
let params = (try? Self.decodeParams(OpenClawCalendarEventsParams.self, from: req.paramsJSON)) ??
OpenClawCalendarEventsParams()
let payload = try await calendarService.events(params: params)
let json = try Self.encodePayload(payload)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
return try Self.successfulInvokeResponse(req, payload: payload)
case OpenClawCalendarCommand.add.rawValue:
let params = try Self.decodeParams(OpenClawCalendarAddParams.self, from: req.paramsJSON)
let payload = try await calendarService.add(params: params)
let json = try Self.encodePayload(payload)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
return try Self.successfulInvokeResponse(req, payload: payload)
default:
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
return Self.unknownInvokeResponse(req)
}
}
@@ -2872,18 +2862,13 @@ final class NodeAppModel {
let params = (try? Self.decodeParams(OpenClawRemindersListParams.self, from: req.paramsJSON)) ??
OpenClawRemindersListParams()
let payload = try await remindersService.list(params: params)
let json = try Self.encodePayload(payload)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
return try Self.successfulInvokeResponse(req, payload: payload)
case OpenClawRemindersCommand.add.rawValue:
let params = try Self.decodeParams(OpenClawRemindersAddParams.self, from: req.paramsJSON)
let payload = try await remindersService.add(params: params)
let json = try Self.encodePayload(payload)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
return try Self.successfulInvokeResponse(req, payload: payload)
default:
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
return Self.unknownInvokeResponse(req)
}
}
@@ -2893,19 +2878,14 @@ final class NodeAppModel {
let params = (try? Self.decodeParams(OpenClawMotionActivityParams.self, from: req.paramsJSON)) ??
OpenClawMotionActivityParams()
let payload = try await motionService.activities(params: params)
let json = try Self.encodePayload(payload)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
return try Self.successfulInvokeResponse(req, payload: payload)
case OpenClawMotionCommand.pedometer.rawValue:
let params = (try? Self.decodeParams(OpenClawPedometerParams.self, from: req.paramsJSON)) ??
OpenClawPedometerParams()
let payload = try await motionService.pedometer(params: params)
let json = try Self.encodePayload(payload)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
return try Self.successfulInvokeResponse(req, payload: payload)
default:
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
return Self.unknownInvokeResponse(req)
}
}
@@ -2919,8 +2899,7 @@ final class NodeAppModel {
message: "INVALID_REQUEST: period must be today"))
}
let payload = try await self.healthSummaryService.summary(params: params)
let json = try Self.encodePayload(payload)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
return try Self.successfulInvokeResponse(req, payload: payload)
}
private func handleTalkInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
@@ -2988,25 +2967,19 @@ final class NodeAppModel {
case .started:
await self.talkMode.awaitPushToTalkOnce(start)
}
let json = try Self.encodePayload(payload)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
return try Self.successfulInvokeResponse(req, payload: payload)
case OpenClawTalkCommand.pttStop.rawValue:
// Interrupt commands invalidate suspended preparation before touching
// capture state, then bypass the preparation queue entirely.
self.talkPttCommandEpoch &+= 1
let payload = self.talkMode.endPushToTalk(expectedTranscriptionOnly: false)
let json = try Self.encodePayload(payload)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
return try Self.successfulInvokeResponse(req, payload: payload)
case OpenClawTalkCommand.pttCancel.rawValue:
self.talkPttCommandEpoch &+= 1
let payload = self.talkMode.cancelPushToTalk(expectedTranscriptionOnly: false)
let json = try Self.encodePayload(payload)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
return try Self.successfulInvokeResponse(req, payload: payload)
default:
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
return Self.unknownInvokeResponse(req)
}
}
@@ -3283,16 +3256,20 @@ extension NodeAppModel {
private func buildCapabilityRouter() -> NodeCapabilityRouter {
var handlers: [String: NodeCapabilityRouter.Handler] = [:]
func register(_ commands: [String], handler: @escaping NodeCapabilityRouter.Handler) {
func register(
_ commands: [String],
handler: @escaping (NodeAppModel, BridgeInvokeRequest) async throws -> BridgeInvokeResponse)
{
let invoke: NodeCapabilityRouter.Handler = { [weak self] request in
guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
return try await handler(self, request)
}
for command in commands {
handlers[command] = handler
handlers[command] = invoke
}
}
register([OpenClawLocationCommand.get.rawValue]) { [weak self] req in
guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
return try await self.handleLocationInvoke(req)
}
register([OpenClawLocationCommand.get.rawValue]) { try await $0.handleLocationInvoke($1) }
register([
OpenClawCanvasCommand.present.rawValue,
@@ -3300,111 +3277,66 @@ extension NodeAppModel {
OpenClawCanvasCommand.navigate.rawValue,
OpenClawCanvasCommand.evalJS.rawValue,
OpenClawCanvasCommand.snapshot.rawValue,
]) { [weak self] req in
guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
return try await self.handleCanvasInvoke(req)
}
]) { try await $0.handleCanvasInvoke($1) }
register([
OpenClawCanvasA2UICommand.reset.rawValue,
OpenClawCanvasA2UICommand.push.rawValue,
OpenClawCanvasA2UICommand.pushJSONL.rawValue,
]) { [weak self] req in
guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
return try await self.handleCanvasA2UIInvoke(req)
}
]) { try await $0.handleCanvasA2UIInvoke($1) }
register([
OpenClawCameraCommand.list.rawValue,
OpenClawCameraCommand.snap.rawValue,
OpenClawCameraCommand.clip.rawValue,
]) { [weak self] req in
guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
return try await self.handleCameraInvoke(req)
}
]) { try await $0.handleCameraInvoke($1) }
register([OpenClawScreenCommand.record.rawValue]) { [weak self] req in
guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
return try await self.handleScreenRecordInvoke(req)
}
register([OpenClawScreenCommand.record.rawValue]) { try await $0.handleScreenRecordInvoke($1) }
register([OpenClawSystemCommand.notify.rawValue]) { [weak self] req in
guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
return try await self.handleSystemNotify(req)
}
register([OpenClawSystemCommand.notify.rawValue]) { try await $0.handleSystemNotify($1) }
register([OpenClawChatCommand.push.rawValue]) { [weak self] req in
guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
return try await self.handleChatPushInvoke(req)
}
register([OpenClawChatCommand.push.rawValue]) { try await $0.handleChatPushInvoke($1) }
register([
OpenClawDeviceCommand.status.rawValue,
OpenClawDeviceCommand.info.rawValue,
]) { [weak self] req in
guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
return try await self.handleDeviceInvoke(req)
}
]) { try await $0.handleDeviceInvoke($1) }
register([
OpenClawWatchCommand.status.rawValue,
OpenClawWatchCommand.notify.rawValue,
]) { [weak self] req in
guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
return try await self.handleWatchInvoke(req)
}
]) { try await $0.handleWatchInvoke($1) }
register([OpenClawPhotosCommand.latest.rawValue]) { [weak self] req in
guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
return try await self.handlePhotosInvoke(req)
}
register([OpenClawPhotosCommand.latest.rawValue]) { try await $0.handlePhotosInvoke($1) }
register([
OpenClawContactsCommand.search.rawValue,
OpenClawContactsCommand.add.rawValue,
]) { [weak self] req in
guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
return try await self.handleContactsInvoke(req)
}
]) { try await $0.handleContactsInvoke($1) }
register([
OpenClawCalendarCommand.events.rawValue,
OpenClawCalendarCommand.add.rawValue,
]) { [weak self] req in
guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
return try await self.handleCalendarInvoke(req)
}
]) { try await $0.handleCalendarInvoke($1) }
register([
OpenClawRemindersCommand.list.rawValue,
OpenClawRemindersCommand.add.rawValue,
]) { [weak self] req in
guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
return try await self.handleRemindersInvoke(req)
}
]) { try await $0.handleRemindersInvoke($1) }
register([
OpenClawMotionCommand.activity.rawValue,
OpenClawMotionCommand.pedometer.rawValue,
]) { [weak self] req in
guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
return try await self.handleMotionInvoke(req)
}
]) { try await $0.handleMotionInvoke($1) }
register([OpenClawHealthCommand.summary.rawValue]) { [weak self] req in
guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
return try await self.handleHealthInvoke(req)
}
register([OpenClawHealthCommand.summary.rawValue]) { try await $0.handleHealthInvoke($1) }
register([
OpenClawTalkCommand.pttStart.rawValue,
OpenClawTalkCommand.pttStop.rawValue,
OpenClawTalkCommand.pttCancel.rawValue,
OpenClawTalkCommand.pttOnce.rawValue,
]) { [weak self] req in
guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
return try await self.handleTalkInvoke(req)
}
]) { try await $0.handleTalkInvoke($1) }
return NodeCapabilityRouter(handlers: handlers)
}
@@ -3419,8 +3351,7 @@ extension NodeAppModel {
appInstalled: status.appInstalled,
reachable: status.reachable,
activationState: status.activationState)
let json = try Self.encodePayload(payload)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
return try Self.successfulInvokeResponse(req, payload: payload)
case OpenClawWatchCommand.notify.rawValue:
let params = try Self.decodeParams(OpenClawWatchNotifyParams.self, from: req.paramsJSON)
let normalizedParams = Self.normalizeWatchNotifyParams(params)
@@ -3457,8 +3388,7 @@ extension NodeAppModel {
deliveredImmediately: result.deliveredImmediately,
queuedForDelivery: result.queuedForDelivery,
transport: result.transport)
let json = try Self.encodePayload(payload)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
return try Self.successfulInvokeResponse(req, payload: payload)
} catch {
return BridgeInvokeResponse(
id: req.id,
@@ -3468,10 +3398,7 @@ extension NodeAppModel {
message: error.localizedDescription))
}
default:
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
return Self.unknownInvokeResponse(req)
}
}
@@ -6047,14 +5974,38 @@ extension NodeAppModel {
await self.syncWatchExecApprovalSnapshot(reason: reason)
}
private static func appendPendingApproval<Item>(
_ item: Item,
to items: inout [Item],
maximumCount: Int? = nil,
key: (Item) -> (some Equatable)?,
sortedBy sortsBefore: (Item, Item) -> Bool) -> Bool
{
guard let itemKey = key(item), !items.contains(where: { key($0) == itemKey }) else { return false }
items.append(item)
if let maximumCount, items.count > maximumCount { items.removeFirst() }
items.sort(by: sortsBefore)
return true
}
private static func removePendingApproval<Item>(
_ item: Item,
from items: inout [Item],
key: (Item) -> (some Equatable)?) -> Bool
{
guard let itemKey = key(item) else { return false }
let originalCount = items.count
items.removeAll { key($0) == itemKey }
return items.count != originalCount
}
private func appendPendingWatchExecApprovalRecoveryPush(_ push: ExecApprovalNotificationPrompt) {
guard let pushKey = Self.execApprovalPushKey(push),
!self.pendingWatchExecApprovalRecoveryPushes.contains(where: {
Self.execApprovalPushKey($0) == pushKey
})
guard Self.appendPendingApproval(
push,
to: &self.pendingWatchExecApprovalRecoveryPushes,
key: Self.execApprovalPushKey,
sortedBy: Self.execApprovalPushSortsBefore)
else { return }
self.pendingWatchExecApprovalRecoveryPushes.append(push)
self.pendingWatchExecApprovalRecoveryPushes.sort(by: Self.execApprovalPushSortsBefore)
GatewayDiagnostics.log(
"watch exec approval: queued recovery "
+ "id=\(push.approvalId) pendingCount=\(self.pendingWatchExecApprovalRecoveryPushes.count)")
@@ -6062,12 +6013,11 @@ extension NodeAppModel {
}
private func removePendingWatchExecApprovalRecoveryPush(_ push: ExecApprovalNotificationPrompt) {
guard let pushKey = Self.execApprovalPushKey(push) else { return }
let originalCount = self.pendingWatchExecApprovalRecoveryPushes.count
self.pendingWatchExecApprovalRecoveryPushes.removeAll {
Self.execApprovalPushKey($0) == pushKey
}
guard self.pendingWatchExecApprovalRecoveryPushes.count != originalCount else { return }
guard Self.removePendingApproval(
push,
from: &self.pendingWatchExecApprovalRecoveryPushes,
key: Self.execApprovalPushKey)
else { return }
GatewayDiagnostics.log(
"watch exec approval: cleared recovery "
+ "id=\(push.approvalId) pendingCount=\(self.pendingWatchExecApprovalRecoveryPushes.count)")
@@ -6075,40 +6025,35 @@ extension NodeAppModel {
}
private func appendPendingExecApprovalResolvedPush(_ push: ExecApprovalNotificationPrompt) {
guard let pushKey = Self.execApprovalPushKey(push),
!self.pendingExecApprovalResolvedPushes.contains(where: {
Self.execApprovalPushKey($0) == pushKey
})
else { return }
// A silent resolution push is not replayed by the gateway. Keep it until the
// authenticated owner route returns so its matching notification cannot linger.
self.pendingExecApprovalResolvedPushes.append(push)
if self.pendingExecApprovalResolvedPushes.count > 32 {
self.pendingExecApprovalResolvedPushes.removeFirst()
}
self.pendingExecApprovalResolvedPushes.sort(by: Self.execApprovalPushSortsBefore)
guard Self.appendPendingApproval(
push,
to: &self.pendingExecApprovalResolvedPushes,
maximumCount: 32,
key: Self.execApprovalPushKey,
sortedBy: Self.execApprovalPushSortsBefore)
else { return }
self.persistWatchExecApprovalBridgeState()
}
private func removePendingExecApprovalResolvedPush(_ push: ExecApprovalNotificationPrompt) {
guard let pushKey = Self.execApprovalPushKey(push) else { return }
let originalCount = self.pendingExecApprovalResolvedPushes.count
self.pendingExecApprovalResolvedPushes.removeAll {
Self.execApprovalPushKey($0) == pushKey
}
guard self.pendingExecApprovalResolvedPushes.count != originalCount else { return }
guard Self.removePendingApproval(
push,
from: &self.pendingExecApprovalResolvedPushes,
key: Self.execApprovalPushKey)
else { return }
self.persistWatchExecApprovalBridgeState()
}
private func removePendingPersistedExecApprovalReadback(
_ readback: PersistedExecApprovalReadback)
{
guard let readbackKey = Self.persistedExecApprovalReadbackKey(readback) else { return }
let originalCount = self.pendingPersistedExecApprovalReadbacks.count
self.pendingPersistedExecApprovalReadbacks.removeAll {
Self.persistedExecApprovalReadbackKey($0) == readbackKey
}
guard self.pendingPersistedExecApprovalReadbacks.count != originalCount else { return }
guard Self.removePendingApproval(
readback,
from: &self.pendingPersistedExecApprovalReadbacks,
key: Self.persistedExecApprovalReadbackKey)
else { return }
self.persistWatchExecApprovalBridgeState()
}
@@ -6119,19 +6064,15 @@ extension NodeAppModel {
let readback = PersistedExecApprovalReadback(
approvalId: approvalId,
gatewayStableID: gatewayStableID)
guard let readbackKey = Self.persistedExecApprovalReadbackKey(readback),
!self.pendingPersistedExecApprovalReadbacks.contains(where: {
Self.persistedExecApprovalReadbackKey($0) == readbackKey
})
else { return }
// A requested event is an edge trigger, not replayed state. Retain its exact owner
// until approval.get classifies it so a reconnect cannot lose a parked approval.
self.pendingPersistedExecApprovalReadbacks.append(readback)
if self.pendingPersistedExecApprovalReadbacks.count > 64 {
self.pendingPersistedExecApprovalReadbacks.removeFirst()
}
self.pendingPersistedExecApprovalReadbacks.sort(
by: Self.persistedExecApprovalReadbackSortsBefore)
guard Self.appendPendingApproval(
readback,
to: &self.pendingPersistedExecApprovalReadbacks,
maximumCount: 64,
key: Self.persistedExecApprovalReadbackKey,
sortedBy: Self.persistedExecApprovalReadbackSortsBefore)
else { return }
self.persistWatchExecApprovalBridgeState()
}

View File

@@ -4577,34 +4577,31 @@ extension TalkModeManager {
}
static func configureAudioSession(preferredInputDeviceID: String? = nil) throws {
let session = AVAudioSession.sharedInstance()
let forceSpeaker = TalkDefaults.speakerphoneEnabled()
let options = TalkAudioRoute.categoryOptions(speakerphoneEnabled: forceSpeaker)
// Prefer `.spokenAudio` for STT; it tends to preserve speech energy better than `.voiceChat`.
try session.setCategory(.playAndRecord, mode: .spokenAudio, options: options)
try? session.setPreferredSampleRate(48000)
try? session.setPreferredIOBufferDuration(0.02)
try session.setActive(true, options: [])
if TalkAudioRoute.shouldForceSpeaker(
preferenceEnabled: forceSpeaker,
outputPortTypes: session.currentRoute.outputs.map(\.portType))
{
try? session.overrideOutputAudioPort(.speaker)
} else {
try? session.overrideOutputAudioPort(.none)
}
// A speaker override also forces the built-in microphone; apply an explicit input last.
self.applyPreferredInput(preferredInputDeviceID, to: session)
GatewayDiagnostics.log("talk audio: session speakerphone=\(forceSpeaker) \(Self.describeAudioSession())")
try self.configureAudioSession(
mode: .spokenAudio,
preferredInputDeviceID: preferredInputDeviceID,
diagnosticsPrefix: "talk audio")
}
static func configureRealtimeAudioSession(preferredInputDeviceID: String? = nil) throws {
// Realtime Talk is full duplex. `.voiceChat` enables iOS voice processing so speaker
// output is less likely to be captured as fresh microphone input.
try self.configureAudioSession(
mode: .voiceChat,
preferredInputDeviceID: preferredInputDeviceID,
diagnosticsPrefix: "talk realtime audio")
}
private static func configureAudioSession(
mode: AVAudioSession.Mode,
preferredInputDeviceID: String?,
diagnosticsPrefix: String) throws
{
let session = AVAudioSession.sharedInstance()
let forceSpeaker = TalkDefaults.speakerphoneEnabled()
let options = TalkAudioRoute.categoryOptions(speakerphoneEnabled: forceSpeaker)
// Realtime Talk is full duplex. `.voiceChat` enables iOS voice processing so speaker
// output is less likely to be captured as fresh microphone input.
try session.setCategory(.playAndRecord, mode: .voiceChat, options: options)
try session.setCategory(.playAndRecord, mode: mode, options: options)
try? session.setPreferredSampleRate(48000)
try? session.setPreferredIOBufferDuration(0.02)
try session.setActive(true, options: [])
@@ -4619,7 +4616,7 @@ extension TalkModeManager {
// A speaker override also forces the built-in microphone; apply an explicit input last.
self.applyPreferredInput(preferredInputDeviceID, to: session)
GatewayDiagnostics.log(
"talk realtime audio: session speakerphone=\(forceSpeaker) \(Self.describeAudioSession())")
"\(diagnosticsPrefix): session speakerphone=\(forceSpeaker) \(Self.describeAudioSession())")
}
private static func applyPreferredInput(_ deviceID: String?, to session: AVAudioSession) {

View File

@@ -1943,7 +1943,9 @@ struct ChatViewModelTests {
let discardedViewModel = try weakReference(to: viewModel)
viewModel = nil
await Task.yield()
for _ in 0..<100 where discardedViewModel.value != nil {
await Task.yield()
}
#expect(discardedViewModel.value == nil)
}

View File

@@ -6,6 +6,7 @@ function createExtensionCodexAppServerAttemptExtraVitestConfig(
) {
return createScopedVitestConfig(
[
"extensions/codex/src/app-server/run-attempt-client-prewarm.test.ts",
"extensions/codex/src/app-server/run-attempt-lifecycle-controller.test.ts",
"extensions/codex/src/app-server/run-attempt-thread-cleanup.test.ts",
"extensions/codex/src/app-server/run-attempt.context-engine.test.ts",