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:
@@ -0,0 +1,118 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@earendil-works/pi-ai", () => ({
|
||||
calculateCost: vi.fn(),
|
||||
}));
|
||||
|
||||
import { createEventBridge } from "../event-bridge.js";
|
||||
|
||||
function createMockStream() {
|
||||
const events: unknown[] = [];
|
||||
return {
|
||||
push: vi.fn((event: unknown) => events.push(event)),
|
||||
end: vi.fn(),
|
||||
events,
|
||||
};
|
||||
}
|
||||
|
||||
function createMockModel() {
|
||||
return {
|
||||
id: "droid-pro",
|
||||
name: "Droid Pro",
|
||||
api: "droid-cli",
|
||||
provider: "droid-cli",
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 200000,
|
||||
maxTokens: 8192,
|
||||
};
|
||||
}
|
||||
|
||||
describe("droid event bridge streaming delta normalization", () => {
|
||||
let stream: ReturnType<typeof createMockStream>;
|
||||
let model: ReturnType<typeof createMockModel>;
|
||||
|
||||
beforeEach(() => {
|
||||
stream = createMockStream();
|
||||
model = createMockModel();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function createBridgeWithStart() {
|
||||
const bridge = createEventBridge(stream as any, model as any);
|
||||
bridge.handleEvent({ type: "message_start", message: { usage: {} } } as any);
|
||||
stream.push.mockClear();
|
||||
stream.events.length = 0;
|
||||
return bridge;
|
||||
}
|
||||
|
||||
it("repairs a missing sentence boundary across text deltas", () => {
|
||||
const bridge = createBridgeWithStart();
|
||||
|
||||
bridge.handleEvent({ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } } as any);
|
||||
bridge.handleEvent({ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "compare them." } } as any);
|
||||
bridge.handleEvent({ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Good overview." } } as any);
|
||||
|
||||
const output = bridge.getOutput();
|
||||
expect((output.content[0] as any).text).toBe("compare them. Good overview.");
|
||||
expect(stream.events[2]).toEqual(expect.objectContaining({ type: "text_delta", delta: " Good overview." }));
|
||||
});
|
||||
|
||||
it("repairs a missing sentence boundary between consecutive text blocks", () => {
|
||||
const bridge = createBridgeWithStart();
|
||||
|
||||
bridge.handleEvent({ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } } as any);
|
||||
bridge.handleEvent({ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "compare them." } } as any);
|
||||
bridge.handleEvent({ type: "content_block_stop", index: 0 } as any);
|
||||
bridge.handleEvent({ type: "content_block_start", index: 1, content_block: { type: "text", text: "" } } as any);
|
||||
bridge.handleEvent({ type: "content_block_delta", index: 1, delta: { type: "text_delta", text: "Good overview." } } as any);
|
||||
|
||||
const output = bridge.getOutput();
|
||||
const combinedText = output.content
|
||||
.filter((content): content is any => content.type === "text")
|
||||
.map((content: any) => content.text)
|
||||
.join("");
|
||||
|
||||
expect(combinedText).toBe("compare them. Good overview.");
|
||||
expect(stream.events[4]).toEqual(expect.objectContaining({ type: "text_delta", contentIndex: 1, delta: " Good overview." }));
|
||||
});
|
||||
|
||||
it("repairs a missing sentence boundary between thinking deltas", () => {
|
||||
const bridge = createBridgeWithStart();
|
||||
|
||||
bridge.handleEvent({ type: "content_block_start", index: 0, content_block: { type: "thinking", thinking: "" } } as any);
|
||||
bridge.handleEvent({ type: "content_block_delta", index: 0, delta: { type: "thinking_delta", thinking: "task." } } as any);
|
||||
bridge.handleEvent({ type: "content_block_delta", index: 0, delta: { type: "thinking_delta", thinking: "Let us continue." } } as any);
|
||||
|
||||
const output = bridge.getOutput();
|
||||
expect((output.content[0] as any).thinking).toBe("task. Let us continue.");
|
||||
expect(stream.events[2]).toEqual(expect.objectContaining({ type: "thinking_delta", delta: " Let us continue." }));
|
||||
});
|
||||
|
||||
it("does not insert spaces into lowercase continuations or property access", () => {
|
||||
const bridge = createBridgeWithStart();
|
||||
|
||||
bridge.handleEvent({ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } } as any);
|
||||
bridge.handleEvent({ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "obj" } } as any);
|
||||
bridge.handleEvent({ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: ".prop" } } as any);
|
||||
bridge.handleEvent({ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: " foo." } } as any);
|
||||
bridge.handleEvent({ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "bar" } } as any);
|
||||
|
||||
const output = bridge.getOutput();
|
||||
expect((output.content[0] as any).text).toBe("obj.prop foo.bar");
|
||||
expect(stream.events[2]).toEqual(expect.objectContaining({ type: "text_delta", delta: ".prop" }));
|
||||
expect(stream.events[4]).toEqual(expect.objectContaining({ type: "text_delta", delta: "bar" }));
|
||||
});
|
||||
|
||||
it("does not double-insert when space already exists at boundary", () => {
|
||||
const bridge = createBridgeWithStart();
|
||||
|
||||
bridge.handleEvent({ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } } as any);
|
||||
bridge.handleEvent({ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "render." } } as any);
|
||||
bridge.handleEvent({ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: " Done" } } as any);
|
||||
bridge.handleEvent({ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: " Next." } } as any);
|
||||
|
||||
const output = bridge.getOutput();
|
||||
expect((output.content[0] as any).text).toBe("render. Done Next.");
|
||||
expect(stream.events[2]).toEqual(expect.objectContaining({ type: "text_delta", delta: " Done" }));
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user