Files
fusion/packages/engine/src/streaming-delta.ts
gsxdsm feceedbf7e 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
2026-05-31 23:24:53 -07:00

132 lines
3.6 KiB
TypeScript

type StreamingContentBlock = {
type?: string;
text?: string;
thinking?: string;
};
type StreamingPartialMessage = {
content?: StreamingContentBlock[];
};
export function normalizeStreamingDelta(previousText: string, nextDelta: string): string {
if (!previousText || !nextDelta) {
return nextDelta;
}
const previousChar = previousText.slice(-1);
const nextChar = nextDelta[0] ?? "";
if (/\s/.test(previousChar) || /\s/.test(nextChar)) {
return nextDelta;
}
// Claude sometimes splits adjacent sentences across separate deltas or text
// blocks without preserving the separating space. Only repair the specific
// "sentence punctuation + uppercase/quoted sentence start" case so code,
// domains, and lowercase continuations remain untouched.
if (/[.!?]/.test(previousChar) && /[A-Z0-9"'([]/.test(nextChar)) {
return ` ${nextDelta}`;
}
return nextDelta;
}
function getContentText(block: StreamingContentBlock | undefined, kind: "text" | "thinking"): string {
if (!block || block.type !== kind) {
return "";
}
if (kind === "text") {
return typeof block.text === "string" ? block.text : "";
}
return typeof block.thinking === "string" ? block.thinking : "";
}
function derivePreviousText(accumulatedText: string, delta: string): string {
if (!accumulatedText || !delta) {
return accumulatedText;
}
return accumulatedText.endsWith(delta)
? accumulatedText.slice(0, Math.max(0, accumulatedText.length - delta.length))
: accumulatedText;
}
function findPreviousBlockText(
partial: StreamingPartialMessage,
contentIndex: number,
kind: "text" | "thinking",
): string {
const content = partial.content;
if (!Array.isArray(content)) {
return "";
}
for (let i = contentIndex - 1; i >= 0; i--) {
const text = getContentText(content[i], kind);
if (text) {
return text;
}
}
return "";
}
function derivePreviousTextFromEvent(
partial: StreamingPartialMessage | undefined,
contentIndex: number,
delta: string,
kind: "text" | "thinking",
): string {
const content = partial?.content;
const block = Array.isArray(content) && Number.isInteger(contentIndex) && contentIndex >= 0
? content[contentIndex]
: undefined;
const accumulatedText = getContentText(block, kind);
let previousText = derivePreviousText(accumulatedText, delta);
if (!previousText && partial && Number.isInteger(contentIndex) && contentIndex > 0) {
previousText = findPreviousBlockText(partial, contentIndex, kind);
}
return previousText;
}
export function createStreamingDeltaNormalizer(): {
normalize: (
partial: StreamingPartialMessage | undefined,
contentIndex: number,
delta: string,
kind: "text" | "thinking",
) => string;
} {
let lastTextTail = "";
let lastThinkingTail = "";
return {
normalize(partial, contentIndex, delta, kind) {
const derivedPreviousText = derivePreviousTextFromEvent(partial, contentIndex, delta, kind);
const previousText = derivedPreviousText || (kind === "text" ? lastTextTail : lastThinkingTail);
const result = normalizeStreamingDelta(previousText, delta);
if (result) {
const tail = result.slice(-1);
if (kind === "text") {
lastTextTail = tail;
} else {
lastThinkingTail = tail;
}
}
return result;
},
};
}
export function normalizeStreamingDeltaFromEvent(
partial: StreamingPartialMessage | undefined,
contentIndex: number,
delta: string,
kind: "text" | "thinking",
): string {
return normalizeStreamingDelta(derivePreviousTextFromEvent(partial, contentIndex, delta, kind), delta);
}