fix(talk): require accepted cancellation results

This commit is contained in:
Vincent Koc
2026-08-21 00:36:21 -07:00
parent 350d258ce6
commit 115b10261d
5 changed files with 74 additions and 13 deletions
@@ -860,7 +860,7 @@ class TalkModeManagerTest {
@Test
fun malformedOutputCancellationResultFailsClosed() {
for (response in listOf("""{"status":"stale"}""", """{"ok":true,"status":"unknown"}""", """{"ok":true,"extra":1}""")) {
for (response in listOf("""{"status":"stale"}""", """{"ok":false}""", """{"ok":true,"status":"unknown"}""", """{"ok":true,"extra":1}""")) {
assertTrue(runCatching { shouldRetireRealtimeOutputCancellation(response) }.isFailure)
}
}
@@ -549,6 +549,19 @@ private func outputAudioEvent(
stateversion: nil)
}
private func outputClearEvent(turnId: String) -> EventFrame {
EventFrame(
type: "event",
event: "talk.event",
payload: AnyCodable([
"relaySessionId": "relay-1",
"type": "clear",
"talkEvent": ["turnId": turnId],
]),
seq: nil,
stateversion: nil)
}
@MainActor
struct RealtimeTalkRelaySessionTests {
private func makeIdleCancellationSession(
@@ -1257,16 +1270,7 @@ extension RealtimeTalkRelaySessionTests {
#expect(session.cancelOutput())
try await barrier.waitUntilEntered()
await session._test_handleGatewayEvent(EventFrame(
type: "event",
event: "talk.event",
payload: AnyCodable([
"relaySessionId": "relay-1",
"type": "clear",
"talkEvent": ["turnId": "turn-1"],
]),
seq: nil,
stateversion: nil))
await session._test_handleGatewayEvent(outputClearEvent(turnId: "turn-1"))
await session._test_handleGatewayEvent(outputAudioEvent(turnId: "turn-2"))
#expect(session.cancelOutput())
await barrier.release()
@@ -1324,6 +1328,60 @@ extension RealtimeTalkRelaySessionTests {
])
}
@Test(arguments: [
#"{"ok":true}"#,
#"{"ok":true,"status":"applied","turnId":"turn-1"}"#,
])
func `accepted cancellation response keeps fence until matching clear`(
response: String) async throws
{
let barrier = RealtimeRelayStartupBarrier()
var speakingStates: [Bool] = []
let session = RealtimeTalkRelaySession(
transport: RealtimeTalkRelayTransport(
subscribeServerEvents: { _ in AsyncStream { $0.finish() } },
request: { method, _, _ in
if method == "talk.session.cancelOutput" {
await barrier.suspend()
return Data(response.utf8)
}
return Data(#"{"ok":true}"#.utf8)
}),
options: .init(sessionKey: "main", provider: "openai", model: nil, voice: nil),
audioCapture: TestRealtimeTalkAudioCapture(),
pcmPlayer: DrainingPCMStreamingAudioPlayer(),
onStatus: { _ in },
onSpeakingChanged: { speakingStates.append($0) })
defer { session.stop() }
session._test_setRelaySessionId("relay-1")
session._test_prepareAudioSender(relaySessionId: "relay-1")
await session._test_handleGatewayEvent(outputAudioEvent(turnId: "turn-1"))
#expect(session.cancelOutput())
var cancellationTask: Task<Void, Never>?
do {
try await barrier.waitUntilEntered()
guard let exactCancellationTask = session._test_outputCancellationTask() else {
throw RealtimeRelayTestTimeout(operation: "cancellation task registration")
}
cancellationTask = exactCancellationTask
await barrier.release()
await exactCancellationTask.value
#expect(session._test_enqueueMicrophoneFrame(Data([0x01])) == nil)
await session._test_handleGatewayEvent(outputAudioEvent(turnId: "turn-2"))
#expect(speakingStates == [true, false])
await session._test_handleGatewayEvent(outputClearEvent(turnId: "turn-1"))
let admittedTask = try #require(session._test_enqueueMicrophoneFrame(Data([0x02])))
await admittedTask.value
await session._test_handleGatewayEvent(outputAudioEvent(turnId: "turn-2"))
} catch {
await barrier.release()
await cancellationTask?.value
throw error
}
#expect(speakingStates == [true, false, true])
}
@Test func `close retires in flight cancellation failure`() async throws {
let barrier = RealtimeRelayStartupBarrier()
var issues: [RealtimeTalkRelayIssue] = []
@@ -52,6 +52,7 @@ describe("TalkSessionCancelOutputResultSchema", () => {
for (const value of [
{},
{ status: "applied" },
{ ok: false },
{ ok: true, status: "unknown" },
{ ok: true, turnId: "" },
{ ok: true, extra: true },
@@ -319,7 +319,7 @@ export const TalkSessionCancelOutputParamsSchema = closedObject({
/** Reports whether a Talk output cancellation applied to the requested turn. */
export const TalkSessionCancelOutputResultSchema = closedObject({
ok: Type.Boolean(),
ok: Type.Literal(true),
status: Type.Optional(
Type.Union([Type.Literal("applied"), Type.Literal("stale"), Type.Literal("idle")]),
),
+3 -1
View File
@@ -336,11 +336,13 @@ function emitWireModels(): string[] {
const type = kotlinType(propertySchema, `${name}${upperCamel(wireName)}`);
const literal = literalValue(propertySchema);
const optional = !required.has(wireName);
const useLiteralDefault =
literal !== undefined && (optional || typeof literal !== "boolean");
return {
annotation:
propertyName === wireName ? [] : [` @SerialName(${JSON.stringify(wireName)})`],
declaration: ` val ${propertyName}: ${type}${optional ? "?" : ""}${
literal !== undefined ? ` = ${kotlinLiteral(literal)}` : optional ? " = null" : ""
useLiteralDefault ? ` = ${kotlinLiteral(literal)}` : optional ? " = null" : ""
},`,
};
});