Skip to content

Commit af592d7

Browse files
committed
fix(reply): suppress per-message finals across multi-message block streaming
Block streaming tracked all streamed text fragments in one flat array and suppressed a final payload only when the concatenation of every fragment across all assistant messages equaled that payload. In a turn that streams two or more assistant messages, that aggregate never equals any single message's final text, so each fully-streamed message was re-delivered as a duplicate chat message. Group streamed fragments by assistantMessageIndex and suppress a final when any one message's fragment join matches it, mirroring the existing per-message direct-send path.
1 parent 8cd0c11 commit af592d7

2 files changed

Lines changed: 74 additions & 4 deletions

File tree

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { describe, expect, it } from "vitest";
2+
import { setReplyPayloadMetadata } from "../reply-payload.js";
3+
import { createBlockReplyPipeline } from "./block-reply-pipeline.js";
4+
5+
function blockFor(text: string, assistantMessageIndex: number) {
6+
return setReplyPayloadMetadata({ text }, { assistantMessageIndex });
7+
}
8+
9+
describe("block reply pipeline multi-assistant-message suppression", () => {
10+
it("recognizes each fully-streamed message across a multi-message turn", async () => {
11+
const sent: string[] = [];
12+
const pipeline = createBlockReplyPipeline({
13+
onBlockReply: async (payload) => {
14+
if (payload.text) {
15+
sent.push(payload.text);
16+
}
17+
},
18+
timeoutMs: 5000,
19+
});
20+
21+
pipeline.enqueue(blockFor("Alpha one.", 0));
22+
pipeline.enqueue(blockFor("Alpha two.", 0));
23+
pipeline.enqueue(blockFor("Beta one.", 1));
24+
pipeline.enqueue(blockFor("Beta two.", 1));
25+
await pipeline.flush({ force: true });
26+
27+
expect(sent).toEqual(["Alpha one.", "Alpha two.", "Beta one.", "Beta two."]);
28+
expect(pipeline.hasSentPayload({ text: "Alpha one. Alpha two." })).toBe(true);
29+
expect(pipeline.hasSentPayload({ text: "Beta one. Beta two." })).toBe(true);
30+
});
31+
32+
it("does not treat one message as covering another message's text", async () => {
33+
const pipeline = createBlockReplyPipeline({
34+
onBlockReply: async () => {},
35+
timeoutMs: 5000,
36+
});
37+
38+
pipeline.enqueue(blockFor("Alpha one.", 0));
39+
pipeline.enqueue(blockFor("Alpha two.", 0));
40+
pipeline.enqueue(blockFor("Beta one.", 1));
41+
pipeline.enqueue(blockFor("Beta two.", 1));
42+
await pipeline.flush({ force: true });
43+
44+
expect(pipeline.hasSentPayload({ text: "Alpha one. Alpha two. Beta one. Beta two." })).toBe(
45+
false,
46+
);
47+
});
48+
49+
it("suppresses a single message split into multiple blocks", async () => {
50+
const pipeline = createBlockReplyPipeline({
51+
onBlockReply: async () => {},
52+
timeoutMs: 5000,
53+
});
54+
55+
pipeline.enqueue(blockFor("Gamma one.", 0));
56+
pipeline.enqueue(blockFor("Gamma two.", 0));
57+
await pipeline.flush({ force: true });
58+
59+
expect(pipeline.hasSentPayload({ text: "Gamma one. Gamma two." })).toBe(true);
60+
});
61+
});

src/auto-reply/reply/block-reply-pipeline.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ export function createBlockReplyPipeline(params: {
114114
const bufferedKeys = new Set<string>();
115115
const bufferedPayloadKeys = new Set<string>();
116116
const bufferedPayloads: ReplyPayload[] = [];
117-
const streamedTextFragments: string[] = [];
117+
const streamedTextFragmentsByMessage = new Map<number | undefined, string[]>();
118118
let bufferedAssistantMessageIndex: number | undefined;
119119
let sendChain: Promise<void> = Promise.resolve();
120120
let aborted = false;
@@ -180,7 +180,10 @@ export function createBlockReplyPipeline(params: {
180180
sentMediaUrls.add(mediaUrl);
181181
}
182182
if (!isStatusNotice && reply.trimmedText) {
183-
streamedTextFragments.push(reply.trimmedText);
183+
const assistantMessageIndex = getReplyPayloadMetadata(payload)?.assistantMessageIndex;
184+
const fragments = streamedTextFragmentsByMessage.get(assistantMessageIndex) ?? [];
185+
fragments.push(reply.trimmedText);
186+
streamedTextFragmentsByMessage.set(assistantMessageIndex, fragments);
184187
}
185188
if (!isStatusNotice) {
186189
didStream = true;
@@ -322,15 +325,21 @@ export function createBlockReplyPipeline(params: {
322325
if (sentContentKeys.has(payloadKey)) {
323326
return true;
324327
}
325-
if (!didStream || streamedTextFragments.length === 0) {
328+
if (!didStream) {
326329
return false;
327330
}
328331
const reply = resolveSendableOutboundReplyParts(payload);
329332
if (reply.hasMedia || !reply.trimmedText) {
330333
return false;
331334
}
332335
const normalize = (text: string) => text.replace(/\s+/g, "");
333-
return normalize(streamedTextFragments.join("")) === normalize(reply.trimmedText);
336+
const target = normalize(reply.trimmedText);
337+
for (const fragments of streamedTextFragmentsByMessage.values()) {
338+
if (fragments.length > 0 && normalize(fragments.join("")) === target) {
339+
return true;
340+
}
341+
}
342+
return false;
334343
},
335344
getSentMediaUrls: () => Array.from(sentMediaUrls),
336345
};

0 commit comments

Comments
 (0)