Skip to content

Commit e437763

Browse files
Conan-ScottClawdbotobviyus
authored
fix(agents): deliver agent TTS audio when block streaming is off (#78355)
Summary: - The branch changes non-streaming block reply delivery to direct-send all media-bearing block replies, updates reply-delivery/media-path regression tests, and adds a changelog entry. - Reproducibility: yes. Current main's predicate and unit test show captioned media-bearing block replies are ... sent when block streaming is disabled, and the PR body adds real Telegram after-fix proof for the TTS path. Automerge notes: - PR branch already contained follow-up commit before automerge: test(agents): align direct media block delivery coverage Validation: - ClawSweeper review passed for head e9bb131. - Required merge gates passed before the squash merge. Prepared head SHA: e9bb131 Review: #78355 (comment) Co-authored-by: Clawdbot <[email protected]> Co-authored-by: Ayaan Zaidi <[email protected]>
1 parent ffafa90 commit e437763

4 files changed

Lines changed: 53 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ Docs: https://docs.openclaw.ai
142142
- Agents/context engines: keep hidden OpenClaw runtime-context custom messages out of context-engine assemble, afterTurn, and ingest hooks so transcript reconstruction plugins only see conversation messages. Thanks @vincentkoc.
143143
- Network/runtime: avoid importing Undici's package dispatcher during no-proxy timeout bootstrap so external channel plugin fetch requests with explicit Content-Length keep working. Fixes #78007. Thanks @shakkernerd.
144144
- Gateway/shutdown: cancel delayed post-ready maintenance during close and suppress maintenance/cron startup after quick restarts, preventing orphaned background timers. Thanks @vincentkoc.
145+
- Agents/TTS: send media-bearing block replies directly when block streaming is off, so agent `tts` tool audio attached to a final text reply is delivered instead of being consumed before final Telegram/media delivery. Thanks @Conan-Scott.
145146
- Agents/generated media: treat attachment-style message tool actions as completed chat sends, preventing duplicate fallback media posts when generated files were already uploaded.
146147
- Control UI/sessions: show each session's agent runtime in the Sessions table and allow filtering by runtime labels, matching the Agents panel runtime wording. Thanks @vincentkoc.
147148
- Discord/streaming: show live reasoning text in progress drafts instead of a bare `Reasoning` status line.

src/auto-reply/reply/agent-runner.media-paths.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,8 @@ describe("runReplyAgent media path normalization", () => {
268268
}),
269269
);
270270

271-
expect(result).toMatchObject({
271+
expect(result).toBeUndefined();
272+
expect(onBlockReply).toHaveBeenCalledWith({
272273
text: "here is the chart",
273274
mediaUrl: "/tmp/outbound-media/1-chart.png",
274275
mediaUrls: ["/tmp/outbound-media/1-chart.png"],
@@ -277,7 +278,6 @@ describe("runReplyAgent media path normalization", () => {
277278
audioAsVoice: false,
278279
});
279280
expect(resolveOutboundAttachmentFromUrlMock).toHaveBeenCalledTimes(1);
280-
expect(onBlockReply).not.toHaveBeenCalled();
281281
});
282282

283283
it("does not create a second media context inside runAgentTurnWithFallback when onBlockReply is provided", async () => {

src/auto-reply/reply/reply-delivery.test.ts

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ type BlockReplyPipelineLike = NonNullable<
1313
>;
1414

1515
describe("createBlockReplyDeliveryHandler", () => {
16-
it("keeps captioned media-bearing block replies buffered when block streaming is disabled", async () => {
16+
it("sends captioned media-bearing block replies when block streaming is disabled", async () => {
1717
const onBlockReply = vi.fn(async () => {});
1818
const normalizeStreamingText = vi.fn((payload: { text?: string }) => ({
1919
text: payload.text,
@@ -40,11 +40,57 @@ describe("createBlockReplyDeliveryHandler", () => {
4040
replyToCurrent: true,
4141
});
4242

43-
expect(onBlockReply).not.toHaveBeenCalled();
44-
expect(directlySentBlockKeys).toEqual(new Set());
43+
const expectedPayload = {
44+
text: "here's the vibe",
45+
mediaUrl: "/tmp/generated.png",
46+
mediaUrls: ["/tmp/generated.png"],
47+
replyToCurrent: true,
48+
replyToId: undefined,
49+
replyToTag: undefined,
50+
audioAsVoice: false,
51+
};
52+
53+
expect(onBlockReply).toHaveBeenCalledWith(expectedPayload);
54+
expect(directlySentBlockKeys).toEqual(new Set([createBlockReplyContentKey(expectedPayload)]));
4555
expect(typingSignals.signalTextDelta).toHaveBeenCalledWith("here's the vibe");
4656
});
4757

58+
it("sends captioned audio-as-voice block replies when block streaming is disabled", async () => {
59+
const onBlockReply = vi.fn(async () => {});
60+
const directlySentBlockKeys = new Set<string>();
61+
62+
const handler = createBlockReplyDeliveryHandler({
63+
onBlockReply,
64+
normalizeStreamingText: (payload) => ({ text: payload.text, skip: false }),
65+
applyReplyToMode: (payload) => payload,
66+
typingSignals: {
67+
signalTextDelta: vi.fn(async () => {}),
68+
} as unknown as TypingSignaler,
69+
blockStreamingEnabled: false,
70+
blockReplyPipeline: null,
71+
directlySentBlockKeys,
72+
});
73+
74+
await handler({
75+
text: "spoken confirmation",
76+
mediaUrls: ["/tmp/voice.opus"],
77+
audioAsVoice: true,
78+
});
79+
80+
const expectedPayload = {
81+
text: "spoken confirmation",
82+
mediaUrl: "/tmp/voice.opus",
83+
mediaUrls: ["/tmp/voice.opus"],
84+
replyToId: undefined,
85+
replyToCurrent: undefined,
86+
replyToTag: undefined,
87+
audioAsVoice: true,
88+
};
89+
90+
expect(onBlockReply).toHaveBeenCalledWith(expectedPayload);
91+
expect(directlySentBlockKeys).toEqual(new Set([createBlockReplyContentKey(expectedPayload)]));
92+
});
93+
4894
it("sends media-only block replies when block streaming is disabled", async () => {
4995
const onBlockReply = vi.fn(async () => {});
5096
const directlySentBlockKeys = new Set<string>();

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

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -157,9 +157,7 @@ export function createBlockReplyDeliveryHandler(params: {
157157
trackingPayload: blockPayload,
158158
payload: blockPayload,
159159
});
160-
} else if (blockHasMedia && !blockPayload.text) {
161-
// Media-only block replies (for example orphaned tool attachments) are not reconstructible
162-
// from the assistant's final text, so they still need a direct fallback when streaming is off.
160+
} else if (blockHasMedia) {
163161
await sendDirectBlockReply({
164162
onBlockReply: params.onBlockReply,
165163
directlySentBlockKeys: params.directlySentBlockKeys,

0 commit comments

Comments
 (0)