






















@@ -23,6 +23,10 @@ const mockState = vi.hoisted(() => ({
2323finalPayload: null as {
2424text?: string;
2525mediaUrl?: string;
26+mediaUrls?: string[];
27+spokenText?: string;
28+audioAsVoice?: boolean;
29+trustedLocalMedia?: boolean;
2630sensitiveMedia?: boolean;
2731replyToId?: string;
2832replyToCurrent?: boolean;
@@ -34,6 +38,8 @@ const mockState = vi.hoisted(() => ({
3438text?: string;
3539mediaUrl?: string;
3640mediaUrls?: string[];
41+spokenText?: string;
42+audioAsVoice?: boolean;
3743trustedLocalMedia?: boolean;
3844replyToId?: string;
3945replyToCurrent?: boolean;
@@ -113,6 +119,10 @@ vi.mock("../../auto-reply/dispatch.js", () => ({
113119sendFinalReply: (payload: {
114120text?: string;
115121mediaUrl?: string;
122+mediaUrls?: string[];
123+spokenText?: string;
124+audioAsVoice?: boolean;
125+trustedLocalMedia?: boolean;
116126sensitiveMedia?: boolean;
117127replyToId?: string;
118128replyToCurrent?: boolean;
@@ -122,6 +132,8 @@ vi.mock("../../auto-reply/dispatch.js", () => ({
122132text?: string;
123133mediaUrl?: string;
124134mediaUrls?: string[];
135+spokenText?: string;
136+audioAsVoice?: boolean;
125137trustedLocalMedia?: boolean;
126138replyToId?: string;
127139replyToCurrent?: boolean;
@@ -131,6 +143,8 @@ vi.mock("../../auto-reply/dispatch.js", () => ({
131143text?: string;
132144mediaUrl?: string;
133145mediaUrls?: string[];
146+spokenText?: string;
147+audioAsVoice?: boolean;
134148trustedLocalMedia?: boolean;
135149replyToId?: string;
136150replyToCurrent?: boolean;
@@ -257,6 +271,7 @@ function createTranscriptFixture(prefix: string) {
257271"utf-8",
258272);
259273mockState.transcriptPath = transcriptPath;
274+return dir;
260275}
261276262277function extractFirstTextBlock(payload: unknown): string | undefined {
@@ -579,6 +594,121 @@ describe("chat directive tag stripping for non-streaming final payloads", () =>
579594});
580595});
581596597+it("persists auto-TTS final media as audio-only so webchat does not duplicate assistant text", async () => {
598+const transcriptDir = createTranscriptFixture("openclaw-chat-send-agent-tts-final-");
599+const audioPath = path.join(transcriptDir, "tts.mp3");
600+fs.writeFileSync(audioPath, Buffer.from([0xff, 0xfb, 0x90, 0x00]));
601+mockState.config = {
602+agents: {
603+defaults: {
604+workspace: transcriptDir,
605+},
606+},
607+};
608+mockState.triggerAgentRunStart = true;
609+mockState.dispatchedReplies = [
610+{
611+kind: "final",
612+payload: {
613+text: "This text is already in the model transcript.",
614+spokenText: "This text is already in the model transcript.",
615+mediaUrl: audioPath,
616+mediaUrls: [audioPath],
617+trustedLocalMedia: true,
618+audioAsVoice: true,
619+},
620+},
621+];
622+const respond = vi.fn();
623+const context = createChatContext();
624+625+await runNonStreamingChatSend({
626+ context,
627+ respond,
628+idempotencyKey: "idem-agent-tts",
629+expectBroadcast: false,
630+waitFor: "dedupe",
631+});
632+633+const assistantUpdates = mockState.emittedTranscriptUpdates.filter(
634+(update) =>
635+typeof update.message === "object" &&
636+update.message !== null &&
637+(update.message as { role?: unknown }).role === "assistant",
638+);
639+expect(assistantUpdates).toHaveLength(1);
640+expect(assistantUpdates[0]).toMatchObject({
641+message: {
642+role: "assistant",
643+idempotencyKey: "idem-agent-tts:assistant-media",
644+content: [
645+{ type: "text", text: "Audio reply" },
646+{
647+type: "audio",
648+source: {
649+type: "base64",
650+media_type: "audio/mpeg",
651+},
652+},
653+],
654+},
655+});
656+expect(JSON.stringify(assistantUpdates[0]?.message)).not.toContain(
657+"This text is already in the model transcript.",
658+);
659+});
660+661+it("keeps visible text on non-agent TTS final media because no model transcript exists", async () => {
662+const transcriptDir = createTranscriptFixture("openclaw-chat-send-command-tts-final-");
663+const audioPath = path.join(transcriptDir, "tts.mp3");
664+fs.writeFileSync(audioPath, Buffer.from([0xff, 0xfb, 0x90, 0x00]));
665+mockState.config = {
666+agents: {
667+defaults: {
668+workspace: transcriptDir,
669+},
670+},
671+};
672+mockState.finalPayload = {
673+text: "Command result with TTS.",
674+spokenText: "Command result with TTS.",
675+mediaUrl: audioPath,
676+mediaUrls: [audioPath],
677+trustedLocalMedia: true,
678+audioAsVoice: true,
679+};
680+const respond = vi.fn();
681+const context = createChatContext();
682+683+const payload = await runNonStreamingChatSend({
684+ context,
685+ respond,
686+idempotencyKey: "idem-command-tts",
687+});
688+689+expect(payload?.message).toMatchObject({
690+role: "assistant",
691+content: [
692+{ type: "text", text: "Command result with TTS." },
693+{
694+type: "audio",
695+source: {
696+type: "base64",
697+media_type: "audio/mpeg",
698+},
699+},
700+],
701+});
702+const assistantUpdates = mockState.emittedTranscriptUpdates.filter(
703+(update) =>
704+typeof update.message === "object" &&
705+update.message !== null &&
706+(update.message as { role?: unknown }).role === "assistant",
707+);
708+expect(assistantUpdates).toHaveLength(1);
709+expect(JSON.stringify(assistantUpdates[0]?.message)).toContain("Command result with TTS.");
710+});
711+582712it("renders image reply payloads as assistant image content instead of MEDIA text", async () => {
583713createTranscriptFixture("openclaw-chat-send-agent-image-");
584714mockState.finalPayload = {
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。