Skip to content

Commit 7d7c61f

Browse files
yetvalvincentkoc
authored andcommitted
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 9f0d242 commit 7d7c61f

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
@@ -120,7 +120,7 @@ export function createBlockReplyPipeline(params: {
120120
const bufferedKeys = new Set<string>();
121121
const bufferedPayloadKeys = new Set<string>();
122122
const bufferedPayloads: ReplyPayload[] = [];
123-
const streamedTextFragments: string[] = [];
123+
const streamedTextFragmentsByMessage = new Map<number | undefined, string[]>();
124124
let bufferedAssistantMessageIndex: number | undefined;
125125
let sendChain: Promise<void> = Promise.resolve();
126126
let aborted = false;
@@ -186,7 +186,10 @@ export function createBlockReplyPipeline(params: {
186186
sentMediaUrls.add(mediaUrl);
187187
}
188188
if (!isStatusNotice && reply.trimmedText) {
189-
streamedTextFragments.push(reply.trimmedText);
189+
const assistantMessageIndex = getReplyPayloadMetadata(payload)?.assistantMessageIndex;
190+
const fragments = streamedTextFragmentsByMessage.get(assistantMessageIndex) ?? [];
191+
fragments.push(reply.trimmedText);
192+
streamedTextFragmentsByMessage.set(assistantMessageIndex, fragments);
190193
}
191194
if (!isStatusNotice) {
192195
didStream = true;
@@ -328,15 +331,21 @@ export function createBlockReplyPipeline(params: {
328331
if (sentContentKeys.has(payloadKey)) {
329332
return true;
330333
}
331-
if (!didStream || streamedTextFragments.length === 0) {
334+
if (!didStream) {
332335
return false;
333336
}
334337
const reply = resolveSendableOutboundReplyParts(payload);
335338
if (reply.hasMedia || !reply.trimmedText) {
336339
return false;
337340
}
338341
const normalize = (text: string) => text.replace(/\s+/g, "");
339-
return normalize(streamedTextFragments.join("")) === normalize(reply.trimmedText);
342+
const target = normalize(reply.trimmedText);
343+
for (const fragments of streamedTextFragmentsByMessage.values()) {
344+
if (fragments.length > 0 && normalize(fragments.join("")) === target) {
345+
return true;
346+
}
347+
}
348+
return false;
340349
},
341350
getSentMediaUrls: () => Array.from(sentMediaUrls),
342351
};

0 commit comments

Comments
 (0)