From 88e370dfaca9aa77847deae5c38b2a5301fee9ae Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 11 Aug 2026 15:14:45 +0800 Subject: [PATCH] fix(talk): preserve legacy output cancellation Punchcard-Session: golden-meadow-cedar-dv --- docs/gateway/protocol.md | 2 +- src/gateway/server-methods/talk-session.ts | 28 +++--- src/gateway/server-methods/talk.test.ts | 103 +++++++++++++-------- 3 files changed, 79 insertions(+), 54 deletions(-) diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index 149c0fce0f61..7d58c72b0a14 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -584,7 +584,7 @@ methods. Treat this as feature discovery, not a full enumeration of - `talk.config` returns the effective Talk config payload; `includeSecrets` requires `operator.talk.secrets` (or `operator.admin`). - `talk.session.create` (`operator.talk`) creates a gateway-owned Talk session for `realtime/gateway-relay`, `transcription/gateway-relay`, or `stt-tts/managed-room`. For `stt-tts/managed-room`, non-admin callers that pass `sessionKey` must also pass `spawnedBy` for scoped session-key visibility; unscoped `sessionKey` creation and `brain: "direct-tools"` require `operator.admin`. - `talk.session.appendAudio` appends base64 PCM input audio to gateway-owned realtime relay and transcription sessions. - - `talk.session.cancelOutput` stops assistant audio output, primarily for VAD-gated barge-in in gateway relay sessions. Current gateways require the `outputGeneration` advertised by the `talk-output-generation` capability; omission remains schema-compatible for older peers but returns `INVALID_REQUEST` with upgrade guidance, while an explicit stale generation is an idempotent no-op. + - `talk.session.cancelOutput` stops assistant audio output, primarily for VAD-gated barge-in in gateway relay sessions. Requests with the `outputGeneration` advertised by the `talk-output-generation` capability cancel only that output generation, while an explicit stale generation is an idempotent no-op. Older clients may omit `outputGeneration`; gateways preserve their shipped behavior by cancelling the full active turn, including agent work. - `talk.session.submitToolResult` completes a provider tool call emitted by a gateway-owned realtime relay session. The request waits for any asynchronous completion signal exposed by the provider bridge; failed submissions keep the linked run active and do not emit a successful tool-result event. Pass `options: { willContinue: true }` for interim tool output or `options: { suppressResponse: true }` when the provider bridge advertises suppression support and the result should not start another response. - `talk.session.steer` sends active-run voice control into a gateway-owned agent-backed Talk session: `{ sessionId, text, mode? }`, where `mode` is `status`, `steer`, `cancel`, or `followup`; omitted mode is classified from the spoken text. - `talk.session.close` closes a gateway-owned relay, transcription, or managed-room session and emits terminal Talk events. diff --git a/src/gateway/server-methods/talk-session.ts b/src/gateway/server-methods/talk-session.ts index fda0cdc8b9b9..3cfef1af4ac6 100644 --- a/src/gateway/server-methods/talk-session.ts +++ b/src/gateway/server-methods/talk-session.ts @@ -26,6 +26,7 @@ import { resolveSessionKeyFromResolveParams } from "../sessions-resolve.js"; import { createTalkHandoff, getTalkHandoff, revokeTalkHandoff } from "../talk-handoff.js"; import { cancelTalkRealtimeRelayOutput, + cancelTalkRealtimeRelayTurn, createTalkRealtimeRelaySession, sendTalkRealtimeRelayAudio, steerTalkRealtimeRelayAgentRun, @@ -417,13 +418,6 @@ export const talkSessionHandlers: GatewayRequestHandlers = { ) { return; } - if (params.outputGeneration === undefined) { - respondInvalidRequest( - respond, - "talk.session.cancelOutput requires outputGeneration; upgrade the client before retrying output cancellation", - ); - return; - } try { const session = getUnifiedTalkSession(params.sessionId); if (session.kind !== "realtime-relay") { @@ -431,13 +425,19 @@ export const talkSessionHandlers: GatewayRequestHandlers = { return; } const connId = requireUnifiedTalkSessionConn(session, client?.connId); - cancelTalkRealtimeRelayOutput({ - relaySessionId: session.relaySessionId, - connId, - turnId: normalizeOptionalString(params.turnId), - outputGeneration: params.outputGeneration, - reason: normalizeOptionalString(params.reason) ?? "output-cancelled", - }); + const reason = normalizeOptionalString(params.reason) ?? "output-cancelled"; + if (params.outputGeneration === undefined) { + // Deployed clients used this RPC for full-turn cancellation before output generations. + cancelTalkRealtimeRelayTurn({ relaySessionId: session.relaySessionId, connId, reason }); + } else { + cancelTalkRealtimeRelayOutput({ + relaySessionId: session.relaySessionId, + connId, + turnId: normalizeOptionalString(params.turnId), + outputGeneration: params.outputGeneration, + reason, + }); + } respondOk(respond); } catch (err) { respondUnavailable(respond, err); diff --git a/src/gateway/server-methods/talk.test.ts b/src/gateway/server-methods/talk.test.ts index 67ec9f427f96..b56863ab1330 100644 --- a/src/gateway/server-methods/talk.test.ts +++ b/src/gateway/server-methods/talk.test.ts @@ -9,6 +9,7 @@ import { createDeferred } from "../../../test/helpers/promise.js"; import type { OpenClawConfig } from "../../config/config.js"; import { normalizeResolvedSecretInputString } from "../../config/types.secrets.js"; import { REALTIME_VOICE_DESCRIBE_VIEW_TOOL_NAME } from "../../talk/describe-view-tool.js"; +import { forgetUnifiedTalkSession, rememberUnifiedTalkSession } from "../talk-session-registry.js"; import { buildTalkRealtimeConfig } from "./talk-shared.js"; import { talkHandlers } from "./talk.js"; @@ -1613,6 +1614,69 @@ describe("talk.session unified handlers", () => { }); }); + it("preserves legacy full-turn cancellation when outputGeneration is omitted", async () => { + rememberUnifiedTalkSession("relay-legacy-cancel", { + kind: "realtime-relay", + connId: "conn-1", + relaySessionId: "relay-legacy-cancel", + }); + const respond = vi.fn(); + try { + await callTalkHandler("talk.session.cancelOutput", { + params: { + sessionId: "relay-legacy-cancel", + turnId: "turn-legacy", + reason: "legacy-barge-in", + }, + respond, + context: {}, + }); + } finally { + forgetUnifiedTalkSession("relay-legacy-cancel"); + } + + expect(mocks.cancelTalkRealtimeRelayTurn).toHaveBeenCalledWith({ + relaySessionId: "relay-legacy-cancel", + connId: "conn-1", + reason: "legacy-barge-in", + }); + expect(mocks.cancelTalkRealtimeRelayOutput).not.toHaveBeenCalled(); + expectRespondOk(respond, { ok: true }); + }); + + it("uses generation-fenced output cancellation when outputGeneration is present", async () => { + rememberUnifiedTalkSession("relay-generation-cancel", { + kind: "realtime-relay", + connId: "conn-1", + relaySessionId: "relay-generation-cancel", + }); + const respond = vi.fn(); + try { + await callTalkHandler("talk.session.cancelOutput", { + params: { + sessionId: "relay-generation-cancel", + turnId: "turn-1", + outputGeneration: 3, + reason: "barge-in", + }, + respond, + context: {}, + }); + } finally { + forgetUnifiedTalkSession("relay-generation-cancel"); + } + + expect(mocks.cancelTalkRealtimeRelayOutput).toHaveBeenCalledWith({ + relaySessionId: "relay-generation-cancel", + connId: "conn-1", + turnId: "turn-1", + outputGeneration: 3, + reason: "barge-in", + }); + expect(mocks.cancelTalkRealtimeRelayTurn).not.toHaveBeenCalled(); + expectRespondOk(respond, { ok: true }); + }); + it("creates and drives a realtime gateway-relay session through the unified API", async () => { const provider = { id: "openai", @@ -1723,45 +1787,6 @@ describe("talk.session unified handlers", () => { timestamp: 42, }); - const cancelRespond = vi.fn(); - await callTalkHandler("talk.session.cancelOutput", { - params: { - sessionId: "relay-unified-1", - turnId: "turn-1", - outputGeneration: 3, - reason: "barge-in", - }, - id: "3", - respond: cancelRespond, - context: {}, - }); - expect(mocks.cancelTalkRealtimeRelayOutput).toHaveBeenCalledWith({ - relaySessionId: "relay-unified-1", - connId: "conn-1", - turnId: "turn-1", - outputGeneration: 3, - reason: "barge-in", - }); - expect(mocks.cancelTalkRealtimeRelayTurn).not.toHaveBeenCalled(); - - const legacyCancelRespond = vi.fn(); - await callTalkHandler("talk.session.cancelOutput", { - params: { - sessionId: "relay-unified-1", - turnId: "turn-1", - reason: "legacy-barge-in", - }, - id: "3-legacy", - respond: legacyCancelRespond, - context: {}, - }); - const legacyCancelError = expectRespondError(legacyCancelRespond, { - code: ErrorCodes.INVALID_REQUEST, - }); - expect(legacyCancelError.message).toContain("requires outputGeneration"); - expect(legacyCancelError.message).toContain("upgrade the client"); - expect(mocks.cancelTalkRealtimeRelayOutput).toHaveBeenCalledTimes(1); - const markRespond = vi.fn(); await callTalkHandler("talk.session.acknowledgeMark", { params: { sessionId: "relay-unified-1", markName: "audio-mark-1" },