diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt index 83f591a87a07..3c65a5747683 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt @@ -325,6 +325,13 @@ data class SessionGitHubPublicationFailed( @JsonClassDiscriminator("status") sealed interface SessionGitHubPublicationResult +@Serializable +data class TalkSessionCancelOutputResult( + val ok: Boolean, + val status: String? = null, + val turnId: String? = null, +) + @Serializable data class GatewayEventFrameStateVersion( val presence: Long, 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 a099c4252376..0aec10dbafaa 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 @@ -3,6 +3,7 @@ package ai.openclaw.app.voice import ai.openclaw.app.gateway.ChatSendAck import ai.openclaw.app.gateway.GatewayRequestRejected import ai.openclaw.app.gateway.GatewaySession +import ai.openclaw.app.gateway.TalkSessionCancelOutputResult import ai.openclaw.app.gateway.chatSendAckHistorySinceSeconds import ai.openclaw.app.gateway.parseChatSendAck import ai.openclaw.app.i18n.NativeText @@ -60,6 +61,7 @@ import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.yield +import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement @@ -2934,9 +2936,8 @@ class TalkModeManager internal constructor( put("reason", JsonPrimitive(reason)) if (turnId != null) put("turnId", JsonPrimitive(turnId)) } - val result = - json.parseToJsonElement(requestGateway("talk.session.cancelOutput", params.toString(), timeoutMs = 5_000)).asObjectOrNull() - if (result?.get("status").asStringOrNull() != "applied") clear.complete(Unit) + val response = requestGateway("talk.session.cancelOutput", params.toString(), timeoutMs = 5_000) + if (shouldRetireRealtimeOutputCancellation(response)) clear.complete(Unit) // The response confirms provider cancellation; clear confirms that the // old playback boundary reached Android before capture can resume. withTimeout(2_000) { clear.await() } @@ -3335,6 +3336,16 @@ class TalkModeManager internal constructor( private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject +internal fun shouldRetireRealtimeOutputCancellation(response: String): Boolean { + val result = Json.decodeFromString(response) + check(result.ok) { "talk.session.cancelOutput was not accepted" } + return when (result.status) { + null, "applied" -> false + "stale", "idle" -> true + else -> error("unknown talk.session.cancelOutput status") + } +} + private fun JsonElement?.asStringOrNull(): String? = (this as? JsonPrimitive)?.takeIf { it.isString }?.content private fun JsonElement?.asDoubleOrNull(): Double? { 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 5ce7d595136a..3cf1e1ad32b5 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 @@ -850,6 +850,21 @@ class TalkModeManagerTest { assertFalse(stoppedByRelay) } + @Test + fun outputCancellationResultRetainsOnlyAcceptedClearFences() { + assertFalse(shouldRetireRealtimeOutputCancellation("""{"ok":true}""")) + assertFalse(shouldRetireRealtimeOutputCancellation("""{"ok":true,"status":"applied"}""")) + assertTrue(shouldRetireRealtimeOutputCancellation("""{"ok":true,"status":"stale"}""")) + assertTrue(shouldRetireRealtimeOutputCancellation("""{"ok":true,"status":"idle"}""")) + } + + @Test + fun malformedOutputCancellationResultFailsClosed() { + for (response in listOf("""{"status":"stale"}""", """{"ok":true,"status":"unknown"}""", """{"ok":true,"extra":1}""")) { + assertTrue(runCatching { shouldRetireRealtimeOutputCancellation(response) }.isFailure) + } + } + @Test fun stalePushToTalkCompletionCannotResumeNewerPause() = runTest { diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/RealtimeTalkRelaySession.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/RealtimeTalkRelaySession.swift index 15e04a8b24a4..6545b29fca7e 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/RealtimeTalkRelaySession.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/RealtimeTalkRelaySession.swift @@ -1195,12 +1195,20 @@ extension RealtimeTalkRelaySession { ] do { let response = try await transport.request("talk.session.cancelOutput", payload, 8000) - let status = try? JSONDecoder().decode([String: String].self, from: response)["status"] + let result = try JSONDecoder().decode(TalkSessionCancelOutputResult.self, from: response) + guard result.ok else { throw URLError(.badServerResponse) } guard let self, self.isCurrentOutputCancellation(cancellationGeneration) else { return } - if status == "stale" || status == "idle" || !self.awaitingOutputClear { + switch result.status?.stringValue { + case "stale", "idle": self.retireOutputCancellation() - } else { - self.outputCancellationTask = nil + case nil, "applied": + if self.awaitingOutputClear { + self.outputCancellationTask = nil + } else { + self.retireOutputCancellation() + } + default: + throw URLError(.badServerResponse) } } catch { guard let self, self.isCurrentOutputCancellation(cancellationGeneration) else { return } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 640f50c7fb86..d8ec69691eb1 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -13111,6 +13111,28 @@ public struct TalkSessionCancelOutputParams: Codable, Sendable { } } +public struct TalkSessionCancelOutputResult: Codable, Sendable { + public let ok: Bool + public let status: AnyCodable? + public let turnid: String? + + public init( + ok: Bool, + status: AnyCodable? = nil, + turnid: String? = nil) + { + self.ok = ok + self.status = status + self.turnid = turnid + } + + private enum CodingKeys: String, CodingKey { + case ok + case status + case turnid = "turnId" + } +} + public struct TalkSessionCreateParams: Codable, Sendable { public let sessionkey: String? public let spawnedby: String? diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/RealtimeTalkRelaySessionTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/RealtimeTalkRelaySessionTests.swift index da6ab711f49f..b43a8ff72431 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/RealtimeTalkRelaySessionTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/RealtimeTalkRelaySessionTests.swift @@ -982,7 +982,7 @@ extension RealtimeTalkRelaySessionTests { request: { method, params, _ in await requests.record(method: method, params: params) await barrier.suspend() - return Data("{\"status\":\"\(status)\"}".utf8) + return Data("{\"ok\":true,\"status\":\"\(status)\"}".utf8) }), options: .init(sessionKey: "main", provider: "openai", model: nil, voice: nil), audioCapture: TestRealtimeTalkAudioCapture(), @@ -1288,7 +1288,7 @@ extension RealtimeTalkRelaySessionTests { await requests.record(method: method, params: params) if method == "talk.session.cancelOutput" { await barrier.suspend() - return Data("{\"status\":\"applied\",\"turnId\":\"turn-1\"}".utf8) + return Data("{\"ok\":true,\"status\":\"applied\",\"turnId\":\"turn-1\"}".utf8) } return Data("{\"ok\":true}".utf8) }), diff --git a/packages/gateway-protocol/src/channels.schema.test.ts b/packages/gateway-protocol/src/channels.schema.test.ts index 4c1b4e0a5356..67aae8e1f758 100644 --- a/packages/gateway-protocol/src/channels.schema.test.ts +++ b/packages/gateway-protocol/src/channels.schema.test.ts @@ -1,7 +1,11 @@ // Gateway Protocol tests cover channels.schema behavior. import { Compile } from "typebox/compile"; import { describe, expect, it } from "vitest"; -import { ChannelsStatusResultSchema, WebLoginWaitParamsSchema } from "./schema/channels.js"; +import { + ChannelsStatusResultSchema, + TalkSessionCancelOutputResultSchema, + WebLoginWaitParamsSchema, +} from "./schema/channels.js"; /** * Channel schema regressions for browser login and status diagnostics. @@ -33,6 +37,30 @@ describe("WebLoginWaitParamsSchema", () => { }); }); +describe("TalkSessionCancelOutputResultSchema", () => { + const validate = Compile(TalkSessionCancelOutputResultSchema); + + it("accepts only closed cancellation outcomes with an explicit ok field", () => { + for (const value of [ + { ok: true }, + { ok: true, status: "applied", turnId: "turn-7" }, + { ok: true, status: "stale" }, + { ok: true, status: "idle" }, + ]) { + expect(validate.Check(value)).toBe(true); + } + for (const value of [ + {}, + { status: "applied" }, + { ok: true, status: "unknown" }, + { ok: true, turnId: "" }, + { ok: true, extra: true }, + ]) { + expect(validate.Check(value)).toBe(false); + } + }); +}); + describe("ChannelsStatusResultSchema", () => { /** Compiled status validator for channel docking diagnostics. */ const validate = Compile(ChannelsStatusResultSchema); diff --git a/packages/gateway-protocol/src/public-schema.ts b/packages/gateway-protocol/src/public-schema.ts index f4c2f8f1f487..3511f233c98b 100644 --- a/packages/gateway-protocol/src/public-schema.ts +++ b/packages/gateway-protocol/src/public-schema.ts @@ -413,6 +413,7 @@ export { TalkSessionAppendAudioParamsSchema, TalkSessionAcknowledgeMarkParamsSchema, TalkSessionCancelOutputParamsSchema, + TalkSessionCancelOutputResultSchema, TalkSessionCreateParamsSchema, TalkSessionCreateResultSchema, TalkSessionSteerParamsSchema, diff --git a/packages/gateway-protocol/src/schema/channels.ts b/packages/gateway-protocol/src/schema/channels.ts index c313c1594d61..a2dfd607c709 100644 --- a/packages/gateway-protocol/src/schema/channels.ts +++ b/packages/gateway-protocol/src/schema/channels.ts @@ -317,6 +317,15 @@ export const TalkSessionCancelOutputParamsSchema = closedObject({ reason: Type.Optional(Type.String()), }); +/** Reports whether a Talk output cancellation applied to the requested turn. */ +export const TalkSessionCancelOutputResultSchema = closedObject({ + ok: Type.Boolean(), + status: Type.Optional( + Type.Union([Type.Literal("applied"), Type.Literal("stale"), Type.Literal("idle")]), + ), + turnId: Type.Optional(NonEmptyString), +}); + /** Submits a tool result back to a Talk provider session. */ export const TalkSessionSubmitToolResultParamsSchema = closedObject({ sessionId: NonEmptyString, @@ -741,6 +750,7 @@ export type TalkSessionCreateParams = Static; export type TalkSessionAppendAudioParams = Static; export type TalkSessionCancelOutputParams = Static; +export type TalkSessionCancelOutputResult = Static; export type TalkSessionSteerParams = Static; export type TalkSessionSubmitToolResultParams = Static< typeof TalkSessionSubmitToolResultParamsSchema diff --git a/packages/gateway-protocol/src/schema/protocol-schema-fragment-channels.ts b/packages/gateway-protocol/src/schema/protocol-schema-fragment-channels.ts index 1a698c298656..0864577582da 100644 --- a/packages/gateway-protocol/src/schema/protocol-schema-fragment-channels.ts +++ b/packages/gateway-protocol/src/schema/protocol-schema-fragment-channels.ts @@ -21,6 +21,7 @@ export const ChannelProtocolSchemas = { TalkSessionAppendAudioParams: channels.TalkSessionAppendAudioParamsSchema, TalkSessionAcknowledgeMarkParams: talkMarks.TalkSessionAcknowledgeMarkParamsSchema, TalkSessionCancelOutputParams: channels.TalkSessionCancelOutputParamsSchema, + TalkSessionCancelOutputResult: channels.TalkSessionCancelOutputResultSchema, TalkSessionCreateParams: channels.TalkSessionCreateParamsSchema, TalkSessionCreateResult: channels.TalkSessionCreateResultSchema, TalkSessionSteerParams: channels.TalkSessionSteerParamsSchema, diff --git a/packages/gateway-protocol/src/validator-registry.ts b/packages/gateway-protocol/src/validator-registry.ts index 9e2dd3531fc8..db35ec32e32a 100644 --- a/packages/gateway-protocol/src/validator-registry.ts +++ b/packages/gateway-protocol/src/validator-registry.ts @@ -330,6 +330,7 @@ export const validateTalkSessionAcknowledgeMarkParams = compile( S.TalkSessionAcknowledgeMarkParamsSchema, ); export const validateTalkSessionCancelOutputParams = compile(S.TalkSessionCancelOutputParamsSchema); +export const validateTalkSessionCancelOutputResult = compile(S.TalkSessionCancelOutputResultSchema); export const validateTalkSessionSteerParams = compile(S.TalkSessionSteerParamsSchema); export const validateTalkSessionSubmitToolResultParams = compile( S.TalkSessionSubmitToolResultParamsSchema, diff --git a/scripts/protocol-gen-kotlin.ts b/scripts/protocol-gen-kotlin.ts index 53eca8ca1252..6e3e6dfc3799 100644 --- a/scripts/protocol-gen-kotlin.ts +++ b/scripts/protocol-gen-kotlin.ts @@ -87,6 +87,7 @@ const schemaNames = new Map([ ["SessionGitHubPublicationPublished", "SessionGitHubPublicationPublished"], ["SessionGitHubPublicationFailed", "SessionGitHubPublicationFailed"], ["SessionGitHubPublicationResult", "SessionGitHubPublicationResult"], + ["TalkSessionCancelOutputResult", "TalkSessionCancelOutputResult"], ]); const androidEnums: EnumSpec[] = [ diff --git a/src/gateway/server-methods/talk-session.ts b/src/gateway/server-methods/talk-session.ts index 59a76f44fc60..b679c46f262a 100644 --- a/src/gateway/server-methods/talk-session.ts +++ b/src/gateway/server-methods/talk-session.ts @@ -432,7 +432,7 @@ export const talkSessionHandlers: GatewayRequestHandlers = { reason: normalizeOptionalString(params.reason) ?? "output-cancelled", turnId: normalizeOptionalString(params.turnId), }); - respondOk(respond, result); + respondOk(respond, { ok: true, ...result }); } catch (err) { respondUnavailable(respond, err); } diff --git a/src/gateway/server-methods/talk.test.ts b/src/gateway/server-methods/talk.test.ts index be4cf5659a8e..e91d26445e24 100644 --- a/src/gateway/server-methods/talk.test.ts +++ b/src/gateway/server-methods/talk.test.ts @@ -1748,7 +1748,7 @@ describe("talk.session unified handlers", () => { reason: "barge-in", turnId: "turn-7", }); - expectRespondOk(cancelRespond, { status: "applied", turnId: "turn-7" }); + expectRespondOk(cancelRespond, { ok: true, status: "applied", turnId: "turn-7" }); for (const status of ["stale", "idle"] as const) { const nonAppliedRespond = vi.fn(); mocks.cancelTalkRealtimeRelayTurn.mockResolvedValueOnce({ status }); @@ -1758,7 +1758,7 @@ describe("talk.session unified handlers", () => { respond: nonAppliedRespond, context: {}, }); - expectRespondOk(nonAppliedRespond, { status }); + expectRespondOk(nonAppliedRespond, { ok: true, status }); } expect(mocks.cancelTalkRealtimeRelayTurn).toHaveBeenLastCalledWith({ relaySessionId: "relay-unified-1",