fix(desktop): avoid O(n²) cloning during session load (#10665)

Co-authored-by: Czaruno <11508893+Czaruno@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Douwe M Osinga <douwe@sidewalklabs.com>
This commit is contained in:
Larry Velez
2026-08-04 08:19:50 -04:00
committed by GitHub
parent 1366eef3ac
commit 6a1344ba49
3 changed files with 54 additions and 4 deletions
@@ -539,10 +539,19 @@ describe('acpChatSessionStore', () => {
const loadingSnapshot = acpChatSessionActions.startSessionLoad(currentSessionId);
expect(loadingSnapshot.messages).toEqual([]);
// While a load replay is in flight, per-notification snapshots stay frozen
// at the load-start state (avoids O(n^2) cloning on large sessions); the
// replayed conversation materializes in the finishSessionLoad snapshot.
const replayedSnapshot = acpChatSessionActions.applyAcpSessionNotification(replayedChunk);
expect(replayedSnapshot.messages).toEqual([]);
expect(replayedSnapshot.messages).toHaveLength(1);
expect(replayedSnapshot.messages[0].content).toEqual([{ type: 'text', text: 'Hello' }]);
const finishedSnapshot = acpChatSessionActions.finishSessionLoad(
currentSessionId,
session(currentSessionId)
);
expect(finishedSnapshot.messages).toHaveLength(1);
expect(finishedSnapshot.messages[0].content).toEqual([{ type: 'text', text: 'Hello' }]);
});
it('applies permission requests as waiting action-required messages', () => {
+5 -1
View File
@@ -40,7 +40,11 @@ export const DEFAULT_VISIBLE_MESSAGE_METADATA: Message['metadata'] = {
};
export function messagesChange(state: AdapterState): AcpChatStateChange[] {
return [{ type: 'messages', messages: state.messages.map(cloneMessage) }];
// Pass the live array by reference: the store is the only consumer and it
// clones on write (applyChatStateChanges). Cloning here as well made every
// streamed chunk O(messages) twice, which turns session-load replay into
// O(n^2) on large sessions.
return [{ type: 'messages', messages: state.messages }];
}
export function cloneMessage(message: Message): Message {
+38 -1
View File
@@ -39,6 +39,10 @@ interface StoreEntry extends AcpChatSessionSnapshot {
pendingUserInputRequestIds: Set<string>;
pendingLocalSteerMessageIds: Set<string>;
preConfirmedSteerMessageIds: Set<string>;
// Cached result of the last notify(); reused while a session-load replay is
// in flight so per-notification reads don't deep-clone the growing message
// array (see applyAcpSessionNotification / getSnapshot).
lastSnapshot?: AcpChatSessionSnapshot;
}
const initialTokenState: TokenState = {
@@ -115,7 +119,16 @@ function createAcpChatSessionStoreInternal(): AcpChatSessionStoreInternal {
const getSnapshot: AcpChatSessionStore['getSnapshot'] = (sessionId) => {
const entry = sessionsById.get(sessionId);
return entry ? snapshotFromEntry(entry) : undefined;
if (!entry) {
return undefined;
}
// While a session-load replay is streaming in, serve the snapshot cached
// by the last notify() instead of deep-cloning the growing message array
// on every read (getSnapshot is called per replay notification).
if (entry.chatState === ChatState.LoadingConversation && entry.lastSnapshot) {
return entry.lastSnapshot;
}
return snapshotFromEntry(entry);
};
const subscribe: AcpChatSessionStoreInternal['subscribe'] = (sessionId, listener) => {
@@ -175,6 +188,7 @@ function createAcpChatSessionStoreInternal(): AcpChatSessionStoreInternal {
const notify = (sessionId: string, entry: StoreEntry): AcpChatSessionSnapshot => {
const snapshot = snapshotFromEntry(entry);
entry.lastSnapshot = snapshot;
const listeners = listenersBySessionId.get(sessionId);
if (listeners) {
for (const listener of listeners) {
@@ -204,6 +218,10 @@ function createAcpChatSessionStoreInternal(): AcpChatSessionStoreInternal {
entry.session = session;
entry.sessionLoadError = undefined;
entry.progressMessage = undefined;
// Materialize the replayed conversation in one pass (the per-notification
// fast path above skips message copies while loading).
entry.messages = entry.adapter.getMessages();
retainPendingLocalSteerMessageIds(entry);
entry.chatState = entry.activePromptAttemptId ? ChatState.Streaming : ChatState.Idle;
return notify(sessionId, entry);
};
@@ -435,6 +453,17 @@ function createAcpChatSessionStoreInternal(): AcpChatSessionStoreInternal {
entry.progressMessage = undefined;
}
const changes = entry.adapter.apply(notification);
// Session-load replay fast path: messages accumulate inside the adapter
// and are materialized once in finishSessionLoad. Copying them into the
// entry and re-notifying per replayed message is O(n^2) over the whole
// conversation and freezes the renderer on large sessions.
if (entry.chatState === ChatState.LoadingConversation && entry.lastSnapshot) {
applyChatStateChanges(
entry,
changes.filter((change) => change.type !== 'messages')
);
return entry.lastSnapshot;
}
applyChatStateChanges(entry, changes);
return notify(notification.sessionId, entry);
};
@@ -443,6 +472,14 @@ function createAcpChatSessionStoreInternal(): AcpChatSessionStoreInternal {
(notification) => {
const entry = getOrCreateEntry(notification.sessionId);
const changes = entry.adapter.applyGoose(notification);
// Same session-load replay fast path as applyAcpSessionNotification.
if (entry.chatState === ChatState.LoadingConversation && entry.lastSnapshot) {
applyChatStateChanges(
entry,
changes.filter((change) => change.type !== 'messages')
);
return entry.lastSnapshot;
}
applyChatStateChanges(entry, changes);
return notify(notification.sessionId, entry);
};