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:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user