mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
fix(talk): preserve legacy output cancellation
Punchcard-Session: golden-meadow-cedar-dv
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user