perf(ui): batch chat stream and outbox work into one frame (#127826)

* perf(ui): coalesce chat progress renders

* perf(ui): bound outbox projection and retries

* fix(ui): invalidate retained outbox projections

* fix(ui): invalidate cleared outbox state

* test(ui): identify outbox storage event source
This commit is contained in:
Vyctor H. Brzezowski
2026-08-22 21:24:52 -03:00
committed by GitHub
parent 844e781ca4
commit b099741805
10 changed files with 398 additions and 65 deletions
@@ -801,7 +801,7 @@ suite.define(() => {
},
}),
);
window.dispatchEvent(new StorageEvent("storage", { key }));
window.dispatchEvent(new StorageEvent("storage", { key, storageArea: sessionStorage }));
}, firstKey);
await page
.locator(
+2 -2
View File
@@ -9,7 +9,7 @@ import type { StoredComposerSession } from "./outbox-store-codec.ts";
import {
applyStoredChatOutboxScope,
hasKnownSessionDefaults,
readStoredOutboxStore,
readProjectedOutboxStore,
resolveComposerStorageScope,
resolveStoredComposerSession,
resolveStoredChatOutboxScope,
@@ -36,7 +36,7 @@ function listStoredComposerRows(
}
try {
const target = storageTargetForGateway(state.settings?.gatewayUrl);
const store = readStoredOutboxStore(storage, target);
const store = readProjectedOutboxStore(storage, target);
let migrated = false;
const selectedAgentId = resolveUiKnownSelectedGlobalAgentId(state);
const defaultAgentId = hasKnownSessionDefaults(state)
+110
View File
@@ -7,6 +7,7 @@ import {
summarizeStoredChatOutboxes,
} from "./outbox-store-projection.ts";
import {
readProjectedOutboxStore,
resolveStoredChatOutboxScope,
retireStoredComposerDrafts,
storedChatOutboxScopeKey,
@@ -23,6 +24,115 @@ afterEach(() => {
});
describe("stored outbox summaries", () => {
it("normalizes an unchanged projection once and refreshes after an external write", () => {
const unsubscribe = subscribeStoredChatOutboxChanges(() => undefined);
const gatewayUrl = "ws://gateway.test/control";
const storageKey = `openclaw.control.chatComposer.v2:${encodeURIComponent(gatewayUrl)}`;
sessionStorage.setItem(
storageKey,
JSON.stringify({ version: 2, gatewayOwner: gatewayUrl, sessions: {} }),
);
const target = {
gatewayOwner: gatewayUrl,
key: storageKey,
legacyKey: "unused",
legacyOwnerIsUnambiguous: true,
};
const first = readProjectedOutboxStore(sessionStorage, target);
expect(readProjectedOutboxStore(sessionStorage, target)).toBe(first);
sessionStorage.setItem(
storageKey,
JSON.stringify({
version: 2,
gatewayOwner: gatewayUrl,
sessions: { "main\u0000agent:main": { draft: "new", updatedAt: 1 } },
}),
);
const storageEvent = new StorageEvent("storage", { key: storageKey });
Object.defineProperty(storageEvent, "storageArea", { value: sessionStorage });
window.dispatchEvent(storageEvent);
expect(readProjectedOutboxStore(sessionStorage, target)).not.toBe(first);
unsubscribe();
});
it("refreshes a retained legacy projection after an external write", () => {
const unsubscribe = subscribeStoredChatOutboxChanges(() => undefined);
const gatewayUrl = "ws://gateway.test/control";
const legacyKey = `openclaw.control.chatComposer.v1:${encodeURIComponent(gatewayUrl)}`;
const stored = (ids: string[]) =>
JSON.stringify({
version: 1,
sessions: {
"thread\u0000agent:main": {
queue: ids.map((id, createdAt) => ({ id, text: id, createdAt })),
updatedAt: ids.length,
},
},
});
sessionStorage.setItem(legacyKey, stored(["first"]));
vi.spyOn(sessionStorage, "setItem").mockImplementationOnce(() => {
throw new DOMException("quota exceeded", "QuotaExceededError");
});
const state = { settings: { gatewayUrl } };
expect(summarizeStoredChatOutboxes(state).total).toBe(1);
sessionStorage.setItem(legacyKey, stored(["first", "second"]));
const storageEvent = new StorageEvent("storage", { key: legacyKey });
Object.defineProperty(storageEvent, "storageArea", { value: sessionStorage });
window.dispatchEvent(storageEvent);
const refreshedTotal = summarizeStoredChatOutboxes(state).total;
unsubscribe();
expect(refreshedTotal).toBe(2);
});
it("clears every retained projection after an external storage clear", () => {
const listener = vi.fn();
const unsubscribe = subscribeStoredChatOutboxChanges(listener);
const gatewayUrls = ["ws://first.test/control", "ws://second.test/control"];
for (const gatewayUrl of gatewayUrls) {
sessionStorage.setItem(
`openclaw.control.chatComposer.v2:${encodeURIComponent(gatewayUrl)}`,
JSON.stringify({
version: 2,
gatewayOwner: gatewayUrl,
mainAlias: { key: "workspace", agentId: "work" },
sessions: {
"thread\u0000agent:main": {
queue: [{ id: gatewayUrl, text: gatewayUrl, createdAt: 1 }],
updatedAt: 1,
},
},
}),
);
expect(summarizeStoredChatOutboxes({ settings: { gatewayUrl } }).total).toBe(1);
expect(
resolveStoredChatOutboxScope(
{ settings: { gatewayUrl }, agentsList: null, hello: null },
"workspace",
),
).toEqual({ sessionKey: "global", agentId: "work" });
}
sessionStorage.clear();
const storageEvent = new StorageEvent("storage", { key: null });
Object.defineProperty(storageEvent, "storageArea", { value: sessionStorage });
window.dispatchEvent(storageEvent);
unsubscribe();
for (const gatewayUrl of gatewayUrls) {
expect(
resolveStoredChatOutboxScope(
{ settings: { gatewayUrl }, agentsList: null, hello: null },
"workspace",
),
).toEqual({ sessionKey: "workspace" });
expect(summarizeStoredChatOutboxes({ settings: { gatewayUrl } }).total).toBe(0);
}
expect(listener).toHaveBeenCalledOnce();
});
it("keeps the exact aliased scope when sessionStorage retirement fails", () => {
const gatewayUrl = "ws://gateway.test/control";
const storageKey = `openclaw.control.chatComposer.v2:${encodeURIComponent(gatewayUrl)}`;
+33 -6
View File
@@ -85,7 +85,9 @@ const storedMainAliasByStorage = new WeakMap<
Storage,
Map<string, StoredComposerMainAlias | null>
>();
// Projection reads share one normalized snapshot until a canonical write or
// browser storage event invalidates it; mutation paths still reread for CAS.
const projectedStoreByStorage = new WeakMap<Storage, Map<string, StoredComposerState>>();
export function subscribeStoredChatOutboxChanges(listener: () => void): () => void {
storedChatOutboxChangeListeners.add(listener);
if (!storageChangeListenerInstalled && typeof window !== "undefined") {
@@ -116,10 +118,22 @@ export function notifyStoredChatOutboxChanges(): void {
}
function handleStoredChatOutboxStorageChange(event: StorageEvent): void {
if (event.key === null && event.storageArea) {
storedMainAliasByStorage.get(event.storageArea)?.clear();
projectedStoreByStorage.get(event.storageArea)?.clear();
notifyStoredChatOutboxChanges();
return;
}
if (
event.key?.startsWith(STORAGE_KEY_PREFIX) ||
event.key?.startsWith(LEGACY_STORAGE_KEY_PREFIX)
) {
if (event.storageArea) {
const projectedKey = event.key.startsWith(LEGACY_STORAGE_KEY_PREFIX)
? `${STORAGE_KEY_PREFIX}${event.key.slice(LEGACY_STORAGE_KEY_PREFIX.length)}`
: event.key;
projectedStoreByStorage.get(event.storageArea)?.delete(projectedKey);
}
notifyStoredChatOutboxChanges();
}
}
@@ -527,11 +541,28 @@ export function readStoredOutboxStore(
return { version: 2, gatewayOwner: target.gatewayOwner, sessions: {} };
}
export function readProjectedOutboxStore(
storage: Storage,
target: ComposerStorageTarget,
): StoredComposerState {
const byKey = projectedStoreByStorage.get(storage);
const cached = byKey?.get(target.key);
if (cached) {
return cached;
}
const store = readStoredOutboxStore(storage, target);
const nextByKey = byKey ?? new Map();
nextByKey.set(target.key, store);
projectedStoreByStorage.set(storage, nextByKey);
return store;
}
export function writeStoredOutboxStore(
storage: Storage,
target: ComposerStorageTarget,
store: StoredComposerState,
): void {
projectedStoreByStorage.get(storage)?.delete(target.key);
const entries = Object.entries(store.sessions);
const outboxes = entries.filter(([, session]) => session.queue?.length);
if (outboxes.length > MAX_STORED_SESSIONS) {
@@ -588,11 +619,7 @@ export function writeStoredOutboxStore(
export function retireStoredComposerDrafts(
state: ChatComposerScope,
targets: readonly StoredComposerRetirementTarget[],
): {
gatewayOwner: string;
retirements: StoredComposerRetirement[];
storageFailed: boolean;
} {
) {
const storageTarget = storageTargetForGateway(state.settings?.gatewayUrl);
if (targets.length === 0) {
return { gatewayOwner: storageTarget.gatewayOwner, retirements: [], storageFailed: false };
+24 -46
View File
@@ -18,6 +18,12 @@ import {
} from "./chat-commands.ts";
import { loadChatHistory, type ChatHistoryResult } from "./chat-history.ts";
import { chatOutboxOwner } from "./chat-outbox-owner.ts";
import {
consumeChatOutboxRetry,
retryableGatewayDelayMs,
scheduleChatOutboxRetry,
settleChatOutboxRetry,
} from "./chat-outbox-retry.ts";
import {
anyChatOutboxPaneMatches,
excludeComposerAttachments,
@@ -78,12 +84,9 @@ export type ChatOutboxDrainDependencies = {
) => void;
};
type StoredChatOutboxDrainResult = "blocked" | "empty";
type StoredChatOutboxDrainLane = {
freshAdmissions: Set<string>;
host: ChatHost;
// A pre-request cancellation can remove its row; retain the direct outcome so
// the submitter never mistakes absence for a successful transport handoff.
outcomes: Map<string, QueuedChatSendResult>;
pendingOptions: Map<string, QueuedChatSendOptions>;
promise: Promise<void>;
@@ -92,13 +95,9 @@ type StoredChatOutboxDrainLane = {
type StoredChatOutboxClientState = {
lanes: Map<string, StoredChatOutboxDrainLane>;
retryTimers: Map<string, ReturnType<typeof setTimeout>>;
};
const STORED_OUTBOX_CONFIRMATION_GRACE_MS = 5_000;
const STORED_OUTBOX_RETRY_DEFAULT_MS = 500;
const STORED_OUTBOX_RETRY_MIN_MS = 100;
const STORED_OUTBOX_RETRY_MAX_MS = 30_000;
export const UNCONFIRMED_CHAT_SEND_ERROR =
"Delivery could not be confirmed after reconnect. Check the conversation before retrying.";
const UNCERTAIN_CLEAR_SUCCESSOR_ERROR =
@@ -107,24 +106,11 @@ const UNCERTAIN_CLEAR_SUCCESSOR_ERROR =
const storedChatOutboxClients = new WeakMap<GatewayBrowserClient, StoredChatOutboxClientState>();
function getStoredChatOutboxClientState(client: GatewayBrowserClient): StoredChatOutboxClientState {
const existing = storedChatOutboxClients.get(client);
if (existing) {
return existing;
}
const created: StoredChatOutboxClientState = {
const state = storedChatOutboxClients.get(client) ?? {
lanes: new Map(),
retryTimers: new Map(),
};
storedChatOutboxClients.set(client, created);
return created;
}
export function retryableGatewayDelayMs(err: unknown): number | null {
if (!(err instanceof GatewayRequestError) || !err.retryable) {
return null;
}
const requested = err.retryAfterMs ?? STORED_OUTBOX_RETRY_DEFAULT_MS;
return Math.min(Math.max(requested, STORED_OUTBOX_RETRY_MIN_MS), STORED_OUTBOX_RETRY_MAX_MS);
storedChatOutboxClients.set(client, state);
return state;
}
export function scheduleStoredChatOutboxRetry(
@@ -132,24 +118,16 @@ export function scheduleStoredChatOutboxRetry(
scope: StoredChatOutboxScope,
delayMs: number,
dependencies: ChatOutboxDrainDependencies,
suppressGenericWake = true,
) {
const client = host.client;
if (!host.connected || !client) {
return;
}
const connectionEpoch = host.connectionEpoch;
const timers = getStoredChatOutboxClientState(client).retryTimers;
const key = storedChatOutboxScopeKey(scope);
if (timers.has(key)) {
return;
}
const timer = setTimeout(() => {
timers.delete(key);
if (host.connected && host.client === client && host.connectionEpoch === connectionEpoch) {
void scheduleStoredChatOutboxDrain(host, scope, dependencies);
}
}, delayMs);
timers.set(key, timer);
scheduleChatOutboxRetry(
host,
key,
delayMs,
(owner) => void scheduleStoredChatOutboxDrain(owner, scope, dependencies),
suppressGenericWake,
);
}
function readStoredChatOutbox(
@@ -340,6 +318,7 @@ async function reconcileStoredChatOutboxHead(
outbox,
Math.min(STORED_OUTBOX_RETRY_DEFAULT_MS, deadlineMs - now),
dependencies,
false,
);
return "blocked";
}
@@ -367,7 +346,7 @@ async function drainStoredChatOutbox(
lane: StoredChatOutboxDrainLane,
scope: StoredChatOutboxScope,
dependencies: ChatOutboxDrainDependencies,
): Promise<StoredChatOutboxDrainResult> {
): Promise<"blocked" | "empty"> {
while (true) {
const host = lane.host;
if (!host.connected || !host.client) {
@@ -643,16 +622,14 @@ export async function scheduleStoredChatOutboxDrain(
return undefined;
}
const key = storedChatOutboxScopeKey(scope);
const { lanes, retryTimers } = getStoredChatOutboxClientState(client);
const retryTimer = retryTimers.get(key);
if (retryTimer !== undefined) {
clearTimeout(retryTimer);
retryTimers.delete(key);
const { lanes } = getStoredChatOutboxClientState(client);
const candidateOwnsScope = visibleSessionMatches(host, scope.sessionKey, scope.agentId);
if (consumeChatOutboxRetry(host, key, candidateOwnsScope, itemId)) {
return undefined;
}
// Drain ownership follows the live client, never a disconnected pending RPC.
const existing = lanes.get(key);
if (existing) {
const candidateOwnsScope = visibleSessionMatches(host, scope.sessionKey, scope.agentId);
// Keep a connected visible owner for local commands across split-pane reruns.
if (
!existing.host.connected ||
@@ -691,6 +668,7 @@ export async function scheduleStoredChatOutboxDrain(
})();
try {
await lane.promise;
settleChatOutboxRetry(client, key);
return itemId ? lane.outcomes.get(itemId) : undefined;
} finally {
if (lanes.get(key) === lane) {
+116
View File
@@ -0,0 +1,116 @@
import { computeBackoff } from "@openclaw/retry";
import { GatewayRequestError, type GatewayBrowserClient } from "../../api/gateway.ts";
import type { ChatHost } from "./chat-send-contract.ts";
type RetryTimer = {
timer: ReturnType<typeof setTimeout>;
suppressGenericWake: boolean;
connectionEpoch: number | undefined;
host: ChatHost;
};
type RetryState = {
attempts: Map<string, number>;
timers: Map<string, RetryTimer>;
};
const RETRY_DEFAULT_MS = 500;
const RETRY_MAX_MS = 30_000;
const retryStates = new WeakMap<GatewayBrowserClient, RetryState>();
function retryState(client: GatewayBrowserClient): RetryState {
const state = retryStates.get(client) ?? { attempts: new Map(), timers: new Map() };
retryStates.set(client, state);
return state;
}
function retryOwnerStale(client: GatewayBrowserClient, retry: RetryTimer): boolean {
return (
!retry.host.connected ||
retry.host.client !== client ||
retry.connectionEpoch !== retry.host.connectionEpoch
);
}
export function retryableGatewayDelayMs(err: unknown): number | null {
if (!(err instanceof GatewayRequestError) || !err.retryable) {
return null;
}
return Math.min(Math.max(err.retryAfterMs ?? RETRY_DEFAULT_MS, 100), RETRY_MAX_MS);
}
export function scheduleChatOutboxRetry(
host: ChatHost,
key: string,
delayMs: number,
wake: (host: ChatHost) => void,
suppressGenericWake: boolean,
): void {
const client = host.client;
if (!host.connected || !client) {
return;
}
const state = retryState(client);
if (state.timers.has(key)) {
return;
}
const attempt = (state.attempts.get(key) ?? 0) + 1;
const retryDelayMs = suppressGenericWake
? computeBackoff({ initialMs: delayMs, maxMs: RETRY_MAX_MS, factor: 2, jitter: 0 }, attempt)
: delayMs;
if (suppressGenericWake) {
state.attempts.set(key, attempt);
} else {
state.attempts.delete(key);
}
const retry: RetryTimer = {
timer: setTimeout(() => {
state.timers.delete(key);
if (!retryOwnerStale(client, retry)) {
wake(retry.host);
}
}, retryDelayMs),
suppressGenericWake,
connectionEpoch: host.connectionEpoch,
host,
};
state.timers.set(key, retry);
}
export function consumeChatOutboxRetry(
host: ChatHost,
key: string,
candidateOwnsScope: boolean,
itemId?: string,
): boolean {
const client = host.client;
if (!client) {
return false;
}
const state = retryState(client);
const retry = state.timers.get(key);
if (!retry) {
return false;
}
const ownerStale = retryOwnerStale(client, retry);
if (!ownerStale && candidateOwnsScope) {
retry.connectionEpoch = host.connectionEpoch;
retry.host = host;
}
if (!itemId && !ownerStale && retry.suppressGenericWake) {
return true;
}
clearTimeout(retry.timer);
state.timers.delete(key);
if (itemId || ownerStale) {
state.attempts.delete(key);
}
return false;
}
export function settleChatOutboxRetry(client: GatewayBrowserClient, key: string): void {
const state = retryState(client);
if (!state.timers.has(key)) {
state.attempts.delete(key);
}
}
+1 -1
View File
@@ -10,7 +10,6 @@ import { readChatResetTargetAccess } from "./chat-commands.ts";
import { loadChatBranches, loadChatHistory } from "./chat-history.ts";
import {
flushStoredChatOutbox,
retryableGatewayDelayMs,
scheduleStoredChatOutboxDrain as scheduleOutboxDrain,
scheduleStoredChatOutboxRetry,
UNCONFIRMED_CHAT_SEND_ERROR,
@@ -19,6 +18,7 @@ import {
type QueuedChatSendResult,
type QueuedChatStorageMode,
} from "./chat-outbox-drain.ts";
import { retryableGatewayDelayMs } from "./chat-outbox-retry.ts";
import {
excludeComposerAttachments,
readQueuedMessageById,
+88
View File
@@ -6745,6 +6745,8 @@ describe("handleSendChat", () => {
expect(historyAttempts).toBe(1);
expect(sendAttempts).toBe(0);
await Promise.all(Array.from({ length: 20 }, () => retryReconnectableQueuedChatSends(host)));
expect(historyAttempts).toBe(1);
await vi.advanceTimersByTimeAsync(100);
// Same fire-and-forget retry hand-off as the send-rejection case above.
await waitForFast(() => {
@@ -6757,6 +6759,92 @@ describe("handleSendChat", () => {
}
});
it("retries after reconnecting with the same Gateway client", async () => {
let sendAttempts = 0;
const host = makeChatHost({
requestHandlers: {
"chat.history": {
messages: [],
sessionInfo: row("agent:main", { hasActiveRun: false, status: "done" }),
},
"chat.send": (params: unknown) => {
sendAttempts += 1;
if (sendAttempts === 1) {
throw new GatewayRequestError({
code: "UNAVAILABLE",
message: "Gateway is temporarily busy",
retryable: true,
retryAfterMs: 100,
});
}
const payload = requireRecord(params, "reconnected send payload");
return { runId: payload.idempotencyKey, status: "ok" };
},
},
chatMessage: "retry after reconnecting",
});
vi.useFakeTimers();
try {
await handleSendChat(host);
expect(sendAttempts).toBe(1);
host.connectionEpoch += 1;
await retryReconnectableQueuedChatSends(host);
expect(sendAttempts).toBe(2);
expect(listStoredChatOutboxes(host)).toStrictEqual([]);
} finally {
vi.useRealTimers();
}
});
it("transfers an active retry backoff to a sibling pane without bypassing it", async () => {
let historyAttempts = 0;
const owner = makeChatHost({
connectionEpoch: 1,
requestHandlers: {
"chat.history": () => {
historyAttempts += 1;
throw new GatewayRequestError({
code: "UNAVAILABLE",
message: "History is temporarily unavailable",
retryable: true,
retryAfterMs: 100,
});
},
},
chatQueue: [
{
id: "shared-retry",
text: "wait for backoff",
createdAt: 1,
sendRunId: "shared-retry-run",
sendState: "waiting-reconnect",
sessionKey: "agent:main",
},
],
});
admitHostQueueItems(owner);
const sibling = makeChatHost({
client: owner.client,
connectionEpoch: 2,
chatQueue: owner.chatQueue,
});
vi.useFakeTimers();
try {
await retryReconnectableQueuedChatSends(owner);
owner.sessionKey = "agent:main:other";
await retryReconnectableQueuedChatSends(sibling);
expect(historyAttempts).toBe(1);
await vi.advanceTimersByTimeAsync(100);
await waitForFast(() => expect(historyAttempts).toBe(2));
} finally {
vi.useRealTimers();
}
});
it("persists queueable local commands entered while disconnected", async () => {
executeSlashCommandMock.mockResolvedValueOnce({ content: "Thinking level set." });
const request = makeRequestMock({
+4 -4
View File
@@ -501,13 +501,13 @@ export function handlePageGatewayEvent(
}
if (event.event === "agent" || event.event === "session.tool") {
if (handleAgentEvent(state as never, event.payload as never)) {
requestChatPageUpdate(state);
requestChatPageUpdate(state, "animation-frame");
}
return;
}
if (event.event === "session.operation") {
handleSessionOperationEvent(state as never, event.payload as never);
requestChatPageUpdate(state);
requestChatPageUpdate(state, "animation-frame");
return;
}
if (event.event === "chat.send_timing") {
@@ -517,13 +517,13 @@ export function handlePageGatewayEvent(
if (event.event === "session.message") {
handleSessionMessageEvent(state, event.payload, isPresented);
void resumeStoredChatOutboxes(state);
requestChatPageUpdate(state);
requestChatPageUpdate(state, "animation-frame");
return;
}
if (event.event === "sessions.changed") {
handleSessionsChangedEvent(state, event.payload, isPresented);
void resumeStoredChatOutboxes(state);
requestChatPageUpdate(state);
requestChatPageUpdate(state, "animation-frame");
return;
}
if (event.event === "task") {
+19 -5
View File
@@ -908,7 +908,12 @@ describe("canonical session message recovery", () => {
expect(state.chatStream).toBe("Current partial reply");
});
it("renders distinct live peers immediately and coalesces their stale history", async () => {
it("coalesces distinct live peers into one frame and their stale history into one load", async () => {
let renderFrame: FrameRequestCallback | undefined;
vi.spyOn(globalThis, "requestAnimationFrame").mockImplementation((callback) => {
renderFrame = callback;
return 1;
});
let resolveHistory!: (result: {
messages: unknown[];
sessionId: string;
@@ -945,8 +950,10 @@ describe("canonical session message recovery", () => {
});
expect(state.chatMessages).toHaveLength(index + 1);
expect(state.requestUpdate).toHaveBeenCalledTimes(index + 1);
expect(state.requestUpdate).not.toHaveBeenCalled();
}
renderFrame?.(0);
expect(state.requestUpdate).toHaveBeenCalledOnce();
expect(request).toHaveBeenCalledOnce();
resolveHistory({
@@ -1746,7 +1753,10 @@ describe("ChatStateController render lifecycle", () => {
expect(state.waitingApprovalStatuses.size).toBe(0);
});
it("skips no-op assistant invalidation while tool changes render immediately", () => {
it("skips no-op assistant invalidation while tool changes render on the next frame", () => {
const requestAnimationFrame = vi
.spyOn(globalThis, "requestAnimationFrame")
.mockImplementation(() => 1);
const requestUpdate = vi.fn();
const state = createStreamEventState({
requestUpdate,
@@ -1768,7 +1778,9 @@ describe("ChatStateController render lifecycle", () => {
expect(requestUpdate).not.toHaveBeenCalled();
emitAgent(2, "tool", { phase: "start", name: "read", toolCallId: "tool-1" });
expect(requestUpdate).toHaveBeenCalledOnce();
emitAgent(3, "tool", { phase: "update", name: "read", toolCallId: "tool-1" });
expect(requestAnimationFrame).toHaveBeenCalledOnce();
expect(requestUpdate).not.toHaveBeenCalled();
});
it("coalesces stream invalidations into one animation frame", () => {
@@ -1814,9 +1826,11 @@ describe("ChatStateController render lifecycle", () => {
event: "session.operation",
payload: {},
});
expect(frames.size).toBe(1);
expect(requestUpdate).toHaveBeenCalledOnce();
staleFrame?.(0);
expect(cancelFrame).toHaveBeenCalledWith(2);
expect(cancelFrame).not.toHaveBeenCalledWith(2);
expect(requestUpdate).toHaveBeenCalledTimes(2);
expect(state.chatStreamRenderFrame).toBeNull();
});