fix(talk): preserve legacy output cancellation

Punchcard-Session: golden-meadow-cedar-dv
This commit is contained in:
Vincent Koc
2026-08-11 15:14:45 +08:00
parent 88bc53d542
commit 88e370dfac
3 changed files with 79 additions and 54 deletions
+1 -1
View File
@@ -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.
+14 -14
View File
@@ -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);
+64 -39
View File
@@ -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" },