Skip to content

Commit 49ae7ca

Browse files
committed
fix(dispatch): exclude isReasoning payloads from TTS synthesis
When a channel opts in via supportsReasoningBlocks (Matrix), explicit isReasoning payloads flow past the generic suppression guards and can reach TTS: the per-block TTS application, sendFinalPayload final TTS, and the post-stream accumulated-block TTS-only synthetic reply. Reasoning is private thinking text delivered as its own notice (Matrix m.notice); it must never be synthesized into audio. Exclude isReasoning from the block TTS accumulator (accumulatedBlockTtsText + blockCount) and short-circuit maybeApplyTtsToReplyPayload for isReasoning payloads. The TTS skip is a no-op for channels that suppress reasoning before delivery, since such payloads never reach TTS today. Addresses ClawSweeper P2 review findings on PR #93830: - dispatch-from-config.ts:3107 (accumulated block TTS) - dispatch-from-config.ts:3279 (final TTS) Adds two regression tests covering both paths.
1 parent 3fe241a commit 49ae7ca

2 files changed

Lines changed: 93 additions & 2 deletions

File tree

src/auto-reply/reply/dispatch-from-config.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7396,6 +7396,83 @@ describe("dispatchReplyFromConfig", () => {
73967396
expect(blockReplySentTexts).toContain("The answer is 42");
73977397
});
73987398

7399+
it("does not synthesize TTS for forwarded isReasoning final replies (opted-in channel)", async () => {
7400+
setNoAbort();
7401+
ttsMocks.state.synthesizeFinalAudio = true;
7402+
const dispatcher = createDispatcher();
7403+
const ctx = buildTestCtx({ Provider: "matrix" });
7404+
const replyResolver = async () =>
7405+
[
7406+
{ text: "thinking...", isReasoning: true },
7407+
{ text: "The answer is 42" },
7408+
] satisfies ReplyPayload[];
7409+
await dispatchReplyFromConfig({
7410+
ctx,
7411+
cfg: emptyConfig,
7412+
dispatcher,
7413+
replyResolver,
7414+
replyOptions: { supportsReasoningBlocks: true },
7415+
});
7416+
const finalCalls = (dispatcher.sendFinalReply as ReturnType<typeof vi.fn>).mock.calls;
7417+
// Both payloads are still delivered (forwarded) — TTS exclusion must not
7418+
// suppress the reasoning notice.
7419+
expect(finalCalls).toHaveLength(2);
7420+
const reasoningFinal = finalCalls[0]?.[0] as ReplyPayload | undefined;
7421+
const answerFinal = finalCalls[1]?.[0] as ReplyPayload | undefined;
7422+
expect(reasoningFinal?.text).toBe("thinking...");
7423+
// The reasoning notice is NOT synthesized into audio.
7424+
expect(reasoningFinal?.mediaUrl).toBeUndefined();
7425+
expect(ttsMocks.maybeApplyTtsToPayload).not.toHaveBeenCalledWith(
7426+
expect.objectContaining({ payload: expect.objectContaining({ isReasoning: true }) }),
7427+
);
7428+
// The answer is still eligible for TTS synthesis.
7429+
expect(answerFinal?.mediaUrl).toBe("https://example.com/tts-synth.opus");
7430+
});
7431+
7432+
it("excludes isReasoning blocks from accumulated block TTS-only synthesis (opted-in channel)", async () => {
7433+
setNoAbort();
7434+
ttsMocks.state.synthesizeFinalAudio = true;
7435+
const dispatcher = createDispatcher();
7436+
const ctx = buildTestCtx({
7437+
Provider: "feishu",
7438+
Surface: "feishu",
7439+
SessionKey: "agent:main:feishu:ou_user",
7440+
});
7441+
const blockReplySentTexts: string[] = [];
7442+
const replyResolver = async (
7443+
_ctx: MsgContext,
7444+
opts?: GetReplyOptions,
7445+
): Promise<ReplyPayload | undefined> => {
7446+
await opts?.onBlockReply?.({ text: "thinking...", isReasoning: true });
7447+
await opts?.onBlockReply?.({ text: "The answer is 42" });
7448+
return undefined;
7449+
};
7450+
(dispatcher.sendBlockReply as ReturnType<typeof vi.fn>).mockImplementation(
7451+
(payload: ReplyPayload) => {
7452+
if (payload.text) {
7453+
blockReplySentTexts.push(payload.text);
7454+
}
7455+
return true;
7456+
},
7457+
);
7458+
await dispatchReplyFromConfig({
7459+
ctx,
7460+
cfg: emptyConfig,
7461+
dispatcher,
7462+
replyResolver,
7463+
replyOptions: { supportsReasoningBlocks: true },
7464+
});
7465+
// The reasoning block is forwarded as its own notice (not suppressed).
7466+
expect(blockReplySentTexts).toContain("thinking...");
7467+
expect(blockReplySentTexts).toContain("The answer is 42");
7468+
// The post-stream TTS-only synthetic reply is built from the accumulated
7469+
// block text. Reasoning text must NOT be synthesized into audio.
7470+
const ttsOnlyPayload = firstFinalReplyPayload(dispatcher);
7471+
expect(ttsOnlyPayload?.mediaUrl).toBe("https://example.com/tts-synth.opus");
7472+
expect(ttsOnlyPayload?.spokenText).toBe("The answer is 42");
7473+
expect(ttsOnlyPayload?.spokenText).not.toContain("thinking");
7474+
});
7475+
73997476
it("strips split TTS directives from streamed block text before delivery", async () => {
74007477
setNoAbort();
74017478
ttsMocks.state.synthesizeFinalAudio = true;

src/auto-reply/reply/dispatch-from-config.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,15 @@ async function maybeApplyTtsToReplyPayload(
345345
if (isReplyPayloadStatusNotice(params.payload)) {
346346
return params.payload;
347347
}
348+
// Reasoning payloads are private thinking text delivered as their own
349+
// notice on opted-in channels (e.g. Matrix m.notice). They must never be
350+
// synthesised into audio, so skip TTS application for them on every path
351+
// (final reply, per-block reply, and the post-stream TTS-only synthetic).
352+
// This is a no-op for channels that suppress reasoning before delivery,
353+
// since such payloads never reach TTS today.
354+
if (params.payload.isReasoning === true) {
355+
return params.payload;
356+
}
348357
if (
349358
!shouldAttemptTtsPayload({
350359
cfg: params.cfg,
@@ -3180,9 +3189,14 @@ export async function dispatchReplyFromConfig(
31803189
}
31813190
// Accumulate block text for TTS generation after streaming.
31823191
// Exclude status notices — they are informational UI signals
3183-
// and must not be synthesised into the spoken reply.
3192+
// and must not be synthesised into the spoken reply. Exclude
3193+
// explicit reasoning payloads too: opted-in channels deliver
3194+
// them as their own notice (e.g. Matrix m.notice), so reasoning
3195+
// text must never be accumulated into the post-stream TTS-only
3196+
// synthetic reply or counted toward the TTS block gate.
31843197
const isStatusNotice = isReplyPayloadStatusNotice(payload);
3185-
if (payload.text && !isStatusNotice) {
3198+
const isReasoningBlock = payload.isReasoning === true;
3199+
if (payload.text && !isStatusNotice && !isReasoningBlock) {
31863200
const joinsBufferedTtsDirective =
31873201
cleanBlockTtsDirectiveText?.hasBufferedDirectiveText() === true;
31883202
if (accumulatedBlockText.length > 0) {

0 commit comments

Comments
 (0)