per-block grapheme counts cached (blocks only grow) and in-flight partials bypass the markdown render LRU, removing repeated full Intl.Segmenter walks per 33ms tick and retained stale partial snapshots on long replies.
242 lines
8.2 KiB
TypeScript
242 lines
8.2 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "bun:test";
|
|
import type { AssistantMessage } from "@oh-my-pi/pi-ai";
|
|
import {
|
|
buildDisplayMessage,
|
|
CATCHUP_FRAMES,
|
|
MIN_STEP,
|
|
nextStep,
|
|
STREAMING_REVEAL_FRAME_MS,
|
|
StreamingRevealController,
|
|
visibleUnits,
|
|
} from "@oh-my-pi/pi-coding-agent/modes/controllers/streaming-reveal";
|
|
|
|
function makeUsage(): AssistantMessage["usage"] {
|
|
return {
|
|
input: 0,
|
|
output: 0,
|
|
cacheRead: 0,
|
|
cacheWrite: 0,
|
|
totalTokens: 0,
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
};
|
|
}
|
|
|
|
function makeMessage(content: AssistantMessage["content"]): AssistantMessage {
|
|
return {
|
|
role: "assistant",
|
|
content,
|
|
api: "anthropic-messages",
|
|
provider: "anthropic",
|
|
model: "mock",
|
|
usage: makeUsage(),
|
|
stopReason: "stop",
|
|
timestamp: 0,
|
|
};
|
|
}
|
|
|
|
function textAt(message: AssistantMessage, index: number): string {
|
|
const block = message.content[index];
|
|
if (block?.type !== "text") {
|
|
throw new Error(`Expected text block at index ${index}`);
|
|
}
|
|
return block.text;
|
|
}
|
|
|
|
function thinkingAt(message: AssistantMessage, index: number): string {
|
|
const block = message.content[index];
|
|
if (block?.type !== "thinking") {
|
|
throw new Error(`Expected thinking block at index ${index}`);
|
|
}
|
|
return block.thinking;
|
|
}
|
|
|
|
class RecordingComponent {
|
|
messages: AssistantMessage[] = [];
|
|
|
|
updateContent(message: AssistantMessage): void {
|
|
this.messages.push(message);
|
|
}
|
|
}
|
|
|
|
function latestMessage(component: RecordingComponent): AssistantMessage {
|
|
const message = component.messages.at(-1);
|
|
if (!message) {
|
|
throw new Error("Expected at least one rendered message");
|
|
}
|
|
return message;
|
|
}
|
|
|
|
function makeController(options: { smooth?: boolean; hideThinking?: boolean; requestRender?: () => void } = {}) {
|
|
const component = new RecordingComponent();
|
|
const controller = new StreamingRevealController({
|
|
getSmoothStreaming: () => options.smooth ?? true,
|
|
getHideThinkingBlock: () => options.hideThinking ?? false,
|
|
requestRender: options.requestRender ?? (() => {}),
|
|
});
|
|
return { component, controller };
|
|
}
|
|
|
|
describe("streaming reveal", () => {
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("slices at grapheme boundaries without mutating the target message", () => {
|
|
const familyEmoji = "👨👩👧👦";
|
|
const target = makeMessage([{ type: "text", text: `${familyEmoji}B` }]);
|
|
|
|
expect(visibleUnits(target, false)).toBe(2);
|
|
const display = buildDisplayMessage(target, 1, false);
|
|
|
|
expect(textAt(display, 0)).toBe(familyEmoji);
|
|
expect(textAt(target, 0)).toBe(`${familyEmoji}B`);
|
|
});
|
|
|
|
it("excludes hidden thinking from the reveal budget and passes it through", () => {
|
|
const thinkingBlock = { type: "thinking" as const, thinking: "thought" };
|
|
const target = makeMessage([thinkingBlock, { type: "text", text: "answer" }]);
|
|
|
|
expect(visibleUnits(target, true)).toBe("answer".length);
|
|
const display = buildDisplayMessage(target, 1, true);
|
|
|
|
expect(display.content[0]).toBe(thinkingBlock);
|
|
expect(thinkingAt(display, 0)).toBe("thought");
|
|
expect(textAt(display, 1)).toBe("a");
|
|
});
|
|
|
|
it("smooths thinking content when thinking is shown", () => {
|
|
const target = makeMessage([
|
|
{ type: "thinking", thinking: "thought" },
|
|
{ type: "text", text: "answer" },
|
|
]);
|
|
|
|
expect(visibleUnits(target, false)).toBe("thoughtanswer".length);
|
|
const display = buildDisplayMessage(target, 3, false);
|
|
|
|
expect(thinkingAt(display, 0)).toBe("tho");
|
|
expect(textAt(display, 1)).toBe("");
|
|
});
|
|
|
|
it("uses an adaptive catchup step with the configured floor", () => {
|
|
const largeBacklog = CATCHUP_FRAMES * 101;
|
|
const step = nextStep(largeBacklog);
|
|
|
|
expect(step).toBe(101);
|
|
expect(step * CATCHUP_FRAMES).toBeGreaterThanOrEqual(largeBacklog);
|
|
expect(nextStep(1)).toBe(MIN_STEP);
|
|
expect(nextStep(MIN_STEP * CATCHUP_FRAMES)).toBe(MIN_STEP);
|
|
});
|
|
|
|
it("reveals cumulative targets to the exact final text with monotonic prefixes", () => {
|
|
vi.useFakeTimers();
|
|
const { component, controller } = makeController();
|
|
const first = makeMessage([{ type: "text", text: "Hello" }]);
|
|
const second = makeMessage([{ type: "text", text: "Hello world" }]);
|
|
|
|
controller.begin(component, makeMessage([{ type: "text", text: "" }]));
|
|
controller.setTarget(first);
|
|
for (let i = 0; i < 4; i++) {
|
|
vi.advanceTimersByTime(STREAMING_REVEAL_FRAME_MS);
|
|
}
|
|
controller.setTarget(second);
|
|
for (let i = 0; i < 4; i++) {
|
|
vi.advanceTimersByTime(STREAMING_REVEAL_FRAME_MS);
|
|
}
|
|
|
|
const renderedTexts = component.messages.map(message => textAt(message, 0));
|
|
expect(renderedTexts.at(-1)).toBe("Hello world");
|
|
for (let i = 1; i < renderedTexts.length; i++) {
|
|
expect(renderedTexts[i].length).toBeGreaterThanOrEqual(renderedTexts[i - 1].length);
|
|
expect("Hello world".startsWith(renderedTexts[i])).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("keeps grapheme counts correct when an append extends the final cluster", () => {
|
|
vi.useFakeTimers();
|
|
const { component, controller } = makeController();
|
|
|
|
controller.begin(component, makeMessage([{ type: "text", text: "" }]));
|
|
controller.setTarget(makeMessage([{ type: "text", text: "ab👨" }]));
|
|
vi.advanceTimersByTime(STREAMING_REVEAL_FRAME_MS);
|
|
// The appended ZWJ sequence merges into the previous final grapheme:
|
|
// "👨" + "\u200D👩" becomes a single cluster, so the cached per-block
|
|
// count must re-segment from that cluster, not just add the suffix.
|
|
controller.setTarget(makeMessage([{ type: "text", text: "ab👨\u200D👩x" }]));
|
|
for (let i = 0; i < 6; i++) {
|
|
vi.advanceTimersByTime(STREAMING_REVEAL_FRAME_MS);
|
|
}
|
|
|
|
expect(textAt(latestMessage(component), 0)).toBe("ab👨\u200D👩x");
|
|
});
|
|
|
|
it("renders full targets immediately when smoothing is disabled", () => {
|
|
vi.useFakeTimers();
|
|
const requestRender = vi.fn();
|
|
const { component, controller } = makeController({ smooth: false, requestRender });
|
|
|
|
controller.begin(component, makeMessage([{ type: "text", text: "chunk" }]));
|
|
controller.setTarget(makeMessage([{ type: "text", text: "chunky" }]));
|
|
const updates = component.messages.length;
|
|
vi.advanceTimersByTime(STREAMING_REVEAL_FRAME_MS * 10);
|
|
|
|
expect(textAt(latestMessage(component), 0)).toBe("chunky");
|
|
expect(component.messages).toHaveLength(updates);
|
|
expect(requestRender).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("ticks increasing prefixes at the render cadence", () => {
|
|
vi.useFakeTimers();
|
|
const requestRender = vi.fn();
|
|
const { component, controller } = makeController({ requestRender });
|
|
|
|
controller.begin(component, makeMessage([{ type: "text", text: "" }]));
|
|
controller.setTarget(makeMessage([{ type: "text", text: "abcdefghi" }]));
|
|
|
|
vi.advanceTimersByTime(STREAMING_REVEAL_FRAME_MS);
|
|
expect(textAt(latestMessage(component), 0)).toBe("abc");
|
|
vi.advanceTimersByTime(STREAMING_REVEAL_FRAME_MS);
|
|
expect(textAt(latestMessage(component), 0)).toBe("abcdef");
|
|
expect(requestRender).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("stop halts pending ticker updates", () => {
|
|
vi.useFakeTimers();
|
|
const { component, controller } = makeController();
|
|
|
|
controller.begin(component, makeMessage([{ type: "text", text: "" }]));
|
|
controller.setTarget(makeMessage([{ type: "text", text: "abcdefghi" }]));
|
|
vi.advanceTimersByTime(STREAMING_REVEAL_FRAME_MS);
|
|
controller.stop();
|
|
const updates = component.messages.length;
|
|
const lastText = textAt(latestMessage(component), 0);
|
|
vi.advanceTimersByTime(STREAMING_REVEAL_FRAME_MS * 10);
|
|
|
|
expect(component.messages).toHaveLength(updates);
|
|
expect(textAt(latestMessage(component), 0)).toBe(lastText);
|
|
});
|
|
|
|
it("snaps to full text when a tool call arrives", () => {
|
|
vi.useFakeTimers();
|
|
const requestRender = vi.fn();
|
|
const { component, controller } = makeController({ requestRender });
|
|
|
|
controller.begin(component, makeMessage([{ type: "text", text: "" }]));
|
|
controller.setTarget(makeMessage([{ type: "text", text: "abcdefghi" }]));
|
|
vi.advanceTimersByTime(STREAMING_REVEAL_FRAME_MS);
|
|
expect(textAt(latestMessage(component), 0)).toBe("abc");
|
|
|
|
controller.setTarget(
|
|
makeMessage([
|
|
{ type: "text", text: "abcdefghi" },
|
|
{ type: "toolCall", id: "call-1", name: "read", arguments: { path: "README.md" } },
|
|
]),
|
|
);
|
|
const updates = component.messages.length;
|
|
vi.advanceTimersByTime(STREAMING_REVEAL_FRAME_MS * 10);
|
|
|
|
expect(textAt(latestMessage(component), 0)).toBe("abcdefghi");
|
|
expect(component.messages).toHaveLength(updates);
|
|
expect(requestRender).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|