diff --git a/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt index fe428740b39f..d9b66e1a9036 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt @@ -2927,7 +2927,7 @@ class TalkModeManager internal constructor( private suspend fun cancelRealtimeOutput(reason: String): Boolean = realtimeOutputCancellationMutex.withLock { val sessionId = realtimeSessionId ?: return@withLock true - val turnId = realtimeOutputTurnId + val turnId = realtimeOutputTurnId?.trim()?.takeIf(String::isNotEmpty) ?: return@withLock false val clear = CompletableDeferred() pendingRealtimeOutputClear = clear try { @@ -2935,7 +2935,7 @@ class TalkModeManager internal constructor( buildJsonObject { put("sessionId", JsonPrimitive(sessionId)) put("reason", JsonPrimitive(reason)) - if (turnId != null) put("turnId", JsonPrimitive(turnId)) + put("turnId", JsonPrimitive(turnId)) } val response = requestGateway("talk.session.cancelOutput", params.toString(), timeoutMs = 5_000) val result = requireAcceptedRealtimeOutputCancellation(response, turnId) @@ -2943,8 +2943,7 @@ class TalkModeManager internal constructor( // The response confirms provider cancellation; clear confirms that the // old playback boundary reached Android before capture can resume. val clearedTurnId = withTimeout(2_000) { clear.await() } - val expectedTurnId = turnId ?: result.turnId - check(expectedTurnId == null || clearedTurnId == expectedTurnId) { + check(clearedTurnId == turnId) { "talk.session.cancelOutput clear turnId did not match" } true diff --git a/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeManagerTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeManagerTest.kt index 6492afeb14e7..f17487700719 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeManagerTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeManagerTest.kt @@ -100,18 +100,25 @@ class TalkModeManagerTest { } @Test - fun stopTtsCancelsTrackedPlaybackJob() { - val manager = createManager() - val playbackJob = Job() + fun stopTtsWithoutOutputIdentityCancelsPlaybackWithoutReplacingCancellationWaiter() = + runTest { + val manager = createManager(scope = this) + val playbackJob = Job() + val pendingClear = CompletableDeferred() - setPrivateField(manager, "ttsJob", playbackJob) - playbackGeneration(manager).set(7L) + setPrivateField(manager, "ttsJob", playbackJob) + setPrivateField(manager, "realtimeSessionId", "relay-1") + setPrivateField(manager, "realtimeOutputTurnId", " ") + setPrivateField(manager, "pendingRealtimeOutputClear", pendingClear) + playbackGeneration(manager).set(7L) - manager.stopTts() + manager.stopTts() + runCurrent() - assertTrue(playbackJob.isCancelled) - assertEquals(8L, playbackGeneration(manager).get()) - } + assertTrue(playbackJob.isCancelled) + assertEquals(8L, playbackGeneration(manager).get()) + assertTrue(readPrivateField(manager, "pendingRealtimeOutputClear") === pendingClear) + } @Test fun disablingPlaybackCancelsTrackedJobOnce() { @@ -829,7 +836,7 @@ class TalkModeManagerTest { } @Test - fun unconfirmedOutputCancellationClosesRealtimeRelay() = + fun pushToTalkWithoutOutputIdentityClosesRealtimeRelayWithoutWaitingForClear() = runTest { var stoppedByRelay = false val manager = @@ -843,6 +850,7 @@ class TalkModeManagerTest { manager.pauseRealtimeCaptureForPushToTalk("capture-1") assertNull(readPrivateField(manager, "realtimeSessionId")) + assertNull(readPrivateField(manager, "pendingRealtimeOutputClear")) val pause = readPrivateField(manager, "realtimeCapturePause")!! assertEquals("capture-1", readPrivateField(pause, "pttCaptureId")) assertTrue(readPrivateField(pause, "restartRelay") as Boolean)