fix: flush block streaming on paragraph boundaries for chunkMode=newline (#7014)

* feat: Implement paragraph boundary flushing in block streaming

- Added `flushOnParagraph` option to `BlockReplyChunking` for immediate flushing on paragraph breaks.
- Updated `EmbeddedBlockChunker` to handle paragraph boundaries during chunking.
- Enhanced `createBlockReplyCoalescer` to support flushing on enqueue.
- Added tests to verify behavior of flushing with and without `flushOnEnqueue` set.
- Updated relevant types and interfaces to include `flushOnParagraph` and `flushOnEnqueue` options.

* fix: Improve streaming behavior and enhance block chunking logic

- Resolved issue with stuck typing indicator after streamed BlueBubbles replies.
- Refactored `EmbeddedBlockChunker` to streamline fence-split handling and ensure maxChars fallback for newline chunking.
- Added tests to validate new chunking behavior, including handling of paragraph breaks and fence scenarios.
- Updated changelog to reflect these changes.

* test: Add test for clamping long paragraphs in EmbeddedBlockChunker

- Introduced a new test case to verify that long paragraphs are correctly clamped to maxChars when flushOnParagraph is enabled.
- Updated logic in EmbeddedBlockChunker to handle cases where the next paragraph break exceeds maxChars, ensuring proper chunking behavior.

* refactor: streamline logging and improve error handling in message processing

- Removed verbose logging statements from the `processMessage` function to reduce clutter.
- Enhanced error handling by using `runtime.error` for typing restart failures.
- Updated the `applySystemPromptOverrideToSession` function to accept a string directly instead of a function, simplifying the prompt application process.
- Adjusted the `runEmbeddedAttempt` function to directly use the system prompt override without invoking it as a function.
This commit is contained in:
Tyler Yust
2026-02-02 01:22:41 -08:00
committed by GitHub
parent 85cd55e22b
commit 9ef24fd400
14 changed files with 377 additions and 73 deletions

View File

@@ -71,6 +71,81 @@ describe("block reply coalescer", () => {
coalescer.stop();
});
it("flushes each enqueued payload separately when flushOnEnqueue is set", async () => {
const flushes: string[] = [];
const coalescer = createBlockReplyCoalescer({
config: { minChars: 1, maxChars: 200, idleMs: 100, joiner: "\n\n", flushOnEnqueue: true },
shouldAbort: () => false,
onFlush: (payload) => {
flushes.push(payload.text ?? "");
},
});
coalescer.enqueue({ text: "First paragraph" });
coalescer.enqueue({ text: "Second paragraph" });
coalescer.enqueue({ text: "Third paragraph" });
await Promise.resolve();
expect(flushes).toEqual(["First paragraph", "Second paragraph", "Third paragraph"]);
coalescer.stop();
});
it("still accumulates when flushOnEnqueue is not set (default)", async () => {
vi.useFakeTimers();
const flushes: string[] = [];
const coalescer = createBlockReplyCoalescer({
config: { minChars: 1, maxChars: 2000, idleMs: 100, joiner: "\n\n" },
shouldAbort: () => false,
onFlush: (payload) => {
flushes.push(payload.text ?? "");
},
});
coalescer.enqueue({ text: "First paragraph" });
coalescer.enqueue({ text: "Second paragraph" });
await vi.advanceTimersByTimeAsync(100);
expect(flushes).toEqual(["First paragraph\n\nSecond paragraph"]);
coalescer.stop();
});
it("flushes short payloads immediately when flushOnEnqueue is set", async () => {
const flushes: string[] = [];
const coalescer = createBlockReplyCoalescer({
config: { minChars: 10, maxChars: 200, idleMs: 50, joiner: "\n\n", flushOnEnqueue: true },
shouldAbort: () => false,
onFlush: (payload) => {
flushes.push(payload.text ?? "");
},
});
coalescer.enqueue({ text: "Hi" });
await Promise.resolve();
expect(flushes).toEqual(["Hi"]);
coalescer.stop();
});
it("resets char budget per paragraph with flushOnEnqueue", async () => {
const flushes: string[] = [];
const coalescer = createBlockReplyCoalescer({
config: { minChars: 1, maxChars: 30, idleMs: 100, joiner: "\n\n", flushOnEnqueue: true },
shouldAbort: () => false,
onFlush: (payload) => {
flushes.push(payload.text ?? "");
},
});
// Each 20-char payload fits within maxChars=30 individually
coalescer.enqueue({ text: "12345678901234567890" });
coalescer.enqueue({ text: "abcdefghijklmnopqrst" });
await Promise.resolve();
// Without flushOnEnqueue, these would be joined to 40+ chars and trigger maxChars split.
// With flushOnEnqueue, each is sent independently within budget.
expect(flushes).toEqual(["12345678901234567890", "abcdefghijklmnopqrst"]);
coalescer.stop();
});
it("flushes buffered text before media payloads", () => {
const flushes: Array<{ text?: string; mediaUrls?: string[] }> = [];
const coalescer = createBlockReplyCoalescer({