Skip to content

Commit 22d043e

Browse files
committed
fix(acp): deliver final result text as fallback when no blocks routed
- Check routedCounts.final to detect prior delivery - Skip fallback for ttsMode='all' to avoid duplicate TTS processing - Use delivery.deliver for proper routing in cross-provider turns - Fixes #46814 where ACP child run results were not delivered
1 parent 922f4e6 commit 22d043e

File tree

3 files changed

+132
-9
lines changed

3 files changed

+132
-9
lines changed

src/auto-reply/reply/dispatch-acp-delivery.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { routeReply } from "./route-reply.js";
1212
export type AcpDispatchDeliveryMeta = {
1313
toolCallId?: string;
1414
allowEdit?: boolean;
15+
skipTts?: boolean;
1516
};
1617

1718
type ToolMessageHandle = {
@@ -132,14 +133,16 @@ export function createAcpDispatchDeliveryCoordinator(params: {
132133
await startReplyLifecycleOnce();
133134
}
134135

135-
const ttsPayload = await maybeApplyTtsToPayload({
136-
payload,
137-
cfg: params.cfg,
138-
channel: params.ttsChannel,
139-
kind,
140-
inboundAudio: params.inboundAudio,
141-
ttsAuto: params.sessionTtsAuto,
142-
});
136+
const ttsPayload = meta?.skipTts
137+
? payload
138+
: await maybeApplyTtsToPayload({
139+
payload,
140+
cfg: params.cfg,
141+
channel: params.ttsChannel,
142+
kind,
143+
inboundAudio: params.inboundAudio,
144+
ttsAuto: params.sessionTtsAuto,
145+
});
143146

144147
if (params.shouldRouteToOriginating && params.originatingChannel && params.originatingTo) {
145148
const toolCallId = meta?.toolCallId?.trim();

src/auto-reply/reply/dispatch-acp.test.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -435,4 +435,96 @@ describe("tryDispatchAcpReply", () => {
435435
}),
436436
);
437437
});
438+
439+
it("delivers accumulated block text as fallback when TTS synthesis returns no media", async () => {
440+
setReadyAcpResolution();
441+
// Configure TTS mode as "final" but TTS synthesis returns no mediaUrl
442+
ttsMocks.resolveTtsConfig.mockReturnValue({ mode: "final" });
443+
// Mock TTS to return no mediaUrl for this test only (use Once to avoid cross-test leak)
444+
ttsMocks.maybeApplyTtsToPayload.mockResolvedValueOnce(
445+
{} as ReturnType<typeof ttsMocks.maybeApplyTtsToPayload>,
446+
);
447+
448+
managerMocks.runTurn.mockImplementation(
449+
async ({ onEvent }: { onEvent: (event: unknown) => Promise<void> }) => {
450+
await onEvent({ type: "text_delta", text: "CODEX_OK", tag: "agent_message_chunk" });
451+
await onEvent({ type: "done" });
452+
},
453+
);
454+
455+
const { dispatcher } = createDispatcher();
456+
const result = await runDispatch({
457+
bodyForAgent: "run acp",
458+
dispatcher,
459+
shouldRouteToOriginating: true,
460+
});
461+
462+
// Should deliver final text as fallback when TTS produced no media.
463+
// Note: ACP sends block first (during flush on done), then final fallback.
464+
// So routeReply is called twice: 1 for block + 1 for final.
465+
expect(result?.counts.block).toBeGreaterThanOrEqual(1);
466+
expect(result?.counts.final).toBe(1);
467+
expect(routeMocks.routeReply).toHaveBeenCalledTimes(2);
468+
// Verify final delivery contains the expected text
469+
expect(routeMocks.routeReply).toHaveBeenCalledWith(
470+
expect.objectContaining({
471+
payload: expect.objectContaining({
472+
text: "CODEX_OK",
473+
}),
474+
}),
475+
);
476+
});
477+
478+
it("does not duplicate delivery when blocks were already routed", async () => {
479+
setReadyAcpResolution();
480+
// Configure TTS mode as "none" - should skip TTS for final delivery
481+
ttsMocks.resolveTtsConfig.mockReturnValue({ mode: "none" });
482+
483+
// Simulate normal flow where projector routes blocks
484+
managerMocks.runTurn.mockImplementation(
485+
async ({ onEvent }: { onEvent: (event: unknown) => Promise<void> }) => {
486+
await onEvent({ type: "text_delta", text: "Task completed", tag: "agent_message_chunk" });
487+
await onEvent({ type: "done" });
488+
},
489+
);
490+
491+
const { dispatcher } = createDispatcher();
492+
const result = await runDispatch({
493+
bodyForAgent: "run acp",
494+
dispatcher,
495+
shouldRouteToOriginating: true,
496+
});
497+
498+
// Should NOT deliver duplicate final text when blocks were already routed
499+
// The block delivery should be sufficient
500+
expect(result?.counts.block).toBeGreaterThanOrEqual(1);
501+
expect(result?.counts.final).toBe(0);
502+
// Verify routeReply was called for block, not for duplicate final
503+
expect(routeMocks.routeReply).toHaveBeenCalledTimes(1);
504+
});
505+
506+
it("skips fallback when TTS mode is all (blocks already processed with TTS)", async () => {
507+
setReadyAcpResolution();
508+
// Configure TTS mode as "all" - blocks already went through TTS
509+
ttsMocks.resolveTtsConfig.mockReturnValue({ mode: "all" });
510+
511+
managerMocks.runTurn.mockImplementation(
512+
async ({ onEvent }: { onEvent: (event: unknown) => Promise<void> }) => {
513+
await onEvent({ type: "text_delta", text: "Response", tag: "agent_message_chunk" });
514+
await onEvent({ type: "done" });
515+
},
516+
);
517+
518+
const { dispatcher } = createDispatcher();
519+
const result = await runDispatch({
520+
bodyForAgent: "run acp",
521+
dispatcher,
522+
shouldRouteToOriginating: true,
523+
});
524+
525+
// Should NOT trigger fallback for ttsMode="all" to avoid duplicate TTS
526+
expect(result?.counts.final).toBe(0);
527+
// Note: maybeApplyTtsToPayload is called during block delivery, not in the fallback path
528+
// We just verify that no final delivery occurred
529+
});
438530
});

