@@ -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} ) ;
0 commit comments