@@ -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,
@@ -2515,6 +2534,13 @@ function spliceHandoverPartial(
25152534 * @internal
25162535 */
25172536const chatBackgroundQueueKey = locals.create<ModelMessage[]>("chat.backgroundQueue");
2537+ /**
2538+ * Background injections a step-boundary drain handed to the model this turn,
2539+ * with the transcript message they followed. Reconciled into the model lane
2540+ * and the persisted injections once the turn's response is in.
2541+ */
2542+ const chatPendingBackgroundKey =
2543+ locals.create<{ afterId: string; messages: ModelMessage[] }[]>("chat.pendingBackground");
25182544
25192545/**
25202546 * System-role context injected mid-conversation, held for the instructions lane.
@@ -4970,6 +4996,13 @@ function toStreamTextOptions(options?: ToStreamTextOptionsOptions): Record<strin
49704996 if (bgQueue && bgQueue.length > 0) {
49714997 const injected = bgQueue.splice(0); // drain
49724998 resultMessages = [...(resultMessages ?? messages), ...injected];
4999+ const pendingBackground = locals.get(chatPendingBackgroundKey) ?? [];
5000+ pendingBackground.push({
5001+ afterId:
5002+ (locals.get(chatCurrentUIMessagesKey) as UIMessage[] | undefined)?.at(-1)?.id ?? "",
5003+ messages: injected,
5004+ });
5005+ locals.set(chatPendingBackgroundKey, pendingBackground);
49735006 }
49745007
49755008 return resultMessages ? { messages: resultMessages } : undefined;
@@ -6831,6 +6864,23 @@ function chatAgent<
68316864 // durable snapshot + `session.out` replay (or `hydrateMessages` if
68326865 // registered) — the wire is delta-only now, no longer a seed.
68336866 let accumulatedMessages: ModelMessage[] = [];
6867+ /**
6868+ * Give the model accumulator the background injections a step-boundary
6869+ * drain handed to the model this turn, and record them for persistence.
6870+ * Returns how many model messages were appended.
6871+ */
6872+ const reconcilePendingBackground = (): number => {
6873+ const pending = locals.get(chatPendingBackgroundKey);
6874+ if (!pending || pending.length === 0) return 0;
6875+ locals.set(chatPendingBackgroundKey, []);
6876+ let appended = 0;
6877+ for (const entry of pending) {
6878+ accumulatedMessages.push(...entry.messages);
6879+ laneInjections.push(entry);
6880+ appended += entry.messages.length;
6881+ }
6882+ return appended;
6883+ };
68346884 /**
68356885 * Give the model accumulator the steering messages a drain consumed,
68366886 * in the form the model actually received. Appended, never reconverted
@@ -6870,8 +6920,18 @@ function chatAgent<
68706920 // collectively cost ~600ms on every first-message TTFC. Both reads
68716921 // swallow errors internally; the agent stays available either way.
68726922 const sessionIdForSnapshot = payload.sessionId ?? payload.chatId;
6873- const transcriptStorage = defaultStorage;
6923+ const transcriptStorage = transcriptStorageOverride ?? defaultStorage;
68746924 let transcriptShadow: TranscriptShadow = createTranscriptShadow([]);
6925+ let bootTranscriptState: unknown = null;
6926+ /**
6927+ * True while the model lane holds a compaction summary, so it cannot be
6928+ * rebuilt from the transcript and has to be persisted as state. Reset
6929+ * wherever the lane is reconverted from the UI lane.
6930+ */
6931+ let laneCompacted = false;
6932+ /** Conversational `chat.inject` messages in the lane, anchored to the transcript. */
6933+ let laneInjections: NonNullable<TranscriptRuntimeState["injections"]> = [];
6934+ let persistedStateSet = false;
68756935 let bootSnapshot:
68766936 | { messages: TUIMessage[]; lastOutEventId?: string; lastInEventId?: string }
68776937 | undefined;
@@ -6921,6 +6981,30 @@ function chatAgent<
69216981 const { changes, shadow } = diffTranscript(transcriptShadow, opts.messages, {
69226982 nonFinalIds: opts.nonFinalIds,
69236983 });
6984+ const throughId = opts.messages.at(-1)?.id ?? "";
6985+ const queued = locals.get(chatBackgroundQueueKey) ?? [];
6986+ const runtimeState: TranscriptRuntimeState | null =
6987+ laneCompacted || laneInjections.length > 0 || queued.length > 0
6988+ ? {
6989+ v: 1,
6990+ ...(laneCompacted
6991+ ? {
6992+ compaction: {
6993+ modelMessages: accumulatedMessages,
6994+ throughId,
6995+ fingerprint: prefixFingerprint(shadow, throughId),
6996+ },
6997+ }
6998+ : laneInjections.length > 0
6999+ ? { injections: laneInjections }
7000+ : {}),
7001+ ...(queued.length > 0 ? { queued: [...queued] } : {}),
7002+ }
7003+ : null;
7004+ if (runtimeState !== null || persistedStateSet) {
7005+ changes.push({ op: "state", value: runtimeState } satisfies TranscriptChange);
7006+ }
7007+ transcriptState = runtimeState;
69247008 const inCursor = chatInputRouter().resumeFloor();
69257009 await transcriptStorage.save(
69267010 {
@@ -6949,6 +7033,7 @@ function chatAgent<
69497033 }
69507034 );
69517035 transcriptShadow = shadow;
7036+ persistedStateSet = runtimeState !== null;
69527037 };
69537038
69547039 /**
@@ -7033,6 +7118,8 @@ function chatAgent<
70337118 clientData: bootClientData,
70347119 });
70357120 transcriptShadow = createTranscriptShadow(loaded.messages);
7121+ bootTranscriptState = loaded.state;
7122+ persistedStateSet = loaded.state !== null && loaded.state !== undefined;
70367123 bootSnapshot = {
70377124 messages: loaded.messages,
70387125 lastOutEventId: loaded.cursors?.lastOutEventId,
@@ -7377,7 +7464,21 @@ function chatAgent<
73777464 }
73787465 }
73797466 try {
7380- accumulatedMessages = await toModelMessages(accumulatedUIMessages);
7467+ const bootRuntimeState = parseTranscriptRuntimeState(bootTranscriptState);
7468+ const restored = await restoreModelLane(
7469+ accumulatedUIMessages,
7470+ bootRuntimeState,
7471+ (messages) => toModelMessages(messages)
7472+ );
7473+ accumulatedMessages = restored.messages;
7474+ laneCompacted = restored.compacted;
7475+ laneInjections = restored.injections;
7476+ if (bootRuntimeState?.queued && bootRuntimeState.queued.length > 0) {
7477+ locals.set(chatBackgroundQueueKey, [
7478+ ...(locals.get(chatBackgroundQueueKey) ?? []),
7479+ ...bootRuntimeState.queued,
7480+ ]);
7481+ }
73817482 } catch (error) {
73827483 logger.warn("chat.agent: toModelMessages failed at boot; starting empty", {
73837484 error: error instanceof Error ? error.message : String(error),
@@ -7906,6 +8007,7 @@ function chatAgent<
79068007 locals.set(chatDeferKey, new Set());
79078008 locals.set(chatCompactionStateKey, undefined);
79088009 locals.set(chatSteeringQueueKey, []);
8010+ locals.set(chatPendingBackgroundKey, []);
79098011 locals.set(chatResponsePartsKey, []);
79108012 // NOTE: chatBackgroundQueueKey is NOT reset here — messages injected
79118013 // by deferred work from the previous turn's onTurnComplete need to
@@ -8049,6 +8151,8 @@ function chatAgent<
80498151 );
80508152 accumulatedUIMessages = [...hydrated] as TUIMessage[];
80518153 accumulatedMessages = await toModelMessages(hydrated);
8154+ laneCompacted = false;
8155+ laneInjections = [];
80528156 locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
80538157 }
80548158
@@ -8086,6 +8190,8 @@ function chatAgent<
80868190 locals.set(chatOverrideMessagesKey, undefined);
80878191 accumulatedUIMessages = [...actionOverride] as TUIMessage[];
80888192 accumulatedMessages = await toModelMessages(actionOverride);
8193+ laneCompacted = false;
8194+ laneInjections = [];
80898195 locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
80908196
80918197 actionChangedHistory = true;
@@ -8218,6 +8324,8 @@ function chatAgent<
82188324
82198325 accumulatedUIMessages = merged;
82208326 accumulatedMessages = await toModelMessages(merged);
8327+ laneCompacted = false;
8328+ laneInjections = [];
82218329 locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
82228330
82238331 // Track new messages for onTurnComplete.newUIMessages.
@@ -8267,6 +8375,8 @@ function chatAgent<
82678375 accumulatedUIMessages.pop();
82688376 }
82698377 accumulatedMessages = await toModelMessages(accumulatedUIMessages);
8378+ laneCompacted = false;
8379+ laneInjections = [];
82708380 } else if (cleanedUIMessages.length > 0) {
82718381 // Submit-message (and the special-cased
82728382 // handover-prepare → submit-message rewrite earlier in
@@ -8320,6 +8430,8 @@ function chatAgent<
83208430 "chat.agent: replaced message not found at the model lane tail; reconverting the lane"
83218431 );
83228432 accumulatedMessages = await toModelMessages(accumulatedUIMessages);
8433+ laneCompacted = false;
8434+ laneInjections = [];
83238435 }
83248436 } else {
83258437 const incomingModelMessages = await toModelMessages(cleanedUIMessages);
@@ -8501,6 +8613,8 @@ function chatAgent<
85018613 locals.set(chatOverrideMessagesKey, undefined);
85028614 accumulatedUIMessages = [...turnStartOverride] as TUIMessage[];
85038615 accumulatedMessages = await toModelMessages(turnStartOverride);
8616+ laneCompacted = false;
8617+ laneInjections = [];
85048618 locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
85058619 }
85068620 },
@@ -8566,7 +8680,12 @@ function chatAgent<
85668680 const lastAccumulated = accumulatedMessages[accumulatedMessages.length - 1];
85678681 const bgQueue = locals.get(chatBackgroundQueueKey);
85688682 if (bgQueue && bgQueue.length > 0 && lastAccumulated?.role !== "tool") {
8569- accumulatedMessages.push(...bgQueue.splice(0));
8683+ const injected = bgQueue.splice(0);
8684+ accumulatedMessages.push(...injected);
8685+ laneInjections.push({
8686+ afterId: accumulatedUIMessages.at(-1)?.id ?? "",
8687+ messages: injected,
8688+ });
85708689 }
85718690
85728691 if (isHeadStartFinalTurn) {
@@ -8759,6 +8878,8 @@ function chatAgent<
87598878 accumulatedMessages = await toModelMessages(
87608879 runOverride.filter((m) => !pendingIds.has(m.id))
87618880 );
8881+ laneCompacted = false;
8882+ laneInjections = [];
87628883 locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
87638884 }
87648885
@@ -8784,6 +8905,8 @@ function chatAgent<
87848905 accumulatedMessages = taskCompactionConfig?.compactModelMessages
87858906 ? await taskCompactionConfig.compactModelMessages(compactEvent)
87868907 : modelOnlyOverride;
8908+ laneCompacted = true;
8909+ laneInjections = [];
87878910
87888911 // Apply UI messages: callback or default (preserve all)
87898912 if (taskCompactionConfig?.compactUIMessages) {
@@ -8802,9 +8925,10 @@ function chatAgent<
88028925 // before the response is appended so the order stays
88038926 // steer-then-answer. Outside the `capturedResponseMessage`
88048927 // branches below, so a turn that captured no response is covered.
8805- const steerTailThisTurn = reconcilePendingSteer({
8806- turnNew: turnNewModelMessages,
8807- }).reduce((n, e) => n + e.model.length, 0);
8928+ const steerTailThisTurn =
8929+ reconcilePendingSteer({
8930+ turnNew: turnNewModelMessages,
8931+ }).reduce((n, e) => n + e.model.length, 0) + reconcilePendingBackground();
88088932
88098933 // Append the assistant's response (partial or complete) to the accumulator.
88108934 // The onFinish callback fires even on abort/stop, so partial responses
@@ -8876,6 +9000,8 @@ function chatAgent<
88769000 "chat.agent: replaced response not found at the model lane tail; reconverting the lane"
88779001 );
88789002 accumulatedMessages = await toModelMessages(accumulatedUIMessages);
9003+ laneCompacted = false;
9004+ laneInjections = [];
88799005 }
88809006 } else {
88819007 accumulatedMessages.push(...responseModelMessages);
@@ -8995,6 +9121,9 @@ function chatAgent<
89959121 },
89969122 ];
89979123
9124+ laneCompacted = true;
9125+ laneInjections = [];
9126+
89989127 // UI messages: callback or default (preserve all)
89999128 if (outerCompaction.compactUIMessages) {
90009129 accumulatedUIMessages = (await outerCompaction.compactUIMessages(
@@ -9099,6 +9228,8 @@ function chatAgent<
90999228 locals.set(chatOverrideMessagesKey, undefined);
91009229 accumulatedUIMessages = [...override] as TUIMessage[];
91019230 accumulatedMessages = await toModelMessages(override);
9231+ laneCompacted = false;
9232+ laneInjections = [];
91029233 locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
91039234 // Update event so onTurnComplete sees compacted messages
91049235 turnCompleteEvent.messages = accumulatedMessages;
@@ -9158,6 +9289,8 @@ function chatAgent<
91589289 locals.set(chatOverrideMessagesKey, undefined);
91599290 accumulatedUIMessages = [...turnCompleteOverride] as TUIMessage[];
91609291 accumulatedMessages = await toModelMessages(turnCompleteOverride);
9292+ laneCompacted = false;
9293+ laneInjections = [];
91619294 locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
91629295 }
91639296 },
@@ -9484,6 +9617,7 @@ function chatAgent<
94849617 let erroredNewModelMessages: ModelMessage[] = [];
94859618
94869619 const reconciledSteer = reconcilePendingSteer();
9620+ const backgroundTailThisTurn = reconcilePendingBackground();
94879621
94889622 if (!responseCommitted) {
94899623 try {
@@ -9519,13 +9653,16 @@ function chatAgent<
95199653 accumulatedMessages,
95209654 erroredUIMessages[partialIdx]!,
95219655 partialResponse!,
9522- reconciledSteer.reduce((n, e) => n + e.model.length, 0)
9656+ reconciledSteer.reduce((n, e) => n + e.model.length, 0) +
9657+ backgroundTailThisTurn
95239658 );
95249659 if (!ok) {
95259660 logger.warn(
95269661 "chat.agent: replaced partial not found at the model lane tail; reconverting the lane"
95279662 );
95289663 accumulatedMessages = await toModelMessages(erroredUIMessagesWithPartial);
9664+ laneCompacted = false;
9665+ laneInjections = [];
95299666 }
95309667 }
95319668 accumulatedUIMessages = erroredUIMessagesWithPartial;
0 commit comments