Reduce rendering cost in long chats (#7052)

* perf(app): stop re-walking the timeline on every streamed delta

Every coalesced delta rebuilds the stream render model with the same
tail. The model re-sliced the tail at the history-window start on every
call, so the rendered tail was a fresh array per delta and every WeakMap
keyed on it (ordering, history split, turn timing, the layout history
cache, row revision) missed each time. Cache the slice by tail identity
and window start.

Turn timing then still walked the whole rendered tail per delta and
allocated a new assistant-timing Map, which the layout memo depends on.
Split the walk: the tail is walked once per tail identity and the head is
walked from the carried open turn per delta. The Map keeps its identity
while the head adds no completed turn.

Adds identity-contract tests and a vitest bench for the per-delta
pipeline. 200 deltas over a 2,000-item timeline, mean per run:
default mounted window 5.68ms -> 2.82ms, history fully revealed
16.95ms -> 1.81ms.

Fixes #4816

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* perf(app): keep the stream view's per-delta work bounded by the head

The chat outline concatenated tail and head, reduced over the result in
the render body, and rebuilt jumpToPrompt on every delta even when the
outline was disabled, which is every native viewport. Memoize the tail's
contribution, scan only the head per delta, do nothing while disabled,
make jumpToPrompt a stable event so the rail's memo holds, and republish
the active prompt only when the index or reading position changes.

The view built a Set of every mounted id plus the live head per delta for
the outline's pending-jump check; key it on mounted history only and
treat live head rows as always mounted. Split plugin projection so a head
change does not re-project the tail, memoize the history-window boundary
lookup, and read the turn presentation's submission flag with some()
instead of allocating a filtered array.

Refs #4816

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(app): confirm full history before measuring streaming

---------

Co-authored-by: Ivan Malison <IvanMalison@gmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
This commit is contained in:
paseo-bot[bot]
2026-10-16 01:15:40 +02:00
committed by GitHub
co-authored by Claude Fable 5.1 Ivan Malison
parent d9b874f9d0
commit 8d424e039e
15 changed files with 635 additions and 151 deletions
+5
View File
@@ -58,12 +58,17 @@ So arrival sets a _target_ and the reveal rate is derived from the backlog inste
through that hook; the web viewport once skipped it and history hosts of a live tool group went
stale. A new field on `StreamLayoutItem` must be added to `areLayoutItemsEquivalent`, or sharing
silently stops.
- **Streaming cost must not grow with revealed history.** Reading older messages must not
make the next reply progressively more expensive to display. Reuse unchanged history work
while text arrives, and release obsolete window caches as the reader reveals more history;
window caches must stay bounded while their source timeline remains loaded.
## Measuring
- **Smoothness (user-perceived):** `packages/app/e2e/browser/agent-stream-smoothness.spec.ts`, gated behind `PASEO_AGENT_STREAM_PERF_E2E=1`. Drives the mock provider's `bursty-stream` model and reports coefficient of variation of characters painted per frame (smoothness) plus p95 gap between visible updates (stalls). Both numbers are needed: a stalled stream is perfectly smooth.
- **Reproducing bursty arrival:** the `bursty-stream` model in `mock-load-test-agent.ts` emits uneven runs of tokens separated by idle gaps. Burst sizes come from a seeded generator, so a run repeats exactly.
- **Rate policy in isolation:** `computeRevealStep` in `packages/app/src/agent-stream/text-reveal.ts` is pure; `text-reveal.test.ts` covers convergence and burst flattening without a renderer.
- **Per-delta cost on a long timeline:** `npx vitest bench --project unit src/agent-stream/stream-tick.bench.ts` from `packages/app` replays 200 deltas over a 2,000-item timeline through the pure model and layout pipeline, with the default mounted window and with history fully revealed. It measures the JS work per delta, not paint.
Healthy numbers (2026-08, Expo web against a local dev daemon, real Claude Haiku agent, ~8.5s samples during active streaming). Setting `TEXT_REVEAL_HORIZON_MS` to 0 makes the reveal paint on arrival, which is how the baseline column was taken:
@@ -1,6 +1,13 @@
import { test, expect } from "../support/fixtures";
import { awaitAssistantMessage } from "../support/helpers/agent-stream";
import { startRunningMockAgent } from "../support/helpers/composer";
import { startRunningMockAgent, submitMessage } from "../support/helpers/composer";
import {
seedLongMockAgentTimeline,
openAgentTimeline,
scrollTimelineUntilOlderHistoryIsReachable,
scrollTimelineToNewestLoadedEdge,
expectTimelinePromptVisible,
} from "../support/helpers/timeline-pagination";
import {
sampleStreamFrames,
summarizeStreamSmoothness,
@@ -102,6 +109,40 @@ agentStreamPerfDescribe("Agent stream smoothness", () => {
await agent.cleanup();
}
});
test("keeps streaming work bounded after revealing older history", async ({ page }, testInfo) => {
test.setTimeout(120_000);
await page.addInitScript(() => {
Reflect.set(globalThis, "__PASEO_RENDER_PROFILE_ENABLED__", true);
});
const agent = await seedLongMockAgentTimeline({ turns: 80, liveTurns: "thirty-minute-stream" });
try {
await openAgentTimeline(page, agent);
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible();
await scrollTimelineUntilOlderHistoryIsReachable(page, agent.oldestPrompt);
await expectTimelinePromptVisible(page, agent.oldestPrompt);
await scrollTimelineToNewestLoadedEdge(page);
await submitMessage(page, "Stream a long answer after reading older messages.");
await awaitAssistantMessage(page);
for (let sample = 0; sample < 3; sample += 1) {
await page.evaluate(() => globalThis.__PASEO_RESET_RENDER_PROFILE__?.());
const frames = await sampleStreamFrames(page, { sampleWindowMs: SAMPLE_WINDOW_MS });
const smoothness = summarizeStreamSmoothness(frames, SAMPLE_WINDOW_MS);
const react = summarizeReactCommits(await readStreamReactCommits(page));
await testInfo.attach(`revealed-history-stream-${sample}`, {
body: JSON.stringify({ smoothness, react }, null, 2),
contentType: "application/json",
});
console.log(`[perf] Revealed history stream: ${JSON.stringify({ smoothness, react })}`);
expect(smoothness.growthFrames).toBeGreaterThan(20);
expect(react.count).toBeGreaterThan(20);
expect(react.actualDurationP95Ms).toBeLessThan(REACT_COMMIT_P95_BUDGET_MS);
}
} finally {
await agent.cleanup();
}
});
});
async function readStreamReactCommits(
@@ -2,6 +2,9 @@ import type { ReactNode, RefObject } from "react";
import type { StreamItem } from "@/types/stream";
import type { StreamViewportHandle } from "../strategy";
export interface MountedMessageIds {
has(messageId: string): boolean;
}
export interface ChatFindProps {
agentId: string;
serverId: string;
@@ -9,7 +12,7 @@ export interface ChatFindProps {
items: StreamItem[];
viewportRef: RefObject<StreamViewportHandle | null>;
revealLoadedMessage(messageId: string): boolean;
visibleMessageIds: ReadonlySet<string>;
visibleMessageIds: MountedMessageIds;
children: ReactNode;
}
export interface ChatFindExpansionProps {
@@ -178,7 +178,7 @@ describe("useChatOutline", () => {
timelineEpoch,
enabled,
viewportRef,
visibleMessageIds: new Set(),
mountedHistoryMessageIds: new Set(),
onJumpError: vi.fn(),
}),
{
@@ -278,7 +278,7 @@ describe("useChatOutline", () => {
},
];
const { result, rerender } = renderHook(
({ visibleMessageIds }) =>
({ mountedHistoryMessageIds }) =>
useChatOutline({
agentId: "agent-1",
serverId: "server-1",
@@ -288,10 +288,10 @@ describe("useChatOutline", () => {
enabled: true,
viewportRef,
onJumpError: vi.fn(),
visibleMessageIds,
mountedHistoryMessageIds,
revealLoadedMessage,
}),
{ initialProps: { visibleMessageIds: new Set<string>() } },
{ initialProps: { mountedHistoryMessageIds: new Set<string>() } },
);
await waitFor(() => expect(result.current.prompts).toHaveLength(1));
@@ -299,7 +299,7 @@ describe("useChatOutline", () => {
expect(revealLoadedMessage).toHaveBeenCalledWith("older-prompt");
expect(scrollToMessage).not.toHaveBeenCalled();
rerender({ visibleMessageIds: new Set(["older-prompt"]) });
rerender({ mountedHistoryMessageIds: new Set(["older-prompt"]) });
await waitFor(() => expect(scrollToMessage).toHaveBeenCalledWith("older-prompt"));
});
@@ -327,7 +327,7 @@ describe("useChatOutline", () => {
timelineCursor: { epoch: "epoch-1", seq: 1 },
};
const { result, rerender } = renderHook(
({ tail, visibleMessageIds }) =>
({ tail, mountedHistoryMessageIds }) =>
useChatOutline({
agentId: "agent-1",
serverId: "server-1",
@@ -337,13 +337,13 @@ describe("useChatOutline", () => {
enabled: true,
viewportRef,
onJumpError: vi.fn(),
visibleMessageIds,
mountedHistoryMessageIds,
revealLoadedMessage,
}),
{
initialProps: {
tail: [] as (typeof fetchedPrompt)[],
visibleMessageIds: new Set<string>(),
mountedHistoryMessageIds: new Set<string>(),
},
},
);
@@ -352,11 +352,11 @@ describe("useChatOutline", () => {
act(() => result.current.jumpToPrompt(1));
await waitFor(() => expect(runtime.fetchAgentTimeline).toHaveBeenCalledOnce());
rerender({ tail: [fetchedPrompt], visibleMessageIds: new Set<string>() });
rerender({ tail: [fetchedPrompt], mountedHistoryMessageIds: new Set<string>() });
await waitFor(() => expect(revealLoadedMessage).toHaveBeenCalledWith(fetchedPrompt.id));
expect(scrollToMessage).not.toHaveBeenCalled();
rerender({ tail: [fetchedPrompt], visibleMessageIds: new Set([fetchedPrompt.id]) });
rerender({ tail: [fetchedPrompt], mountedHistoryMessageIds: new Set([fetchedPrompt.id]) });
await waitFor(() => expect(scrollToMessage).toHaveBeenCalledOnce());
expect(scrollToMessage).toHaveBeenCalledWith(fetchedPrompt.id);
await act(async () => fetch.resolve());
@@ -392,7 +392,7 @@ describe("useChatOutline", () => {
enabled: true,
viewportRef: { current: viewport },
onJumpError: vi.fn(),
visibleMessageIds: new Set([livePrompt.id]),
mountedHistoryMessageIds: new Set<string>(),
revealLoadedMessage,
}),
);
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
import { useEffect, useMemo, useRef, useState, type RefObject } from "react";
import type { AgentTimelinePromptIndexPayload } from "@getpaseo/client/internal/daemon-client";
import { isWeb } from "@/constants/platform";
import { useStableEvent } from "@/hooks/use-stable-event";
@@ -33,7 +33,8 @@ export interface UseChatOutlineInput {
enabled: boolean;
viewportRef: RefObject<StreamViewportHandle | null>;
onJumpError: () => void;
visibleMessageIds?: ReadonlySet<string>;
// Message ids of the history the viewport has mounted. Live head messages are always mounted.
mountedHistoryMessageIds?: ReadonlySet<string>;
revealLoadedMessage?: (messageId: string) => boolean;
}
@@ -44,6 +45,24 @@ export interface ChatOutline {
reportReadingPosition: (seq: number | null) => void;
}
function latestPromptSeqIn(items: StreamItem[], timelineEpoch: string | null): number {
let latest = -1;
for (const item of items) {
if (item.kind === "user_message" && item.timelineCursor?.epoch === timelineEpoch) {
latest = Math.max(latest, item.timelineCursor.seq);
}
}
return latest;
}
function findLoadedItem(
tail: StreamItem[],
head: StreamItem[],
predicate: (item: StreamItem) => boolean,
): StreamItem | undefined {
return tail.find(predicate) ?? head.find(predicate);
}
export function useChatOutline({
agentId,
serverId,
@@ -53,7 +72,7 @@ export function useChatOutline({
enabled,
viewportRef,
onJumpError,
visibleMessageIds,
mountedHistoryMessageIds,
revealLoadedMessage,
}: UseChatOutlineInput): ChatOutline {
const [index, setIndex] = useState<AgentTimelinePromptIndexPayload | null>(null);
@@ -62,18 +81,19 @@ export function useChatOutline({
const readingSeqRef = useRef<number | null>(null);
const nextJumpRequestIdRef = useRef(0);
const nextIndexRequestIdRef = useRef(0);
const loadedItems = useMemo(() => [...tail, ...(head ?? NO_STREAM_ITEMS)], [head, tail]);
// A disabled outline (every native viewport) must not pay for the timeline per delta.
const loadedTail = enabled ? tail : NO_STREAM_ITEMS;
const loadedHead = enabled ? (head ?? NO_STREAM_ITEMS) : NO_STREAM_ITEMS;
const prompts = enabled ? (index?.prompts ?? NO_PROMPTS) : NO_PROMPTS;
// The viewed timeline already owns live delivery and reconnect catch-up. Its complete
// loaded items (including rows outside the mounted window) invalidate the prompt index.
const latestPromptSeq = loadedItems.reduce(
(latest, item) =>
item.kind === "user_message" && item.timelineCursor?.epoch === timelineEpoch
? Math.max(latest, item.timelineCursor.seq)
: latest,
-1,
// Only the head changes per streamed delta, so the tail's contribution is memoized.
const tailPromptSeq = useMemo(
() => latestPromptSeqIn(loadedTail, timelineEpoch),
[loadedTail, timelineEpoch],
);
const latestPromptSeq = Math.max(tailPromptSeq, latestPromptSeqIn(loadedHead, timelineEpoch));
useEffect(() => setIndex(null), [agentId, enabled, serverId, timelineEpoch]);
@@ -135,10 +155,18 @@ export function useChatOutline({
useEffect(() => {
if (pendingJump === null) return;
const target = loadedItems.find((item) => item.timelineCursor?.seq === pendingJump.seq);
const target = findLoadedItem(
loadedTail,
loadedHead,
(item) => item.timelineCursor?.seq === pendingJump.seq,
);
if (target) {
if (pendingJump.hasScrolled) return;
if (visibleMessageIds?.has(target.id) === false) {
const isMounted =
mountedHistoryMessageIds === undefined ||
mountedHistoryMessageIds.has(target.id) ||
loadedHead.includes(target);
if (!isMounted) {
revealLoadedMessage?.(target.id);
return;
}
@@ -150,40 +178,48 @@ export function useChatOutline({
return;
}
if (pendingJump.fetchSettled) setPendingJump(null);
}, [loadedItems, pendingJump, revealLoadedMessage, viewportRef, visibleMessageIds]);
}, [
loadedHead,
loadedTail,
mountedHistoryMessageIds,
pendingJump,
revealLoadedMessage,
viewportRef,
]);
const jumpToPrompt = useCallback(
(seq: number) => {
nextJumpRequestIdRef.current += 1;
setPendingJump(null);
const loaded = loadedItems.find((item) => item.timelineCursor?.seq === seq);
if (loaded) {
if (revealLoadedMessage?.(loaded.id)) {
const requestId = nextJumpRequestIdRef.current;
setPendingJump({ requestId, seq, fetchSettled: true, hasScrolled: false });
return;
}
viewportRef.current?.scrollToMessage?.(loaded.id);
const jumpToPrompt = useStableEvent((seq: number) => {
nextJumpRequestIdRef.current += 1;
setPendingJump(null);
const loaded = findLoadedItem(
loadedTail,
loadedHead,
(item) => item.timelineCursor?.seq === seq,
);
if (loaded) {
if (revealLoadedMessage?.(loaded.id)) {
const requestId = nextJumpRequestIdRef.current;
setPendingJump({ requestId, seq, fetchSettled: true, hasScrolled: false });
return;
}
if (!index) return;
const requestId = nextJumpRequestIdRef.current;
setPendingJump({ requestId, seq, fetchSettled: false, hasScrolled: false });
void getHostRuntimeStore()
.fetchAgentTimeline(serverId, agentId, planTimelinePromptJump({ epoch: index.epoch, seq }))
.catch((error: unknown) => {
console.warn("Failed to load a Chat outline window", error);
onJumpError();
})
.finally(() => {
setPendingJump((current) => {
if (current?.requestId !== requestId) return current;
return { ...current, fetchSettled: true };
});
viewportRef.current?.scrollToMessage?.(loaded.id);
return;
}
if (!index) return;
const requestId = nextJumpRequestIdRef.current;
setPendingJump({ requestId, seq, fetchSettled: false, hasScrolled: false });
void getHostRuntimeStore()
.fetchAgentTimeline(serverId, agentId, planTimelinePromptJump({ epoch: index.epoch, seq }))
.catch((error: unknown) => {
console.warn("Failed to load a Chat outline window", error);
onJumpError();
})
.finally(() => {
setPendingJump((current) => {
if (current?.requestId !== requestId) return current;
return { ...current, fetchSettled: true };
});
},
[agentId, index, loadedItems, onJumpError, revealLoadedMessage, serverId, viewportRef],
);
});
});
return { prompts, activePrompt, jumpToPrompt, reportReadingPosition };
}
@@ -0,0 +1,51 @@
import { expect, it } from "vitest";
import { setFlagsFromString } from "node:v8";
import { runInNewContext } from "node:vm";
import { buildAgentStreamRenderModel } from "./model";
import type { StreamItem } from "@/types/stream";
function revealHistory(tail: StreamItem[]): WeakRef<StreamItem[]>[] {
const windows: WeakRef<StreamItem[]>[] = [];
for (let start = tail.length - 20; start >= 20; start -= 20) {
const model = buildAgentStreamRenderModel({
tail,
head: [],
historyStart: start,
platform: "web",
isMobileBreakpoint: false,
isTurnActive: false,
activeTurnStartedAt: null,
});
windows.push(new WeakRef(model.history));
}
return windows;
}
it("releases obsolete history windows and their source timeline", async () => {
setFlagsFromString("--expose_gc");
const gc = runInNewContext("gc") as () => void;
setFlagsFromString("--no-expose_gc");
async function collect() {
for (let i = 0; i < 3; i++) {
await new Promise((resolve) => setTimeout(resolve, 0));
gc();
}
}
let tail: StreamItem[] = Array.from({ length: 1000 }, (_, i) => ({
kind: i % 2 ? "assistant_message" : "user_message",
id: `row-${i}`,
text: "text",
timestamp: new Date(1700000000000 + i * 1000),
}));
const windows = revealHistory(tail);
await collect();
expect(windows.filter((ref) => ref.deref() !== undefined)).toHaveLength(1);
windows.push(...revealHistory(tail));
await collect();
expect(windows.filter((ref) => ref.deref() !== undefined)).toHaveLength(1);
tail = [];
await collect();
expect(windows.every((ref) => ref.deref() === undefined)).toBe(true);
});
@@ -240,4 +240,47 @@ describe("buildAgentStreamRenderModel", () => {
expect(model.turnTiming.byAssistantId.size).toBe(0);
});
it.each(["web", "native"] as const)(
"reuses rendered history and timing across live-head updates inside a history window on %s",
(platform) => {
const at = (seed: number) => new Date(Date.UTC(2026, 0, 1, 0, 0, seed));
const tail: StreamItem[] = [];
for (let index = 0; index < 40; index += 1) {
tail.push(
{ ...userMessage(`u${index}`, 0), timestamp: at(index * 2) },
{ ...assistantMessage(`a${index}`, 0), timestamp: at(index * 2 + 1) },
);
}
const live = (text: string): StreamItem => ({
kind: "assistant_message",
id: "live",
text,
timestamp: at(81),
});
const build = (head: StreamItem[]) =>
buildAgentStreamRenderModel({
isTurnActive: true,
activeTurnStartedAt: tail.at(-2)?.timestamp ?? null,
tail,
head,
platform,
isMobileBreakpoint: false,
historyStart: 60,
});
const first = build([live("live")]);
const second = build([live("live grows")]);
expect(second.history).toBe(first.history);
expect(second.segments.historyMounted).toBe(first.segments.historyMounted);
expect(second.turnTiming.byAssistantId).toBe(first.turnTiming.byAssistantId);
expect(first.history).toHaveLength(20);
expect(first.turnTiming.byAssistantId.has("a29")).toBe(false);
expect(first.turnTiming.byAssistantId.get("a38")).toEqual({
completedAt: tail[77]?.timestamp,
durationMs: 1000,
});
},
);
});
+22 -30
View File
@@ -53,11 +53,29 @@ const splitHistoryCache = new WeakMap<
StreamItem[],
Map<string, Pick<AgentStreamRenderModel, "history" | "segments">>
>();
const turnTimingCache = new WeakMap<
// A streamed delta rebuilds the model with the same tail. Everything below is keyed on
// the rendered tail's identity, so the slice has to be reused, not recomputed.
const renderedTailCache = new WeakMap<
StreamItem[],
WeakMap<StreamItem[], Map<string, StreamTurnTiming>>
{ historyStart: number; rendered: StreamItem[] }
>();
function getRenderedTail(tail: StreamItem[], historyStart: number | undefined): StreamItem[] {
if (!historyStart) {
renderedTailCache.delete(tail);
return tail;
}
const cached = renderedTailCache.get(tail);
if (cached?.historyStart === historyStart) {
return cached.rendered;
}
const rendered = tail.slice(historyStart);
// Revealing older history replaces the previous window, allowing its arrays and
// downstream layouts to be collected while the unchanged source tail stays alive.
renderedTailCache.set(tail, { historyStart, rendered });
return rendered;
}
function getOrderedItems(params: {
cache: WeakMap<StreamItem[], Map<string, StreamItem[]>>;
source: StreamItem[];
@@ -129,32 +147,6 @@ function splitOrderedTail(params: {
return split;
}
function getTurnTiming(params: {
isTurnActive: boolean;
activeTurnStartedAt: Date | null;
tail: StreamItem[];
head: StreamItem[];
}): StreamTurnTiming {
let cachedByHead = turnTimingCache.get(params.tail);
if (!cachedByHead) {
cachedByHead = new WeakMap();
turnTimingCache.set(params.tail, cachedByHead);
}
let cachedByActivity = cachedByHead.get(params.head);
if (!cachedByActivity) {
cachedByActivity = new Map();
cachedByHead.set(params.head, cachedByActivity);
}
const activityKey = `${params.isTurnActive}:${params.activeTurnStartedAt?.getTime() ?? "none"}`;
const cached = cachedByActivity.get(activityKey);
if (cached) {
return cached;
}
const timing = deriveStreamTurnTiming(params);
cachedByActivity.set(activityKey, timing);
return timing;
}
export function buildAgentStreamRenderModel(
input: BuildAgentStreamRenderModelInput,
): AgentStreamRenderModel {
@@ -163,7 +155,7 @@ export function buildAgentStreamRenderModel(
isMobileBreakpoint: input.isMobileBreakpoint,
});
const orderingCacheKey = `${input.platform}:${input.isMobileBreakpoint}`;
const renderedTail = input.historyStart ? input.tail.slice(input.historyStart) : input.tail;
const renderedTail = getRenderedTail(input.tail, input.historyStart);
const orderedTail = getOrderedItems({
cache: orderedTailCache,
source: renderedTail,
@@ -189,7 +181,7 @@ export function buildAgentStreamRenderModel(
platform: input.platform,
isMobileBreakpoint: input.isMobileBreakpoint,
});
const turnTiming = getTurnTiming({
const turnTiming = deriveStreamTurnTiming({
isTurnActive: input.isTurnActive,
activeTurnStartedAt: input.activeTurnStartedAt,
tail: renderedTail,
@@ -0,0 +1,132 @@
import { bench, describe } from "vitest";
import type { StreamItem, ToolCallItem } from "@/types/stream";
import {
prepareToolCallHistory,
projectToolCallDetailLevel,
} from "@/tool-calls/detail-level/projection";
import { findMountedWindowStart, getMountedRecentStreamItems } from "./history-window";
import { layoutStream } from "./layout";
import { buildAgentStreamRenderModel } from "./model";
import { resolveStreamRenderStrategy } from "./strategy-resolver";
// One coalesced delta replaces the stream head. This is the pure work AgentStreamView
// does per delta on a long timeline, without React:
// npx vitest bench --project unit src/agent-stream/stream-tick.bench.ts
const TIMELINE_ITEMS = 2_000;
const TICKS = 200;
function timestamp(seed: number): Date {
return new Date(1_700_000_000_000 + seed * 1000);
}
function toolCall(id: string, seed: number): ToolCallItem {
return {
kind: "tool_call",
id,
timestamp: timestamp(seed),
payload: {
source: "agent",
data: {
provider: "claude",
callId: id,
name: "shell",
status: "completed",
error: null,
detail: { type: "shell", command: id },
},
},
};
}
function buildTimeline(count: number): StreamItem[] {
const items: StreamItem[] = [];
let seed = 0;
while (items.length < count) {
const turn = items.length;
items.push({
kind: "user_message",
id: `u${turn}`,
text: "prompt",
timestamp: timestamp(seed++),
});
items.push(toolCall(`t${turn}a`, seed++));
items.push(toolCall(`t${turn}b`, seed++));
items.push({
kind: "assistant_message",
id: `a${turn}`,
text: "answer",
timestamp: timestamp(seed++),
});
}
return items.slice(0, count);
}
function buildHeads(base: StreamItem[], ticks: number): StreamItem[][] {
let text = "";
const heads: StreamItem[][] = [];
for (let tick = 0; tick < ticks; tick += 1) {
text += "token ";
heads.push([
{
kind: "assistant_message",
id: "live",
text,
timestamp: timestamp(base.length + tick),
},
]);
}
return heads;
}
function runTicks(input: { tail: StreamItem[]; historyStart: number; heads: StreamItem[][] }) {
const { tail, historyStart, heads } = input;
const strategy = resolveStreamRenderStrategy({ platform: "web", isMobileBreakpoint: false });
const prepared = prepareToolCallHistory("overview", tail);
const boundaryId = tail[historyStart]?.id ?? null;
const activeTurnStartedAt = tail.at(-1)?.timestamp ?? null;
for (const head of heads) {
const projected = projectToolCallDetailLevel({
level: "overview",
tail,
head,
preparedHistory: prepared,
isTurnActive: true,
});
const boundaryIndex = boundaryId
? projected.tail.findIndex((item) => item.id === boundaryId)
: -1;
const model = buildAgentStreamRenderModel({
isTurnActive: true,
activeTurnStartedAt,
tail: projected.tail,
head: projected.head,
platform: "web",
isMobileBreakpoint: false,
historyStart: boundaryIndex >= 0 ? boundaryIndex : historyStart,
});
layoutStream({
strategy,
isTurnActive: true,
history: model.history,
liveHead: model.segments.liveHead,
timingByAssistantId: model.turnTiming.byAssistantId,
});
}
}
const tail = buildTimeline(TIMELINE_ITEMS);
const heads = buildHeads(tail, TICKS);
const defaultWindowStart = findMountedWindowStart({
items: tail,
minMountedCount: getMountedRecentStreamItems(),
});
describe(`${TICKS} streamed deltas over a ${TIMELINE_ITEMS}-item timeline`, () => {
bench("default mounted window (recent items only)", () => {
runTicks({ tail, historyStart: defaultWindowStart, heads });
});
bench("history fully revealed (scrolled to top)", () => {
runTicks({ tail, historyStart: 0, heads });
});
});
@@ -70,10 +70,11 @@ export function useStreamHistoryWindow(input: {
boundaryItemId: initialBoundaryItemId,
initialized: items.length > 0,
}));
const boundaryIndex =
window.agentId === agentId && window.boundaryItemId !== null
? items.findIndex((item) => item.id === window.boundaryItemId)
: -1;
const boundaryItemId = window.agentId === agentId ? window.boundaryItemId : null;
const boundaryIndex = useMemo(
() => (boundaryItemId === null ? -1 : items.findIndex((item) => item.id === boundaryItemId)),
[boundaryItemId, items],
);
let start = initialStart;
if (window.agentId === agentId && window.initialized) {
if (window.boundaryItemId === null) {
+16 -11
View File
@@ -607,16 +607,21 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
toast?.error(t("agentStream.historyLoadFailed"));
}, [t, toast]);
// Chat find and the chat outline address messages, and an assistant message is a
// group of block rows, so this is a set of message ids and never of row ids.
const visibleMessageIds = useMemo(
() =>
new Set(
[...baseRenderModel.history, ...baseRenderModel.segments.liveHead].map(
getStreamItemMessageId,
),
),
[baseRenderModel.history, baseRenderModel.segments.liveHead],
// group of block rows, so these hold message ids and never row ids. Only the head
// changes per streamed delta, so the history set is keyed on mounted history alone.
const mountedHistoryMessageIds = useMemo(
() => new Set(baseRenderModel.history.map(getStreamItemMessageId)),
[baseRenderModel.history],
);
const mountedMessageIds = useMemo(() => {
const liveHeadMessageIds = new Set(
baseRenderModel.segments.liveHead.map(getStreamItemMessageId),
);
return {
has: (messageId: string) =>
mountedHistoryMessageIds.has(messageId) || liveHeadMessageIds.has(messageId),
};
}, [baseRenderModel.segments.liveHead, mountedHistoryMessageIds]);
const chatOutline = useChatOutline({
agentId,
serverId: resolvedServerId,
@@ -626,7 +631,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
enabled: supportsChatOutline && chatOutlineEnabled,
viewportRef,
onJumpError: handleTimelineHistoryLoadError,
visibleMessageIds,
mountedHistoryMessageIds,
revealLoadedMessage: revealLoadedHistory,
});
@@ -1105,7 +1110,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
items={findItems}
viewportRef={viewportRef}
revealLoadedMessage={revealLoadedHistory}
visibleMessageIds={visibleMessageIds}
visibleMessageIds={mountedMessageIds}
>
<ToolCallSheetProvider>
<AssistantSelectionCopySurface style={stylesheet.container}>
@@ -17,6 +17,14 @@ export function getActiveMessageSubmissions(
);
}
export function hasActiveMessageSubmission(
submissions: readonly MessageSubmissionRecord[] | null | undefined,
): boolean {
return (submissions ?? EMPTY_MESSAGE_SUBMISSIONS).some(
(submission) => !submission.providerAcknowledged,
);
}
export function getSendingClientMessageIds(
submissions: readonly MessageSubmissionRecord[] | null | undefined,
): string[] {
+2 -2
View File
@@ -15,7 +15,7 @@ import {
import {
acceptMessageSubmission,
beginMessageSubmission,
getActiveMessageSubmissions,
hasActiveMessageSubmission,
observeMessageSubmissionCanonical,
rejectMessageSubmission,
type MessageSubmissionRecord,
@@ -348,7 +348,7 @@ export function selectAgentTurnPresentation(
session?.agents.get(agentId)?.turn ??
session?.agentDetails.get(agentId)?.turn ??
TURN_LIVENESS_IDLE,
getActiveMessageSubmissions(session?.messageSubmissions.get(agentId)).length > 0,
hasActiveMessageSubmission(session?.messageSubmissions.get(agentId)),
);
}
+95 -2
View File
@@ -12,11 +12,11 @@ function user(id: string, timestamp: Date): StreamItem {
};
}
function assistant(id: string, timestamp: Date): StreamItem {
function assistant(id: string, timestamp: Date, text = id): StreamItem {
return {
kind: "assistant_message",
id,
text: id,
text,
timestamp,
};
}
@@ -125,4 +125,97 @@ describe("deriveStreamTurnTiming", () => {
durationMs: null,
});
});
it("keeps the timing map identity while a streamed delta grows the live head", () => {
const tail = [
user("u1", new Date("2026-05-15T00:00:00.000Z")),
assistant("a1", new Date("2026-05-15T00:00:07.000Z")),
user("u2", new Date("2026-05-15T00:01:00.000Z")),
];
const derive = (text: string, at: string) =>
deriveStreamTurnTiming({
isTurnActive: true,
activeTurnStartedAt: tail[2]?.timestamp ?? null,
tail,
head: [assistant("a2", new Date(at), text)],
});
const first = derive("one", "2026-05-15T00:01:04.000Z");
const second = derive("one two", "2026-05-15T00:01:05.000Z");
assert.equal(second.byAssistantId, first.byAssistantId);
assert.deepEqual(first.byAssistantId.get("a1"), {
completedAt: tail[1]?.timestamp,
durationMs: 7000,
});
assert.equal(first.byAssistantId.has("a2"), false);
});
it("keeps the timing map identity when the head closed the tail's open turn", () => {
const lastTailAt = new Date("2026-05-15T00:00:07.000Z");
const tail = [user("u1", new Date("2026-05-15T00:00:00.000Z")), assistant("a1", lastTailAt)];
const derive = (text: string, at: string) =>
deriveStreamTurnTiming({
isTurnActive: true,
activeTurnStartedAt: null,
tail,
head: [
user("u2", new Date("2026-05-15T00:01:00.000Z")),
assistant("a2", new Date(at), text),
],
});
const first = derive("one", "2026-05-15T00:01:04.000Z");
const second = derive("one two", "2026-05-15T00:01:05.000Z");
assert.equal(second.byAssistantId, first.byAssistantId);
assert.deepEqual(first.byAssistantId.get("a1"), {
completedAt: lastTailAt,
durationMs: 7000,
});
});
it("produces a new timing map when the streamed turn completes", () => {
const tail = [user("u1", new Date("2026-05-15T00:00:00.000Z"))];
const completedAt = new Date("2026-05-15T00:00:09.000Z");
const head = [assistant("a1", completedAt)];
const active = deriveStreamTurnTiming({
isTurnActive: true,
activeTurnStartedAt: tail[0]?.timestamp ?? null,
tail,
head,
});
const completed = deriveStreamTurnTiming({
isTurnActive: false,
activeTurnStartedAt: null,
tail,
head,
});
assert.notEqual(completed.byAssistantId, active.byAssistantId);
assert.equal(active.byAssistantId.has("a1"), false);
assert.deepEqual(completed.byAssistantId.get("a1"), { completedAt, durationMs: 9000 });
});
it("derives fresh timing when the tail itself changes", () => {
const firstTail = [
user("u1", new Date("2026-05-15T00:00:00.000Z")),
assistant("a1", new Date("2026-05-15T00:00:07.000Z")),
user("u2", new Date("2026-05-15T00:01:00.000Z")),
];
const secondTail = [...firstTail, assistant("a2", new Date("2026-05-15T00:01:03.000Z"))];
const derive = (tail: StreamItem[]) =>
deriveStreamTurnTiming({ isTurnActive: false, activeTurnStartedAt: null, tail, head: [] });
const first = derive(firstTail);
const second = derive(secondTail);
assert.notEqual(second.byAssistantId, first.byAssistantId);
assert.equal(first.byAssistantId.has("a2"), false);
assert.deepEqual(second.byAssistantId.get("a2"), {
completedAt: secondTail[3]?.timestamp,
durationMs: 3000,
});
});
});
+121 -47
View File
@@ -11,61 +11,135 @@ export interface StreamTurnTiming {
runningStartedAt: Date | null;
}
interface OpenTurn {
userAt: Date | null;
lastItemAt: Date | null;
assistantIds: readonly string[];
previousItem: StreamItem | null;
}
interface HeadTurnTiming {
additions: Map<string, TurnTiming>;
byAssistantId: Map<string, TurnTiming>;
}
interface TailTurnTiming {
completed: Map<string, TurnTiming>;
openTurn: OpenTurn;
lastHead: HeadTurnTiming | null;
}
const EMPTY_OPEN_TURN: OpenTurn = {
userAt: null,
lastItemAt: null,
assistantIds: [],
previousItem: null,
};
function closeTurn(turn: OpenTurn, into: Map<string, TurnTiming>): void {
if (!turn.lastItemAt || turn.assistantIds.length === 0) {
return;
}
const timing: TurnTiming = {
completedAt: turn.lastItemAt,
durationMs: turn.userAt ? Math.max(0, turn.lastItemAt.getTime() - turn.userAt.getTime()) : null,
};
for (const id of turn.assistantIds) {
into.set(id, timing);
}
}
function walkTurns(
items: readonly StreamItem[],
from: OpenTurn,
into: Map<string, TurnTiming>,
): OpenTurn {
let userAt = from.userAt;
let lastItemAt = from.lastItemAt;
let assistantIds = [...from.assistantIds];
let previousItem = from.previousItem;
for (const item of items) {
if (startsNewTurn(item, previousItem)) {
closeTurn({ userAt, lastItemAt, assistantIds, previousItem }, into);
userAt = item.kind === "user_message" ? item.timestamp : null;
lastItemAt = null;
assistantIds = [];
}
lastItemAt = item.timestamp;
if (item.kind === "assistant_message") {
assistantIds.push(item.id);
}
previousItem = item;
}
return { userAt, lastItemAt, assistantIds, previousItem };
}
function areTimingsEqual(a: Map<string, TurnTiming>, b: Map<string, TurnTiming>): boolean {
if (a.size !== b.size) {
return false;
}
for (const [id, timing] of a) {
const other = b.get(id);
if (
!other ||
other.completedAt.getTime() !== timing.completedAt.getTime() ||
other.durationMs !== timing.durationMs
) {
return false;
}
}
return true;
}
// The tail walk is cached by array identity so a streamed delta, which only replaces the
// head, walks the head from the carried open turn. The returned Map keeps its identity
// while the head adds no completed turn, so memos keyed on it do not miss per tick.
const tailTurnTimingCache = new WeakMap<StreamItem[], TailTurnTiming>();
function getTailTurnTiming(tail: StreamItem[]): TailTurnTiming {
const cached = tailTurnTimingCache.get(tail);
if (cached) {
return cached;
}
const completed = new Map<string, TurnTiming>();
const openTurn = walkTurns(tail, EMPTY_OPEN_TURN, completed);
const timing: TailTurnTiming = { completed, openTurn, lastHead: null };
tailTurnTimingCache.set(tail, timing);
return timing;
}
function resolveByAssistantId(
tail: TailTurnTiming,
additions: Map<string, TurnTiming>,
): Map<string, TurnTiming> {
if (additions.size === 0) {
return tail.completed;
}
if (tail.lastHead && areTimingsEqual(tail.lastHead.additions, additions)) {
return tail.lastHead.byAssistantId;
}
const byAssistantId = new Map(tail.completed);
for (const [id, timing] of additions) {
byAssistantId.set(id, timing);
}
tail.lastHead = { additions, byAssistantId };
return byAssistantId;
}
export function deriveStreamTurnTiming(params: {
isTurnActive: boolean;
activeTurnStartedAt: Date | null;
tail: StreamItem[];
head: StreamItem[];
}): StreamTurnTiming {
const byAssistantId = new Map<string, TurnTiming>();
let currentUserAt: Date | null = null;
let currentLastItemAt: Date | null = null;
let currentAssistantIds: string[] = [];
let previousItem: StreamItem | null = null;
const flushCompletedTurn = () => {
if (!currentLastItemAt || currentAssistantIds.length === 0) {
return;
}
const timing: TurnTiming = {
completedAt: currentLastItemAt,
durationMs: currentUserAt
? Math.max(0, currentLastItemAt.getTime() - currentUserAt.getTime())
: null,
};
for (const id of currentAssistantIds) {
byAssistantId.set(id, timing);
}
};
const visitItem = (item: StreamItem) => {
if (startsNewTurn(item, previousItem)) {
flushCompletedTurn();
currentUserAt = item.kind === "user_message" ? item.timestamp : null;
currentLastItemAt = null;
currentAssistantIds = [];
}
currentLastItemAt = item.timestamp;
if (item.kind === "assistant_message") {
currentAssistantIds.push(item.id);
}
previousItem = item;
};
for (const item of params.tail) {
visitItem(item);
}
for (const item of params.head) {
visitItem(item);
}
const runningStartedAt = params.isTurnActive ? params.activeTurnStartedAt : null;
const tail = getTailTurnTiming(params.tail);
const additions = new Map<string, TurnTiming>();
const openTurn = walkTurns(params.head, tail.openTurn, additions);
if (!params.isTurnActive) {
flushCompletedTurn();
closeTurn(openTurn, additions);
}
return {
byAssistantId,
runningStartedAt,
byAssistantId: resolveByAssistantId(tail, additions),
runningStartedAt: params.isTurnActive ? params.activeTurnStartedAt : null,
};
}