fix(talk): type output cancellation results

This commit is contained in:
Vincent Koc
2026-08-21 00:14:40 -07:00
parent 4f676c0ef8
commit 350d258ce6
14 changed files with 118 additions and 13 deletions
@@ -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,
@@ -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<TalkSessionCancelOutputResult>(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? {
@@ -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 {
@@ -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 }
@@ -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?
@@ -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)
}),
@@ -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);
@@ -413,6 +413,7 @@ export {
TalkSessionAppendAudioParamsSchema,
TalkSessionAcknowledgeMarkParamsSchema,
TalkSessionCancelOutputParamsSchema,
TalkSessionCancelOutputResultSchema,
TalkSessionCreateParamsSchema,
TalkSessionCreateResultSchema,
TalkSessionSteerParamsSchema,
@@ -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<typeof TalkSessionCreateParamsSchem
export type TalkSessionCreateResult = Static<typeof TalkSessionCreateResultSchema>;
export type TalkSessionAppendAudioParams = Static<typeof TalkSessionAppendAudioParamsSchema>;
export type TalkSessionCancelOutputParams = Static<typeof TalkSessionCancelOutputParamsSchema>;
export type TalkSessionCancelOutputResult = Static<typeof TalkSessionCancelOutputResultSchema>;
export type TalkSessionSteerParams = Static<typeof TalkSessionSteerParamsSchema>;
export type TalkSessionSubmitToolResultParams = Static<
typeof TalkSessionSubmitToolResultParamsSchema
@@ -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,
@@ -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,
+1
View File
@@ -87,6 +87,7 @@ const schemaNames = new Map<string, string>([
["SessionGitHubPublicationPublished", "SessionGitHubPublicationPublished"],
["SessionGitHubPublicationFailed", "SessionGitHubPublicationFailed"],
["SessionGitHubPublicationResult", "SessionGitHubPublicationResult"],
["TalkSessionCancelOutputResult", "TalkSessionCancelOutputResult"],
]);
const androidEnums: EnumSpec[] = [
+1 -1
View File
@@ -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);
}
+2 -2
View File
@@ -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",