FN-5803: normalize streamed sentence spacing across message boundaries

Ensure streamed agent text preserves sentence-boundary spacing even when providers split output across message boundaries.

- add stateful streaming delta normalizer that tracks prior text/thinking tail when partial blocks reset
- update executor and PI session subscriptions to use per-session normalizer instances for text_delta and thinking_delta events
- expand streaming-delta tests to cover cross-message/tool-call boundary spacing regressions
- add task notes documenting cross-message spacing refinement

Files changed:
 .../fn-5803-streaming-cross-message-space.md       |  5 ++
 .../engine/src/__tests__/streaming-delta.test.ts   | 86 +++++++++++++++++++++-
 packages/engine/src/executor.ts                    | 13 ++--
 packages/engine/src/pi.ts                          | 24 +++---
 packages/engine/src/streaming-delta.ts             | 44 ++++++++++-
 5 files changed, 155 insertions(+), 17 deletions(-)

Fusion-Task-Id: FN-5803

Fusion-Task-Lineage: 8f88d54c-bbc0-44dd-99fc-c69738ce0ca8
This commit is contained in:
gsxdsm
2026-05-31 23:24:53 -07:00
parent a44b6102e3
commit feceedbf7e
5 changed files with 155 additions and 17 deletions

View File

@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { normalizeStreamingDelta, normalizeStreamingDeltaFromEvent } from "../streaming-delta.js";
import {
createStreamingDeltaNormalizer,
normalizeStreamingDelta,
normalizeStreamingDeltaFromEvent,
} from "../streaming-delta.js";
describe("normalizeStreamingDelta", () => {
it("repairs period + uppercase sentence boundaries across deltas", () => {
@@ -82,3 +86,83 @@ describe("normalizeStreamingDeltaFromEvent", () => {
).toBe(" Foundation");
});
});
describe("createStreamingDeltaNormalizer", () => {
it("repairs punctuation boundaries across separate assistant messages", () => {
const normalizer = createStreamingDeltaNormalizer();
normalizer.normalize(
{ content: [{ type: "text", text: "create the foundation task." }] },
0,
"create the foundation task.",
"text",
);
expect(
normalizer.normalize({ content: [{ type: "text", text: "Foundation" }] }, 0, "Foundation", "text"),
).toBe(" Foundation");
normalizer.normalize({ content: [{ type: "text", text: "dependent tasks." }] }, 0, "dependent tasks.", "text");
expect(normalizer.normalize({ content: [{ type: "text", text: "Let me add" }] }, 0, "Let me add", "text"))
.toBe(" Let me add");
normalizer.normalize({ content: [{ type: "text", text: "render." }] }, 0, "render.", "text");
expect(normalizer.normalize({ content: [{ type: "text", text: "Done. Filed 5" }] }, 0, "Done. Filed 5", "text"))
.toBe(" Done. Filed 5");
});
it("preserves same-message behavior and lower-case/property continuations", () => {
const normalizer = createStreamingDeltaNormalizer();
expect(
normalizer.normalize(
{
content: [
{ type: "text", text: "task." },
{ type: "text", text: "" },
],
},
1,
"Let us continue.",
"text",
),
).toBe(" Let us continue.");
expect(normalizer.normalize({ content: [{ type: "text", text: "obj.prop" }] }, 0, ".prop", "text")).toBe(".prop");
expect(normalizer.normalize({ content: [{ type: "text", text: "foo.bar" }] }, 0, "bar", "text")).toBe("bar");
});
it("is idempotent when incoming deltas already start with whitespace", () => {
const normalizer = createStreamingDeltaNormalizer();
normalizer.normalize({ content: [{ type: "text", text: "...task." }] }, 0, "...task.", "text");
expect(normalizer.normalize({ content: [{ type: "text", text: " Foundation" }] }, 0, " Foundation", "text"))
.toBe(" Foundation");
});
it("does not leak tails across text/thinking kinds", () => {
const thinkingFirst = createStreamingDeltaNormalizer();
thinkingFirst.normalize({ content: [{ type: "thinking", thinking: "reason." }] }, 0, "reason.", "thinking");
expect(thinkingFirst.normalize({ content: [{ type: "text", text: "Foundation" }] }, 0, "Foundation", "text"))
.toBe("Foundation");
const textFirst = createStreamingDeltaNormalizer();
textFirst.normalize({ content: [{ type: "text", text: "task." }] }, 0, "task.", "text");
expect(textFirst.normalize({ content: [{ type: "thinking", thinking: "Done" }] }, 0, "Done", "thinking"))
.toBe("Done");
});
it("starts fresh per instance", () => {
const normalizer = createStreamingDeltaNormalizer();
expect(normalizer.normalize(undefined, 0, "Foundation", "text")).toBe("Foundation");
});
it("is defensive for invalid partial/content index and wrong block type", () => {
const normalizer = createStreamingDeltaNormalizer();
expect(normalizer.normalize(undefined, 0, "Foundation", "text")).toBe("Foundation");
expect(normalizer.normalize({ content: [{ type: "text", text: "execution" }] }, 8, "Foundation", "text"))
.toBe("Foundation");
expect(normalizer.normalize({ content: [{ type: "thinking", thinking: "execution." }] }, 0, "Foundation", "text"))
.toBe("Foundation");
normalizer.normalize({ content: [{ type: "text", text: "task." }] }, 0, "task.", "text");
expect(normalizer.normalize(undefined, Number.NaN, "Foundation", "text")).toBe(" Foundation");
});
});