src/auto-reply/reply/dispatch-acp.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,11 @@ export async function tryDispatchAcpReply(params: {
316316
await projector.flush(true);
317317
const ttsMode = resolveTtsConfig(params.cfg).mode ?? "final";
318318
const accumulatedBlockText = delivery.getAccumulatedBlockText();
319-
if (ttsMode === "final" && delivery.getBlockCount() > 0 && accumulatedBlockText.trim()) {
319+
const routedCounts = delivery.getRoutedCounts();
320+
// Attempt final TTS synthesis for ttsMode="final" (independent of delivery status).
321+
// This ensures routed ACP flows still get final audio even after block delivery.
322+
let ttsSucceeded = false;
323+
if (ttsMode === "final") {
320324
try {
321325
const ttsSyntheticReply = await maybeApplyTtsToPayload({
322326
payload: { text: accumulatedBlockText },
@@ -327,18 +331,42 @@ export async function tryDispatchAcpReply(params: {
327331
ttsAuto: params.sessionTtsAuto,
328332
});
329333
if (ttsSyntheticReply.mediaUrl) {
334+
// Use delivery.deliver to ensure proper routing in cross-provider ACP turns.
335+
// Pass audioAsVoice to avoid re-entering TTS synthesis.
330336
const delivered = await delivery.deliver("final", {
331337
mediaUrl: ttsSyntheticReply.mediaUrl,
332338
audioAsVoice: ttsSyntheticReply.audioAsVoice,
333339
});
334340
queuedFinal = queuedFinal || delivered;
341+
if (delivered) {
342+
ttsSucceeded = true; // TTS succeeded AND delivered, skip text fallback
343+
}
335344
}
336345
} catch (err) {
337346
logVerbose(
338347
`dispatch-acp: accumulated ACP block TTS failed: ${err instanceof Error ? err.message : String(err)}`,
339348
);
349+
// TTS failed, fall through to text fallback
340350
}
341351
}
352+
// Only attempt text fallback if no delivery has happened yet.
353+
// For routed flows, check routedCounts (block or final) to detect prior successful delivery.
354+
// For non-routed flows, we cannot reliably detect delivery success (blockCount increments
355+
// before send), so we skip the fallback guard to allow recovery when block delivery fails.
356+
// Skip fallback for ttsMode="all" because blocks were already processed with TTS.
357+
const shouldSkipTextFallback =
358+
ttsMode === "all" ||
359+
ttsSucceeded ||
360+
(params.shouldRouteToOriginating && (routedCounts.block > 0 || routedCounts.final > 0));
361+
if (!shouldSkipTextFallback && accumulatedBlockText.trim()) {
362+
// Fallback to text-only delivery (no TTS).
363+
// For routed flows, use delivery.deliver with skipTts to bypass TTS re-entry.
364+
// For non-routed flows, use dispatcher directly to bypass TTS.
365+
const delivered = params.shouldRouteToOriginating
366+
? await delivery.deliver("final", { text: accumulatedBlockText }, { skipTts: true })
367+
: params.dispatcher.sendFinalReply({ text: accumulatedBlockText });
368+
queuedFinal = queuedFinal || delivered;
369+
}
342370

343371
if (shouldEmitResolvedIdentityNotice) {
344372
const currentMeta = readAcpSessionEntry({

0 commit comments

Comments
 (0)