mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-30 05:02:58 +00:00
* Stream assistant text at a steady rate instead of in arrival lumps Assistant text arrived in lumps and was painted that way. The daemon coalesces one message per 60ms window, and that window carries however many characters the model produced in it, which swings by an order of magnitude within a turn. Painting each delta as it landed made the size of those lumps visible. Three changes: - The agent stream coalescer is now leading + trailing. The first delta after an idle window flushes synchronously instead of waiting a full window, matching the invariant TerminalOutputCoalescer already documents. The window stays at 60ms; shrinking it would raise message rate on a loop that already contends with terminal frames and relay encryption, without making the stream smoother. - The client reducer queue commits on a frame instead of an arbitrary 48ms timer, with the timer kept as a ceiling so a hidden tab still advances. - Assistant and reasoning text are revealed at a rate derived from the backlog rather than from arrival timing. Arrival changes the target, not the rate. The store keeps the full text and only the rendered slice is paced, so copy, selection, the chat outline, and scroll geometry all stay consistent with what is on screen. The leading flush means a burst's first chunk lands as its own timeline row. The projection already merges contiguous same-turn rows and clients read the projected timeline, so history is unchanged; the server tests that assert on raw rows were updated for the extra row. Measured on Expo web against a local dev daemon. Mock bursty-stream provider, 6s samples, paint-on-arrival vs paced: frames that advanced the text 55 -> 244, chars-per-frame CV 2.66 -> 1.44, gap p50 100ms -> 17ms. Real Claude Haiku agent over 52s: 84% of frames advance the text, CV 1.29, gap p50 and p95 both 17ms (one frame at 60Hz). Adds a bursty-stream mock model with seeded burst sizes, a gated browser perf spec, and docs/agent-stream-performance.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Address review: safe reveal boundaries and a testable seam Two findings from the PR review. Unicode boundaries. Slicing the revealed text at an arbitrary index could strand a lone surrogate, which paints as a replacement glyph, or split an emoji ZWJ sequence so a family briefly rendered as its members. Both were visible for a frame or two mid-stream — the exact flicker the paced reveal exists to remove. The rendered cut is now pulled back off surrogate pairs, combining marks, variation selectors, skin tone modifiers, and ZWJ joins. The reveal counter stays monotonic and only the painted boundary moves, so a long cluster cannot stall the reveal. Test placement. The hook test used JSDOM, @testing-library renderHook, and monkey-patched requestAnimationFrame, all of which docs/testing.md rules out; it prescribes fixing the missing seam instead. The reveal policy is now a pure state machine in text-reveal.ts (begin/retarget/advance/ complete/visible) covered by plain unit tests with no environment, and the hook is reduced to the frame-clock binding. Rendered behavior is left to the browser spec, which no longer carries frame sampling and statistics in its body — those moved to e2e/support/helpers/stream-smoothness.ts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Measure total painted text, not the tail message The smoothness sampler read the length of the last assistant-message element. A real turn emits many assistant messages, so that element keeps changing identity and its length is not monotonic — handovers read as resets, and a stream that was plainly moving on screen reported almost no growth at all. Sampling the total across every assistant message fixes it. This invalidated the numbers previously recorded for the real-agent run, so the doc table is replaced with a fresh A/B taken with the corrected sampler against a Claude Haiku agent: frames that advanced the text 6% -> 87%, chars-per-frame CV 4.11 -> 1.86, gap p50 317ms -> 17ms, p95 383ms -> 17ms. The paced column also reported zero Unicode defects across a stream containing emoji, which exercises the reveal boundary clamp end to end. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * perf(agent-stream): harden paced text reveal Use standards-compliant grapheme segmentation when available and preserve full arrivals on older runtimes. Cap reveal commits at 60Hz and measure the real browser rendering path so high-refresh displays stay smooth without rendering at hardware frame rate. * Hold back trailing fragments split by arrival boundaries The reveal boundary is not the only place a grapheme gets cut. The daemon's coalescing window ends a delta wherever the window closes, so a caught-up reveal can be handed half a cluster and paint it. Measured on a mixed-script Haiku stream: five frames in ~350 ending in a single regional indicator, i.e. half a flag on screen for a frame. While the turn is streaming, a trailing fragment that cannot stand alone — a dangling high surrogate, a trailing joiner, an odd regional indicator — now waits for the rest of its cluster. Once the turn ends everything received is painted, because nothing more is coming. Complements the grapheme-boundary clamp, which handles the reveal cut; this handles the arrival cut. The same stream now measures one such frame in ~400 rather than five. The remainder is a flush boundary that coincides with the end of an assistant message, putting the two halves in different messages, which the client cannot rejoin — that one belongs in the coalescer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
216 lines
7.8 KiB
TypeScript
216 lines
7.8 KiB
TypeScript
import type { Page } from "@playwright/test";
|
|
|
|
/**
|
|
* Frame-level sampling of the streaming assistant message, and the two numbers
|
|
* that describe how it reads:
|
|
*
|
|
* - `charsPerFrameCv` — coefficient of variation of characters painted per frame.
|
|
* This is the smoothness metric. Painting deltas as they land tracks the
|
|
* arrival lumps, so it is high; pacing the reveal flattens it.
|
|
* - `updateGapP95Ms` — how long the text can sit unchanged mid-stream. This is
|
|
* the stall metric, and it is what stops smoothness being bought with lag.
|
|
*
|
|
* Both are needed. A stream that never updates is perfectly smooth.
|
|
*/
|
|
|
|
export interface StreamFrameSample {
|
|
timestampMs: number;
|
|
growthChars: number;
|
|
firstPaintChars: number;
|
|
}
|
|
|
|
export interface StreamSmoothnessReport {
|
|
sampleWindowMs: number;
|
|
frames: number;
|
|
growthFrames: number;
|
|
charsPainted: number;
|
|
meanCharsPerFrame: number;
|
|
maxCharsInOneFrame: number;
|
|
charsPerFrameCv: number;
|
|
firstPaintFrames: number;
|
|
firstPaintChars: number;
|
|
maxFirstPaintCharsInOneFrame: number;
|
|
handoverGrowthFrames: number;
|
|
handoverGrowthChars: number;
|
|
maxHandoverGrowthCharsInOneFrame: number;
|
|
updateGapP50Ms: number;
|
|
updateGapP95Ms: number;
|
|
updateGapMaxMs: number;
|
|
}
|
|
|
|
/**
|
|
* Record text growth on every mounted assistant message once per frame.
|
|
*
|
|
* Message occurrence identity separates paced growth from first paint. Completed markdown
|
|
* blocks move into canonical timeline rows while a turn streams, remounting text
|
|
* that was already visible. Counting the new node's full length as growth would
|
|
* report that handover as a large reveal even though no text appeared on screen.
|
|
* First paints stay in the report as a separate diagnostic.
|
|
*
|
|
* Runs entirely in the page so the sampling clock is the same clock the reveal
|
|
* runs on. Waits for the text to start moving first, so a slow model's
|
|
* time-to-first-token is not counted as a stall.
|
|
*/
|
|
export async function sampleStreamFrames(
|
|
page: Page,
|
|
options: { sampleWindowMs: number; startTimeoutMs?: number },
|
|
): Promise<StreamFrameSample[]> {
|
|
const startTimeoutMs = options.startTimeoutMs ?? 60_000;
|
|
return await page.evaluate(
|
|
async ({ sampleWindowMs, startTimeoutMs: waitMs }) => {
|
|
const readLength = () => {
|
|
let total = 0;
|
|
for (const node of document.querySelectorAll('[data-testid="assistant-message"]')) {
|
|
total += node.textContent?.length ?? 0;
|
|
}
|
|
return total;
|
|
};
|
|
|
|
const waitStart = performance.now();
|
|
const base = readLength();
|
|
await new Promise<void>((resolve) => {
|
|
const poll = () => {
|
|
if (readLength() > base + 20 || performance.now() - waitStart > waitMs) {
|
|
resolve();
|
|
return;
|
|
}
|
|
requestAnimationFrame(poll);
|
|
};
|
|
requestAnimationFrame(poll);
|
|
});
|
|
|
|
let previousLengths = new Map<string, number>();
|
|
for (const node of document.querySelectorAll('[data-testid="assistant-message"]')) {
|
|
const key = node.getAttribute("data-reveal-key");
|
|
const length = Number(node.getAttribute("data-reveal-length"));
|
|
if (key !== null && Number.isFinite(length)) {
|
|
previousLengths.set(key, length);
|
|
}
|
|
}
|
|
|
|
const samples: Array<{
|
|
timestampMs: number;
|
|
growthChars: number;
|
|
firstPaintChars: number;
|
|
}> = [];
|
|
await new Promise<void>((resolve) => {
|
|
const start = performance.now();
|
|
const tick = (now: number) => {
|
|
const currentLengths = new Map<string, number>();
|
|
let growthChars = 0;
|
|
let firstPaintChars = 0;
|
|
for (const node of document.querySelectorAll('[data-testid="assistant-message"]')) {
|
|
const key = node.getAttribute("data-reveal-key");
|
|
const currentLength = Number(node.getAttribute("data-reveal-length"));
|
|
if (key === null || !Number.isFinite(currentLength)) {
|
|
continue;
|
|
}
|
|
const previousLength = previousLengths.get(key);
|
|
if (previousLength === undefined || currentLength < previousLength) {
|
|
firstPaintChars += currentLength;
|
|
} else {
|
|
const nodeGrowth = currentLength - previousLength;
|
|
growthChars += nodeGrowth;
|
|
}
|
|
currentLengths.set(key, currentLength);
|
|
}
|
|
samples.push({
|
|
timestampMs: now,
|
|
growthChars,
|
|
firstPaintChars,
|
|
});
|
|
previousLengths = currentLengths;
|
|
if (now - start >= sampleWindowMs) {
|
|
resolve();
|
|
return;
|
|
}
|
|
requestAnimationFrame(tick);
|
|
};
|
|
requestAnimationFrame(tick);
|
|
});
|
|
return samples;
|
|
},
|
|
{ sampleWindowMs: options.sampleWindowMs, startTimeoutMs },
|
|
);
|
|
}
|
|
|
|
export function computePercentile(samples: readonly number[], percentile: number): number {
|
|
if (samples.length === 0) {
|
|
return 0;
|
|
}
|
|
const sorted = [...samples].sort((a, b) => a - b);
|
|
return sorted[Math.max(0, Math.ceil((percentile / 100) * sorted.length) - 1)];
|
|
}
|
|
|
|
function round2(value: number): number {
|
|
return Math.round(value * 100) / 100;
|
|
}
|
|
|
|
/**
|
|
* Reduce frame samples to the report. Frames outside the streaming window are
|
|
* dropped: leading idle is time-to-first-token, and trailing idle means the turn
|
|
* ended mid-sample. First-paint frames are structural handovers. Existing blocks
|
|
* may also snap complete on those frames, so their growth is reported separately
|
|
* from the paced-growth CV.
|
|
*/
|
|
export function summarizeStreamSmoothness(
|
|
samples: readonly StreamFrameSample[],
|
|
sampleWindowMs: number,
|
|
): StreamSmoothnessReport {
|
|
const deltas: number[] = [];
|
|
const gapsMs: number[] = [];
|
|
let lastUpdateAtMs: number | null = null;
|
|
let firstGrowth = -1;
|
|
let lastGrowth = -1;
|
|
const handoverGrowths: number[] = [];
|
|
|
|
for (let index = 0; index < samples.length; index += 1) {
|
|
const sample = samples[index];
|
|
const isHandover = sample.firstPaintChars > 0;
|
|
const growth = isHandover ? 0 : sample.growthChars;
|
|
if (isHandover && sample.growthChars > 0) {
|
|
handoverGrowths.push(sample.growthChars);
|
|
lastUpdateAtMs = sample.timestampMs;
|
|
}
|
|
deltas.push(growth);
|
|
if (growth > 0) {
|
|
if (firstGrowth === -1) {
|
|
firstGrowth = deltas.length - 1;
|
|
}
|
|
lastGrowth = deltas.length - 1;
|
|
if (lastUpdateAtMs !== null) {
|
|
gapsMs.push(samples[index].timestampMs - lastUpdateAtMs);
|
|
}
|
|
lastUpdateAtMs = samples[index].timestampMs;
|
|
}
|
|
}
|
|
|
|
const active = firstGrowth === -1 ? [] : deltas.slice(firstGrowth, lastGrowth + 1);
|
|
const charsPainted = active.reduce((sum, delta) => sum + delta, 0);
|
|
const mean = active.length > 0 ? charsPainted / active.length : 0;
|
|
const variance =
|
|
active.length > 0
|
|
? active.reduce((sum, delta) => sum + (delta - mean) ** 2, 0) / active.length
|
|
: 0;
|
|
const firstPaints = samples.map((sample) => sample.firstPaintChars).filter((chars) => chars > 0);
|
|
|
|
return {
|
|
sampleWindowMs,
|
|
frames: active.length,
|
|
growthFrames: active.filter((delta) => delta > 0).length,
|
|
charsPainted,
|
|
meanCharsPerFrame: round2(mean),
|
|
maxCharsInOneFrame: active.length > 0 ? Math.max(...active) : 0,
|
|
charsPerFrameCv: mean === 0 ? Number.POSITIVE_INFINITY : round2(Math.sqrt(variance) / mean),
|
|
firstPaintFrames: firstPaints.length,
|
|
firstPaintChars: firstPaints.reduce((sum, chars) => sum + chars, 0),
|
|
maxFirstPaintCharsInOneFrame: firstPaints.length > 0 ? Math.max(...firstPaints) : 0,
|
|
handoverGrowthFrames: handoverGrowths.length,
|
|
handoverGrowthChars: handoverGrowths.reduce((sum, chars) => sum + chars, 0),
|
|
maxHandoverGrowthCharsInOneFrame: handoverGrowths.length > 0 ? Math.max(...handoverGrowths) : 0,
|
|
updateGapP50Ms: round2(computePercentile(gapsMs, 50)),
|
|
updateGapP95Ms: round2(computePercentile(gapsMs, 95)),
|
|
updateGapMaxMs: gapsMs.length > 0 ? round2(Math.max(...gapsMs)) : 0,
|
|
};
|
|
}
|