Merge bd74e470e50d2c48fec50fd408bab169e4c853cd into 598f1826d8b2bc969aace2c6459824737667218c
This commit is contained in:
commit
3ef2aba90b
@ -1,4 +1,3 @@
|
||||
import { hasOutboundReplyContent } from "openclaw/plugin-sdk/reply-payload";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type { TtsAutoMode } from "../../config/types.tts.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
@ -12,6 +11,7 @@ import { routeReply } from "./route-reply.js";
|
||||
export type AcpDispatchDeliveryMeta = {
|
||||
toolCallId?: string;
|
||||
allowEdit?: boolean;
|
||||
skipTts?: boolean;
|
||||
};
|
||||
|
||||
type ToolMessageHandle = {
|
||||
@ -128,18 +128,20 @@ export function createAcpDispatchDeliveryCoordinator(params: {
|
||||
state.blockCount += 1;
|
||||
}
|
||||
|
||||
if (hasOutboundReplyContent(payload, { trimText: true })) {
|
||||
if ((payload.text?.trim() ?? "").length > 0 || payload.mediaUrl || payload.mediaUrls?.length) {
|
||||
await startReplyLifecycleOnce();
|
||||
}
|
||||
|
||||
const ttsPayload = await maybeApplyTtsToPayload({
|
||||
payload,
|
||||
cfg: params.cfg,
|
||||
channel: params.ttsChannel,
|
||||
kind,
|
||||
inboundAudio: params.inboundAudio,
|
||||
ttsAuto: params.sessionTtsAuto,
|
||||
});
|
||||
const ttsPayload = meta?.skipTts
|
||||
? payload
|
||||
: await maybeApplyTtsToPayload({
|
||||
payload,
|
||||
cfg: params.cfg,
|
||||
channel: params.ttsChannel,
|
||||
kind,
|
||||
inboundAudio: params.inboundAudio,
|
||||
ttsAuto: params.sessionTtsAuto,
|
||||
});
|
||||
|
||||
if (params.shouldRouteToOriginating && params.originatingChannel && params.originatingTo) {
|
||||
const toolCallId = meta?.toolCallId?.trim();
|
||||
|
||||
@ -435,4 +435,91 @@ describe("tryDispatchAcpReply", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("delivers accumulated block text as fallback when TTS synthesis returns no media", async () => {
|
||||
setReadyAcpResolution();
|
||||
// Configure TTS mode as "final" but TTS synthesis returns no mediaUrl
|
||||
ttsMocks.resolveTtsConfig.mockReturnValue({ mode: "final" });
|
||||
// Mock TTS to return no mediaUrl for all calls in this test
|
||||
ttsMocks.maybeApplyTtsToPayload.mockResolvedValue(
|
||||
{} as ReturnType<typeof ttsMocks.maybeApplyTtsToPayload>,
|
||||
);
|
||||
|
||||
managerMocks.runTurn.mockImplementation(
|
||||
async ({ onEvent }: { onEvent: (event: unknown) => Promise<void> }) => {
|
||||
await onEvent({ type: "text_delta", text: "CODEX_OK", tag: "agent_message_chunk" });
|
||||
await onEvent({ type: "done" });
|
||||
},
|
||||
);
|
||||
|
||||
const { dispatcher } = createDispatcher();
|
||||
const result = await runDispatch({
|
||||
bodyForAgent: "run acp",
|
||||
dispatcher,
|
||||
shouldRouteToOriginating: false, // Use non-routed flow to test fallback logic
|
||||
});
|
||||
|
||||
// Should deliver final text as fallback when TTS produced no media.
|
||||
// In non-routed flow, block delivery is not tracked, so fallback should run.
|
||||
expect(result?.counts.final).toBe(1);
|
||||
// Verify final delivery contains the expected text
|
||||
expect(dispatcher.sendFinalReply).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: "CODEX_OK",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not duplicate delivery when blocks were already routed", async () => {
|
||||
setReadyAcpResolution();
|
||||
// Configure TTS mode as "none" - should skip TTS for final delivery
|
||||
ttsMocks.resolveTtsConfig.mockReturnValue({ mode: "none" });
|
||||
|
||||
// Simulate normal flow where projector routes blocks
|
||||
managerMocks.runTurn.mockImplementation(
|
||||
async ({ onEvent }: { onEvent: (event: unknown) => Promise<void> }) => {
|
||||
await onEvent({ type: "text_delta", text: "Task completed", tag: "agent_message_chunk" });
|
||||
await onEvent({ type: "done" });
|
||||
},
|
||||
);
|
||||
|
||||
const { dispatcher } = createDispatcher();
|
||||
const result = await runDispatch({
|
||||
bodyForAgent: "run acp",
|
||||
dispatcher,
|
||||
shouldRouteToOriginating: true,
|
||||
});
|
||||
|
||||
// Should NOT deliver duplicate final text when blocks were already routed
|
||||
// The block delivery should be sufficient
|
||||
expect(result?.counts.block).toBeGreaterThanOrEqual(1);
|
||||
expect(result?.counts.final).toBe(0);
|
||||
// Verify routeReply was called for block, not for duplicate final
|
||||
expect(routeMocks.routeReply).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("skips fallback when TTS mode is all (blocks already processed with TTS)", async () => {
|
||||
setReadyAcpResolution();
|
||||
// Configure TTS mode as "all" - blocks already went through TTS
|
||||
ttsMocks.resolveTtsConfig.mockReturnValue({ mode: "all" });
|
||||
|
||||
managerMocks.runTurn.mockImplementation(
|
||||
async ({ onEvent }: { onEvent: (event: unknown) => Promise<void> }) => {
|
||||
await onEvent({ type: "text_delta", text: "Response", tag: "agent_message_chunk" });
|
||||
await onEvent({ type: "done" });
|
||||
},
|
||||
);
|
||||
|
||||
const { dispatcher } = createDispatcher();
|
||||
const result = await runDispatch({
|
||||
bodyForAgent: "run acp",
|
||||
dispatcher,
|
||||
shouldRouteToOriginating: true,
|
||||
});
|
||||
|
||||
// Should NOT trigger fallback for ttsMode="all" to avoid duplicate TTS
|
||||
expect(result?.counts.final).toBe(0);
|
||||
// Note: maybeApplyTtsToPayload is called during block delivery, not in the fallback path
|
||||
// We just verify that no final delivery occurred
|
||||
});
|
||||
});
|
||||
|
||||
@ -314,7 +314,11 @@ export async function tryDispatchAcpReply(params: {
|
||||
await projector.flush(true);
|
||||
const ttsMode = resolveTtsConfig(params.cfg).mode ?? "final";
|
||||
const accumulatedBlockText = delivery.getAccumulatedBlockText();
|
||||
if (ttsMode === "final" && delivery.getBlockCount() > 0 && accumulatedBlockText.trim()) {
|
||||
const routedCounts = delivery.getRoutedCounts();
|
||||
// Attempt final TTS synthesis for ttsMode="final" (only if we have text to synthesize).
|
||||
// This ensures routed ACP flows still get final audio even after block delivery.
|
||||
let ttsSucceeded = false;
|
||||
if (ttsMode === "final" && accumulatedBlockText.trim()) {
|
||||
try {
|
||||
const ttsSyntheticReply = await maybeApplyTtsToPayload({
|
||||
payload: { text: accumulatedBlockText },
|
||||
@ -325,18 +329,42 @@ export async function tryDispatchAcpReply(params: {
|
||||
ttsAuto: params.sessionTtsAuto,
|
||||
});
|
||||
if (ttsSyntheticReply.mediaUrl) {
|
||||
// Use delivery.deliver to ensure proper routing in cross-provider ACP turns.
|
||||
// Pass audioAsVoice to avoid re-entering TTS synthesis.
|
||||
const delivered = await delivery.deliver("final", {
|
||||
mediaUrl: ttsSyntheticReply.mediaUrl,
|
||||
audioAsVoice: ttsSyntheticReply.audioAsVoice,
|
||||
});
|
||||
queuedFinal = queuedFinal || delivered;
|
||||
if (delivered) {
|
||||
ttsSucceeded = true; // TTS succeeded AND delivered, skip text fallback
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logVerbose(
|
||||
`dispatch-acp: accumulated ACP block TTS failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
// TTS failed, fall through to text fallback
|
||||
}
|
||||
}
|
||||
// Only attempt text fallback if no delivery has happened yet.
|
||||
// For routed flows, check routedCounts (block or final) to detect prior successful delivery.
|
||||
// For non-routed flows, we cannot reliably detect delivery success (blockCount increments
|
||||
// before send), so we skip the fallback guard to allow recovery when block delivery fails.
|
||||
// Skip fallback for ttsMode="all" because blocks were already processed with TTS.
|
||||
const shouldSkipTextFallback =
|
||||
ttsMode === "all" ||
|
||||
ttsSucceeded ||
|
||||
(params.shouldRouteToOriginating && (routedCounts.block > 0 || routedCounts.final > 0));
|
||||
if (!shouldSkipTextFallback && accumulatedBlockText.trim()) {
|
||||
// Fallback to text-only delivery (no TTS).
|
||||
// For routed flows, use delivery.deliver with skipTts to bypass TTS re-entry.
|
||||
// For non-routed flows, use dispatcher directly to bypass TTS.
|
||||
const delivered = params.shouldRouteToOriginating
|
||||
? await delivery.deliver("final", { text: accumulatedBlockText }, { skipTts: true })
|
||||
: params.dispatcher.sendFinalReply({ text: accumulatedBlockText });
|
||||
queuedFinal = queuedFinal || delivered;
|
||||
}
|
||||
|
||||
if (shouldEmitResolvedIdentityNotice) {
|
||||
const currentMeta = readAcpSessionEntry({
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user