@@ -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;
@@ -6921,6 +6950,24 @@ function chatAgent<
69216950 const { changes, shadow } = diffTranscript(transcriptShadow, opts.messages, {
69226951 nonFinalIds: opts.nonFinalIds,
69236952 });
6953+ const lastId = opts.messages.at(-1)?.id;
6954+ const runtimeState: TranscriptRuntimeState | null =
6955+ laneCompacted && lastId !== undefined
6956+ ? {
6957+ v: 1,
6958+ compaction: {
6959+ modelMessages: accumulatedMessages,
6960+ throughId: lastId,
6961+ fingerprint: prefixFingerprint(shadow, lastId),
6962+ },
6963+ }
6964+ : laneInjections.length > 0
6965+ ? { v: 1, injections: laneInjections }
6966+ : null;
6967+ if (runtimeState !== null || persistedStateSet) {
6968+ changes.push({ op: "state", value: runtimeState } satisfies TranscriptChange);
6969+ }
6970+ transcriptState = runtimeState;
69246971 const inCursor = chatInputRouter().resumeFloor();
69256972 await transcriptStorage.save(
69266973 {
@@ -6949,6 +6996,7 @@ function chatAgent<
69496996 }
69506997 );
69516998 transcriptShadow = shadow;
6999+ persistedStateSet = runtimeState !== null;
69527000 };
69537001
69547002 /**
@@ -7033,6 +7081,8 @@ function chatAgent<
70337081 clientData: bootClientData,
70347082 });
70357083 transcriptShadow = createTranscriptShadow(loaded.messages);
7084+ bootTranscriptState = loaded.state;
7085+ persistedStateSet = loaded.state !== null && loaded.state !== undefined;
70367086 bootSnapshot = {
70377087 messages: loaded.messages,
70387088 lastOutEventId: loaded.cursors?.lastOutEventId,
@@ -7377,7 +7427,14 @@ function chatAgent<
73777427 }
73787428 }
73797429 try {
7380- accumulatedMessages = await toModelMessages(accumulatedUIMessages);
7430+ const restored = await restoreModelLane(
7431+ accumulatedUIMessages,
7432+ parseTranscriptRuntimeState(bootTranscriptState),
7433+ (messages) => toModelMessages(messages)
7434+ );
7435+ accumulatedMessages = restored.messages;
7436+ laneCompacted = restored.compacted;
7437+ laneInjections = restored.injections;
73817438 } catch (error) {
73827439 logger.warn("chat.agent: toModelMessages failed at boot; starting empty", {
73837440 error: error instanceof Error ? error.message : String(error),
@@ -8049,6 +8106,8 @@ function chatAgent<
80498106 );
80508107 accumulatedUIMessages = [...hydrated] as TUIMessage[];
80518108 accumulatedMessages = await toModelMessages(hydrated);
8109+ laneCompacted = false;
8110+ laneInjections = [];
80528111 locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
80538112 }
80548113
@@ -8086,6 +8145,8 @@ function chatAgent<
80868145 locals.set(chatOverrideMessagesKey, undefined);
80878146 accumulatedUIMessages = [...actionOverride] as TUIMessage[];
80888147 accumulatedMessages = await toModelMessages(actionOverride);
8148+ laneCompacted = false;
8149+ laneInjections = [];
80898150 locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
80908151
80918152 actionChangedHistory = true;
@@ -8218,6 +8279,8 @@ function chatAgent<
82188279
82198280 accumulatedUIMessages = merged;
82208281 accumulatedMessages = await toModelMessages(merged);
8282+ laneCompacted = false;
8283+ laneInjections = [];
82218284 locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
82228285
82238286 // Track new messages for onTurnComplete.newUIMessages.
@@ -8267,6 +8330,8 @@ function chatAgent<
82678330 accumulatedUIMessages.pop();
82688331 }
82698332 accumulatedMessages = await toModelMessages(accumulatedUIMessages);
8333+ laneCompacted = false;
8334+ laneInjections = [];
82708335 } else if (cleanedUIMessages.length > 0) {
82718336 // Submit-message (and the special-cased
82728337 // handover-prepare → submit-message rewrite earlier in
@@ -8320,6 +8385,8 @@ function chatAgent<
83208385 "chat.agent: replaced message not found at the model lane tail; reconverting the lane"
83218386 );
83228387 accumulatedMessages = await toModelMessages(accumulatedUIMessages);
8388+ laneCompacted = false;
8389+ laneInjections = [];
83238390 }
83248391 } else {
83258392 const incomingModelMessages = await toModelMessages(cleanedUIMessages);
@@ -8501,6 +8568,8 @@ function chatAgent<
85018568 locals.set(chatOverrideMessagesKey, undefined);
85028569 accumulatedUIMessages = [...turnStartOverride] as TUIMessage[];
85038570 accumulatedMessages = await toModelMessages(turnStartOverride);
8571+ laneCompacted = false;
8572+ laneInjections = [];
85048573 locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
85058574 }
85068575 },
@@ -8566,7 +8635,12 @@ function chatAgent<
85668635 const lastAccumulated = accumulatedMessages[accumulatedMessages.length - 1];
85678636 const bgQueue = locals.get(chatBackgroundQueueKey);
85688637 if (bgQueue && bgQueue.length > 0 && lastAccumulated?.role !== "tool") {
8569- accumulatedMessages.push(...bgQueue.splice(0));
8638+ const injected = bgQueue.splice(0);
8639+ accumulatedMessages.push(...injected);
8640+ laneInjections.push({
8641+ afterId: accumulatedUIMessages.at(-1)?.id ?? "",
8642+ messages: injected,
8643+ });
85708644 }
85718645
85728646 if (isHeadStartFinalTurn) {
@@ -8759,6 +8833,8 @@ function chatAgent<
87598833 accumulatedMessages = await toModelMessages(
87608834 runOverride.filter((m) => !pendingIds.has(m.id))
87618835 );
8836+ laneCompacted = false;
8837+ laneInjections = [];
87628838 locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
87638839 }
87648840
@@ -8784,6 +8860,8 @@ function chatAgent<
87848860 accumulatedMessages = taskCompactionConfig?.compactModelMessages
87858861 ? await taskCompactionConfig.compactModelMessages(compactEvent)
87868862 : modelOnlyOverride;
8863+ laneCompacted = true;
8864+ laneInjections = [];
87878865
87888866 // Apply UI messages: callback or default (preserve all)
87898867 if (taskCompactionConfig?.compactUIMessages) {
@@ -8876,6 +8954,8 @@ function chatAgent<
88768954 "chat.agent: replaced response not found at the model lane tail; reconverting the lane"
88778955 );
88788956 accumulatedMessages = await toModelMessages(accumulatedUIMessages);
8957+ laneCompacted = false;
8958+ laneInjections = [];
88798959 }
88808960 } else {
88818961 accumulatedMessages.push(...responseModelMessages);
@@ -8995,6 +9075,9 @@ function chatAgent<
89959075 },
89969076 ];
89979077
9078+ laneCompacted = true;
9079+ laneInjections = [];
9080+
89989081 // UI messages: callback or default (preserve all)
89999082 if (outerCompaction.compactUIMessages) {
90009083 accumulatedUIMessages = (await outerCompaction.compactUIMessages(
@@ -9099,6 +9182,8 @@ function chatAgent<
90999182 locals.set(chatOverrideMessagesKey, undefined);
91009183 accumulatedUIMessages = [...override] as TUIMessage[];
91019184 accumulatedMessages = await toModelMessages(override);
9185+ laneCompacted = false;
9186+ laneInjections = [];
91029187 locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
91039188 // Update event so onTurnComplete sees compacted messages
91049189 turnCompleteEvent.messages = accumulatedMessages;
@@ -9158,6 +9243,8 @@ function chatAgent<
91589243 locals.set(chatOverrideMessagesKey, undefined);
91599244 accumulatedUIMessages = [...turnCompleteOverride] as TUIMessage[];
91609245 accumulatedMessages = await toModelMessages(turnCompleteOverride);
9246+ laneCompacted = false;
9247+ laneInjections = [];
91619248 locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
91629249 }
91639250 },
@@ -9526,6 +9613,8 @@ function chatAgent<
95269613 "chat.agent: replaced partial not found at the model lane tail; reconverting the lane"
95279614 );
95289615 accumulatedMessages = await toModelMessages(erroredUIMessagesWithPartial);
9616+ laneCompacted = false;
9617+ laneInjections = [];
95299618 }
95309619 }
95319620 accumulatedUIMessages = erroredUIMessagesWithPartial;
0 commit comments