FN-5787: normalize streamed sentence spacing in event bridge

Normalize missing sentence spaces in streamed droid deltas for chat and agent logs.

- add targeted delta normalization for punctuation-to-sentence-start boundaries
- apply normalization to both text and thinking streaming deltas, including cross-block fallback context
- add regression tests covering repaired boundaries and non-regression cases (lowercase/property access/existing spaces)

Files changed:
 plugins/fusion-plugin-droid-runtime/src/__tests__/event-bridge.test.ts | 118 +++++++++++++++++++++
 plugins/fusion-plugin-droid-runtime/src/event-bridge.ts                |  57 +++++++++-
 2 files changed, 171 insertions(+), 4 deletions(-)

Fusion-Task-Id: FN-5787

Fusion-Task-Lineage: 21a91f46-2269-4406-bce6-b19a57f445e0
This commit is contained in:
gsxdsm
2026-05-31 11:14:53 -07:00
parent fca6e7f7db
commit 53e0741aca
2 changed files with 171 additions and 4 deletions

View File

@@ -60,6 +60,29 @@ function mapStopReason(
}
}
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;
}
/**
* Create an event bridge that translates Claude API streaming events
* into pi's AssistantMessageEventStream events.
@@ -98,6 +121,19 @@ export function createEventBridge(
let started = false;
function getPreviousContentText(contentIndex: number, type: "text" | "thinking"): string {
for (let i = contentIndex - 1; i >= 0; i--) {
const contentBlock = output.content[i];
if (type === "text" && contentBlock?.type === "text") {
return contentBlock.text;
}
if (type === "thinking" && contentBlock?.type === "thinking") {
return contentBlock.thinking;
}
}
return "";
}
function handleEvent(event: ClaudeApiEvent): void {
// Emit start event on first message — tells pi to begin incremental rendering
if (!started) {
@@ -226,14 +262,23 @@ export function createEventBridge(
const block = blocks[idx];
if (block.type === "text") {
block.text += event.delta!.text;
// Downstream consumers concatenate these deltas verbatim:
// provider API events -> event-bridge text_delta/thinking_delta ->
// engine pi.ts options.onText/onThinking(delta) -> chat streaming +
// agent-logger.onText buffer. Normalizing here fixes dropped sentence
// spaces once for both chat and agent logs.
const delta = normalizeStreamingDelta(
block.text || getPreviousContentText(idx, "text"),
event.delta!.text,
);
block.text += delta;
const contentBlock = output.content[idx] as TextContent;
contentBlock.text = block.text;
stream.push({
type: "text_delta",
contentIndex: idx,
delta: event.delta!.text,
delta,
partial: output,
});
}
@@ -246,14 +291,18 @@ export function createEventBridge(
const block = blocks[idx];
if (block.type === "thinking") {
block.text += event.delta!.thinking;
const delta = normalizeStreamingDelta(
block.text || getPreviousContentText(idx, "thinking"),
event.delta!.thinking,
);
block.text += delta;
const contentBlock = output.content[idx] as ThinkingContent;
contentBlock.thinking = block.text;
stream.push({
type: "thinking_delta",
contentIndex: idx,
delta: event.delta!.thinking,
delta,
partial: output,
});
}