mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(tts): preserve hidden-only tagged fallback text (#122608)
Punchcard-Session: golden-brook-lantern-zw
This commit is contained in:
@@ -3086,6 +3086,61 @@ describe("tryDispatchAcpReplyCore", () => {
|
||||
expect(dispatcherCall(dispatcher.sendFinalReply).text).toBe("Visible. Done.");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
expectedText: "Private ACP speech.",
|
||||
ttsReply: { text: "Private ACP speech." },
|
||||
finalReply: {},
|
||||
streamedText: "[[tts:text]]Private ACP speech.[[/tts:text]]",
|
||||
},
|
||||
{
|
||||
expectedText: undefined,
|
||||
ttsReply: {
|
||||
text: "Private ACP speech.",
|
||||
mediaUrl: "/tmp/openclaw-media/acp-tts.ogg",
|
||||
audioAsVoice: true,
|
||||
},
|
||||
finalReply: {
|
||||
mediaUrl: "/tmp/openclaw-media/acp-tts.ogg",
|
||||
audioAsVoice: true,
|
||||
},
|
||||
streamedText: "[[tts:text]]Private ACP speech.[[/tts:text]]",
|
||||
},
|
||||
{
|
||||
expectedText: "Visible ACP answer. ",
|
||||
ttsReply: { text: "Visible ACP answer." },
|
||||
finalReply: undefined,
|
||||
streamedText: "Visible ACP answer. [[tts:text]]Private speech.[[/tts:text]]",
|
||||
},
|
||||
])("keeps tagged ACP TTS delivery single for $streamedText", async (testCase) => {
|
||||
setReadyAcpResolution();
|
||||
queueTtsReplies(testCase.ttsReply as MockTtsReply);
|
||||
mockVisibleTextTurn(testCase.streamedText);
|
||||
const { dispatcher } = createDispatcher();
|
||||
|
||||
await runDispatch({
|
||||
bodyForAgent: "reply",
|
||||
cfg: createAcpTestConfig({
|
||||
acp: { enabled: true, stream: { deliveryMode: "live" } },
|
||||
tts: { auto: "tagged" },
|
||||
}),
|
||||
dispatcher,
|
||||
ctxOverrides: { Provider: "telegram", Surface: "telegram" },
|
||||
});
|
||||
|
||||
const blockReply = vi.mocked(dispatcher.sendBlockReply).mock.calls[0]?.[0];
|
||||
const deliveredPayload = testCase.finalReply
|
||||
? dispatcherCall(dispatcher.sendFinalReply)
|
||||
: blockReply;
|
||||
expect(deliveredPayload?.text).toBe(testCase.expectedText);
|
||||
if (testCase.finalReply) {
|
||||
expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1);
|
||||
expect(deliveredPayload).toMatchObject(testCase.finalReply);
|
||||
} else {
|
||||
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to Telegram ACP text when a routed captioned voice is suppressed", async () => {
|
||||
setReadyAcpResolution();
|
||||
ttsCapabilityMocks.captionedFinalText = true;
|
||||
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
createAcpDispatchDeliveryCoordinator,
|
||||
type AcpDispatchDeliveryCoordinator,
|
||||
} from "./dispatch-acp-delivery.js";
|
||||
import { needsTtsFallback } from "./dispatch-from-config.finalize.js";
|
||||
import { appendRecentHistoryImageContext } from "./history-media.js";
|
||||
import { hasInboundMediaForUnderstanding } from "./inbound-media.js";
|
||||
import type { ReplyDispatchKind, ReplyDispatcher } from "./reply-dispatcher.types.js";
|
||||
@@ -368,6 +369,13 @@ async function finalizeAcpTurnOutput(params: {
|
||||
{ skipTts: true },
|
||||
);
|
||||
queuedFinal = queuedFinal || delivered;
|
||||
} else if (needsTtsFallback(true, accumulatedVisibleBlockText, ttsSyntheticReply.text)) {
|
||||
const delivered = await params.delivery.deliver(
|
||||
"final",
|
||||
{ text: ttsSyntheticReply.text },
|
||||
{ skipTts: true },
|
||||
);
|
||||
queuedFinal = queuedFinal || delivered;
|
||||
}
|
||||
} catch (err) {
|
||||
logVerbose(`dispatch-acp: accumulated ACP block TTS failed: ${formatErrorMessage(err)}`);
|
||||
|
||||
@@ -15,6 +15,7 @@ import { createTestRegistry } from "../../test-utils/channel-plugins.js";
|
||||
import { getReplyPayloadMetadata, setReplyPayloadMetadata } from "../reply-payload.js";
|
||||
import type { MsgContext } from "../templating.js";
|
||||
import type { GetReplyOptions, ReplyPayload } from "../types.js";
|
||||
import { needsTtsFallback } from "./dispatch-from-config.finalize.js";
|
||||
import {
|
||||
createDispatcher,
|
||||
diagnosticMocks,
|
||||
@@ -2223,5 +2224,55 @@ describe("dispatchReplyFromConfig", () => {
|
||||
expect(dispatcher.sendBlockReply).toHaveBeenCalledWith({ text: "Plain tagged text." });
|
||||
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
expectedText: "Private speech.",
|
||||
ttsReply: { text: "Private speech." },
|
||||
finalReply: {},
|
||||
streamedText: "[[tts:text]]Private speech.[[/tts:text]]",
|
||||
},
|
||||
{
|
||||
expectedText: undefined,
|
||||
ttsReply: { text: "Private speech.", mediaUrl: "https://x/tts.opus", audioAsVoice: true },
|
||||
finalReply: { mediaUrl: "https://x/tts.opus", audioAsVoice: true },
|
||||
streamedText: "[[tts:text]]Private speech.[[/tts:text]]",
|
||||
},
|
||||
{
|
||||
expectedText: "Visible answer.",
|
||||
ttsReply: { text: "Visible answer." },
|
||||
finalReply: undefined,
|
||||
streamedText: "Visible answer. [[tts:text]]Private speech.[[/tts:text]]",
|
||||
},
|
||||
])("keeps tagged TTS delivery single for $streamedText", async (testCase) => {
|
||||
setNoAbort();
|
||||
ttsMocks.state.statusSnapshot.autoMode = "tagged";
|
||||
ttsMocks.maybeApplyTtsToPayload.mockResolvedValueOnce(testCase.ttsReply);
|
||||
const dispatcher = createDispatcher();
|
||||
const replyResolver = async (_ctx: MsgContext, opts?: GetReplyOptions) => {
|
||||
await opts?.onBlockReply?.({ text: testCase.streamedText });
|
||||
return undefined;
|
||||
};
|
||||
|
||||
await dispatchReplyFromConfig({
|
||||
ctx: buildTestCtx({ Provider: "telegram", Surface: "telegram" }),
|
||||
cfg: emptyConfig,
|
||||
dispatcher,
|
||||
replyResolver,
|
||||
});
|
||||
|
||||
const blockReply = vi.mocked(dispatcher.sendBlockReply).mock.calls[0]?.[0];
|
||||
const deliveredPayload = testCase.finalReply ? firstFinalReplyPayload(dispatcher) : blockReply;
|
||||
expect(deliveredPayload?.text?.trim()).toBe(testCase.expectedText);
|
||||
if (testCase.finalReply) {
|
||||
expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1);
|
||||
expect(deliveredPayload).toMatchObject(testCase.finalReply);
|
||||
} else {
|
||||
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("skips fallback when directives stay visible", () =>
|
||||
expect(needsTtsFallback(false, "[[tts:text]]x", "x")).toBe(false));
|
||||
});
|
||||
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
|
||||
|
||||
@@ -31,6 +31,9 @@ type ExecuteDispatchReadyState = Extract<
|
||||
{ status: "ready" }
|
||||
>["state"];
|
||||
|
||||
export const needsTtsFallback = (clean: boolean, visible: string, fallback?: string) =>
|
||||
clean && !visible.trim() && Boolean(fallback?.trim());
|
||||
|
||||
export async function finalizeDispatchAndAudit(state: ExecuteDispatchReadyState) {
|
||||
const {
|
||||
cfg,
|
||||
@@ -233,6 +236,19 @@ export async function finalizeDispatchAndAudit(state: ExecuteDispatchReadyState)
|
||||
});
|
||||
queuedFinal = finalReply.queuedFinal || queuedFinal;
|
||||
routedFinalCount += finalReply.routedFinalCount;
|
||||
} else if (
|
||||
needsTtsFallback(
|
||||
Boolean(state.cleanBlockTtsDirectiveText),
|
||||
cleanDeferredFinalText(deferredTtsTextPending),
|
||||
ttsSyntheticReply.text,
|
||||
)
|
||||
) {
|
||||
const finalReply = await state.sendFinalPayload(ttsSyntheticReply, {
|
||||
abortSignal: getDispatchAbortSignal(),
|
||||
skipTts: true,
|
||||
});
|
||||
queuedFinal = finalReply.queuedFinal || queuedFinal;
|
||||
routedFinalCount += finalReply.routedFinalCount;
|
||||
}
|
||||
} catch (err) {
|
||||
if (isDispatchReplyOperationAbortedError(err)) {
|
||||
|
||||
Reference in New Issue
Block a user