@@ -76,10 +76,29 @@ import {
7676 createTranscriptShadow,
7777 defaultStorage,
7878 diffTranscript,
79+ parseTranscriptRuntimeState,
80+ prefixFingerprint,
81+ restoreModelLane,
82+ type TranscriptChange,
7983 type TranscriptChangeReason,
84+ type TranscriptRuntimeState,
8085 type TranscriptShadow,
86+ type TranscriptStorage,
8187 type TranscriptStorageContext,
8288} from "./transcriptStorage.js";
89+
90+ let transcriptStorageOverride: TranscriptStorage<unknown> | undefined;
91+
92+ /**
93+ * Test-only override for the storage `chat.agent` persists through, so a
94+ * test can capture the exact changesets the runtime produces.
95+ * @internal
96+ */
97+ export function __setTranscriptStorageForTests(
98+ storage: TranscriptStorage<unknown> | undefined
99+ ): void {
100+ transcriptStorageOverride = storage;
101+ }
83102import {
84103 type ChatInputChunk,
85104 type ChatTaskWirePayload,
@@ -6870,8 +6889,18 @@ function chatAgent<
68706889 // collectively cost ~600ms on every first-message TTFC. Both reads
68716890 // swallow errors internally; the agent stays available either way.
68726891 const sessionIdForSnapshot = payload.sessionId ?? payload.chatId;
6873- const transcriptStorage = defaultStorage;
6892+ const transcriptStorage = transcriptStorageOverride ?? defaultStorage;
68746893 let transcriptShadow: TranscriptShadow = createTranscriptShadow([]);
6894+ let bootTranscriptState: unknown = null;
6895+ /**
6896+ * True while the model lane holds a compaction summary, so it cannot be
6897+ * rebuilt from the transcript and has to be persisted as state. Reset
6898+ * wherever the lane is reconverted from the UI lane.
6899+ */
6900+ let laneCompacted = false;
6901+ /** Conversational `chat.inject` messages in the lane, anchored to the transcript. */
6902+ let laneInjections: NonNullable<TranscriptRuntimeState["injections"]> = [];
6903+ let persistedStateSet = false;
68756904 let bootSnapshot:
68766905 | { messages: TUIMessage[]; lastOutEventId?: string; lastInEventId?: string }
68776906 | undefined;
@@ -6919,6 +6948,24 @@ function chatAgent<
69196948 const { changes, shadow } = diffTranscript(transcriptShadow, opts.messages, {
69206949 nonFinalIds: opts.nonFinalIds,
69216950 });
6951+ const lastId = opts.messages.at(-1)?.id;
6952+ const runtimeState: TranscriptRuntimeState | null =
6953+ laneCompacted && lastId !== undefined
6954+ ? {
6955+ v: 1,
6956+ compaction: {
6957+ modelMessages: accumulatedMessages,
6958+ throughId: lastId,
6959+ fingerprint: prefixFingerprint(shadow, lastId),
6960+ },
6961+ }
6962+ : laneInjections.length > 0
6963+ ? { v: 1, injections: laneInjections }
6964+ : null;
6965+ if (runtimeState !== null || persistedStateSet) {
6966+ changes.push({ op: "state", value: runtimeState } satisfies TranscriptChange);
6967+ }
6968+ transcriptState = runtimeState;
69226969 const inCursor = chatInputRouter().resumeFloor();
69236970 await transcriptStorage.save(
69246971 {
@@ -6947,6 +6994,7 @@ function chatAgent<
69476994 }
69486995 );
69496996 transcriptShadow = shadow;
6997+ persistedStateSet = runtimeState !== null;
69506998 };
69516999
69527000 /**
@@ -7031,6 +7079,8 @@ function chatAgent<
70317079 clientData: bootClientData,
70327080 });
70337081 transcriptShadow = createTranscriptShadow(loaded.messages);
7082+ bootTranscriptState = loaded.state;
7083+ persistedStateSet = loaded.state !== null && loaded.state !== undefined;
70347084 bootSnapshot = {
70357085 messages: loaded.messages,
70367086 lastOutEventId: loaded.cursors?.lastOutEventId,
@@ -7375,7 +7425,14 @@ function chatAgent<
73757425 }
73767426 }
73777427 try {
7378- accumulatedMessages = await toModelMessages(accumulatedUIMessages);
7428+ const restored = await restoreModelLane(
7429+ accumulatedUIMessages,
7430+ parseTranscriptRuntimeState(bootTranscriptState),
7431+ (messages) => toModelMessages(messages)
7432+ );
7433+ accumulatedMessages = restored.messages;
7434+ laneCompacted = restored.compacted;
7435+ laneInjections = restored.injections;
73797436 } catch (error) {
73807437 logger.warn("chat.agent: toModelMessages failed at boot; starting empty", {
73817438 error: error instanceof Error ? error.message : String(error),
@@ -8047,6 +8104,8 @@ function chatAgent<
80478104 );
80488105 accumulatedUIMessages = [...hydrated] as TUIMessage[];
80498106 accumulatedMessages = await toModelMessages(hydrated);
8107+ laneCompacted = false;
8108+ laneInjections = [];
80508109 locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
80518110 }
80528111
@@ -8084,6 +8143,8 @@ function chatAgent<
80848143 locals.set(chatOverrideMessagesKey, undefined);
80858144 accumulatedUIMessages = [...actionOverride] as TUIMessage[];
80868145 accumulatedMessages = await toModelMessages(actionOverride);
8146+ laneCompacted = false;
8147+ laneInjections = [];
80878148 locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
80888149
80898150 actionChangedHistory = true;
@@ -8216,6 +8277,8 @@ function chatAgent<
82168277
82178278 accumulatedUIMessages = merged;
82188279 accumulatedMessages = await toModelMessages(merged);
8280+ laneCompacted = false;
8281+ laneInjections = [];
82198282 locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
82208283
82218284 // Track new messages for onTurnComplete.newUIMessages.
@@ -8265,6 +8328,8 @@ function chatAgent<
82658328 accumulatedUIMessages.pop();
82668329 }
82678330 accumulatedMessages = await toModelMessages(accumulatedUIMessages);
8331+ laneCompacted = false;
8332+ laneInjections = [];
82688333 } else if (cleanedUIMessages.length > 0) {
82698334 // Submit-message (and the special-cased
82708335 // handover-prepare → submit-message rewrite earlier in
@@ -8318,6 +8383,8 @@ function chatAgent<
83188383 "chat.agent: replaced message not found at the model lane tail; reconverting the lane"
83198384 );
83208385 accumulatedMessages = await toModelMessages(accumulatedUIMessages);
8386+ laneCompacted = false;
8387+ laneInjections = [];
83218388 }
83228389 } else {
83238390 const incomingModelMessages = await toModelMessages(cleanedUIMessages);
@@ -8499,6 +8566,8 @@ function chatAgent<
84998566 locals.set(chatOverrideMessagesKey, undefined);
85008567 accumulatedUIMessages = [...turnStartOverride] as TUIMessage[];
85018568 accumulatedMessages = await toModelMessages(turnStartOverride);
8569+ laneCompacted = false;
8570+ laneInjections = [];
85028571 locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
85038572 }
85048573 },
@@ -8564,7 +8633,12 @@ function chatAgent<
85648633 const lastAccumulated = accumulatedMessages[accumulatedMessages.length - 1];
85658634 const bgQueue = locals.get(chatBackgroundQueueKey);
85668635 if (bgQueue && bgQueue.length > 0 && lastAccumulated?.role !== "tool") {
8567- accumulatedMessages.push(...bgQueue.splice(0));
8636+ const injected = bgQueue.splice(0);
8637+ accumulatedMessages.push(...injected);
8638+ laneInjections.push({
8639+ afterId: accumulatedUIMessages.at(-1)?.id ?? "",
8640+ messages: injected,
8641+ });
85688642 }
85698643
85708644 if (isHeadStartFinalTurn) {
@@ -8757,6 +8831,8 @@ function chatAgent<
87578831 accumulatedMessages = await toModelMessages(
87588832 runOverride.filter((m) => !pendingIds.has(m.id))
87598833 );
8834+ laneCompacted = false;
8835+ laneInjections = [];
87608836 locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
87618837 }
87628838
@@ -8782,6 +8858,8 @@ function chatAgent<
87828858 accumulatedMessages = taskCompactionConfig?.compactModelMessages
87838859 ? await taskCompactionConfig.compactModelMessages(compactEvent)
87848860 : modelOnlyOverride;
8861+ laneCompacted = true;
8862+ laneInjections = [];
87858863
87868864 // Apply UI messages: callback or default (preserve all)
87878865 if (taskCompactionConfig?.compactUIMessages) {
@@ -8874,6 +8952,8 @@ function chatAgent<
88748952 "chat.agent: replaced response not found at the model lane tail; reconverting the lane"
88758953 );
88768954 accumulatedMessages = await toModelMessages(accumulatedUIMessages);
8955+ laneCompacted = false;
8956+ laneInjections = [];
88778957 }
88788958 } else {
88798959 accumulatedMessages.push(...responseModelMessages);
@@ -8993,6 +9073,9 @@ function chatAgent<
89939073 },
89949074 ];
89959075
9076+ laneCompacted = true;
9077+ laneInjections = [];
9078+
89969079 // UI messages: callback or default (preserve all)
89979080 if (outerCompaction.compactUIMessages) {
89989081 accumulatedUIMessages = (await outerCompaction.compactUIMessages(
@@ -9097,6 +9180,8 @@ function chatAgent<
90979180 locals.set(chatOverrideMessagesKey, undefined);
90989181 accumulatedUIMessages = [...override] as TUIMessage[];
90999182 accumulatedMessages = await toModelMessages(override);
9183+ laneCompacted = false;
9184+ laneInjections = [];
91009185 locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
91019186 // Update event so onTurnComplete sees compacted messages
91029187 turnCompleteEvent.messages = accumulatedMessages;
@@ -9156,6 +9241,8 @@ function chatAgent<
91569241 locals.set(chatOverrideMessagesKey, undefined);
91579242 accumulatedUIMessages = [...turnCompleteOverride] as TUIMessage[];
91589243 accumulatedMessages = await toModelMessages(turnCompleteOverride);
9244+ laneCompacted = false;
9245+ laneInjections = [];
91599246 locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
91609247 }
91619248 },
@@ -9524,6 +9611,8 @@ function chatAgent<
95249611 "chat.agent: replaced partial not found at the model lane tail; reconverting the lane"
95259612 );
95269613 accumulatedMessages = await toModelMessages(erroredUIMessagesWithPartial);
9614+ laneCompacted = false;
9615+ laneInjections = [];
95279616 }
95289617 }
95299618 accumulatedUIMessages = erroredUIMessagesWithPartial;
0 commit comments