@@ -80,10 +80,29 @@ import {
8080 createTranscriptShadow,
8181 defaultStorage,
8282 diffTranscript,
83+ parseTranscriptRuntimeState,
84+ prefixFingerprint,
85+ restoreModelLane,
86+ type TranscriptChange,
8387 type TranscriptChangeReason,
88+ type TranscriptRuntimeState,
8489 type TranscriptShadow,
90+ type TranscriptStorage,
8591 type TranscriptStorageContext,
8692} from "./transcriptStorage.js";
93+
94+ let transcriptStorageOverride: TranscriptStorage<unknown> | undefined;
95+
96+ /**
97+ * Test-only override for the storage `chat.agent` persists through, so a
98+ * test can capture the exact changesets the runtime produces.
99+ * @internal
100+ */
101+ export function __setTranscriptStorageForTests(
102+ storage: TranscriptStorage<unknown> | undefined
103+ ): void {
104+ transcriptStorageOverride = storage;
105+ }
87106import {
88107 type ChatInputChunk,
89108 type ChatTaskWirePayload,
@@ -2521,6 +2540,13 @@ function spliceHandoverPartial(
25212540 * @internal
25222541 */
25232542const chatBackgroundQueueKey = locals.create<ModelMessage[]>("chat.backgroundQueue");
2543+ /**
2544+ * Background injections a step-boundary drain handed to the model this turn,
2545+ * with the transcript message they followed. Reconciled into the model lane
2546+ * and the persisted injections once the turn's response is in.
2547+ */
2548+ const chatPendingBackgroundKey =
2549+ locals.create<{ afterId: string; messages: ModelMessage[] }[]>("chat.pendingBackground");
25242550
25252551/**
25262552 * System-role context injected mid-conversation, held for the instructions lane.
@@ -4997,6 +5023,13 @@ function toStreamTextOptions(options?: ToStreamTextOptionsOptions): Record<strin
49975023 if (bgQueue && bgQueue.length > 0) {
49985024 const injected = bgQueue.splice(0); // drain
49995025 resultMessages = [...(resultMessages ?? messages), ...injected];
5026+ const pendingBackground = locals.get(chatPendingBackgroundKey) ?? [];
5027+ pendingBackground.push({
5028+ afterId:
5029+ (locals.get(chatCurrentUIMessagesKey) as UIMessage[] | undefined)?.at(-1)?.id ?? "",
5030+ messages: injected,
5031+ });
5032+ locals.set(chatPendingBackgroundKey, pendingBackground);
50005033 }
50015034
50025035 return resultMessages ? { messages: resultMessages } : undefined;
@@ -6874,6 +6907,23 @@ function chatAgent<
68746907 // durable snapshot + `session.out` replay (or `hydrateMessages` if
68756908 // registered) — the wire is delta-only now, no longer a seed.
68766909 let accumulatedMessages: ModelMessage[] = [];
6910+ /**
6911+ * Give the model accumulator the background injections a step-boundary
6912+ * drain handed to the model this turn, and record them for persistence.
6913+ * Returns how many model messages were appended.
6914+ */
6915+ const reconcilePendingBackground = (): number => {
6916+ const pending = locals.get(chatPendingBackgroundKey);
6917+ if (!pending || pending.length === 0) return 0;
6918+ locals.set(chatPendingBackgroundKey, []);
6919+ let appended = 0;
6920+ for (const entry of pending) {
6921+ accumulatedMessages.push(...entry.messages);
6922+ laneInjections.push(entry);
6923+ appended += entry.messages.length;
6924+ }
6925+ return appended;
6926+ };
68776927 /**
68786928 * Give the model accumulator the steering messages a drain consumed,
68796929 * in the form the model actually received. Appended, never reconverted
@@ -6913,8 +6963,18 @@ function chatAgent<
69136963 // collectively cost ~600ms on every first-message TTFC. Both reads
69146964 // swallow errors internally; the agent stays available either way.
69156965 const sessionIdForSnapshot = payload.sessionId ?? payload.chatId;
6916- const transcriptStorage = defaultStorage;
6966+ const transcriptStorage = transcriptStorageOverride ?? defaultStorage;
69176967 let transcriptShadow: TranscriptShadow = createTranscriptShadow([]);
6968+ let bootTranscriptState: unknown = null;
6969+ /**
6970+ * True while the model lane holds a compaction summary, so it cannot be
6971+ * rebuilt from the transcript and has to be persisted as state. Reset
6972+ * wherever the lane is reconverted from the UI lane.
6973+ */
6974+ let laneCompacted = false;
6975+ /** Conversational `chat.inject` messages in the lane, anchored to the transcript. */
6976+ let laneInjections: NonNullable<TranscriptRuntimeState["injections"]> = [];
6977+ let persistedStateSet = false;
69186978 let bootSnapshot:
69196979 | { messages: TUIMessage[]; lastOutEventId?: string; lastInEventId?: string }
69206980 | undefined;
@@ -6964,6 +7024,30 @@ function chatAgent<
69647024 const { changes, shadow } = diffTranscript(transcriptShadow, opts.messages, {
69657025 nonFinalIds: opts.nonFinalIds,
69667026 });
7027+ const throughId = opts.messages.at(-1)?.id ?? "";
7028+ const queued = locals.get(chatBackgroundQueueKey) ?? [];
7029+ const runtimeState: TranscriptRuntimeState | null =
7030+ laneCompacted || laneInjections.length > 0 || queued.length > 0
7031+ ? {
7032+ v: 1,
7033+ ...(laneCompacted
7034+ ? {
7035+ compaction: {
7036+ modelMessages: accumulatedMessages,
7037+ throughId,
7038+ fingerprint: prefixFingerprint(shadow, throughId),
7039+ },
7040+ }
7041+ : laneInjections.length > 0
7042+ ? { injections: laneInjections }
7043+ : {}),
7044+ ...(queued.length > 0 ? { queued: [...queued] } : {}),
7045+ }
7046+ : null;
7047+ if (runtimeState !== null || persistedStateSet) {
7048+ changes.push({ op: "state", value: runtimeState } satisfies TranscriptChange);
7049+ }
7050+ transcriptState = runtimeState;
69677051 const inCursor = chatInputRouter().resumeFloor();
69687052 await transcriptStorage.save(
69697053 {
@@ -6992,6 +7076,7 @@ function chatAgent<
69927076 }
69937077 );
69947078 transcriptShadow = shadow;
7079+ persistedStateSet = runtimeState !== null;
69957080 };
69967081
69977082 /**
@@ -7076,6 +7161,8 @@ function chatAgent<
70767161 clientData: bootClientData,
70777162 });
70787163 transcriptShadow = createTranscriptShadow(loaded.messages);
7164+ bootTranscriptState = loaded.state;
7165+ persistedStateSet = loaded.state !== null && loaded.state !== undefined;
70797166 bootSnapshot = {
70807167 messages: loaded.messages,
70817168 lastOutEventId: loaded.cursors?.lastOutEventId,
@@ -7420,7 +7507,21 @@ function chatAgent<
74207507 }
74217508 }
74227509 try {
7423- accumulatedMessages = await toModelMessages(accumulatedUIMessages);
7510+ const bootRuntimeState = parseTranscriptRuntimeState(bootTranscriptState);
7511+ const restored = await restoreModelLane(
7512+ accumulatedUIMessages,
7513+ bootRuntimeState,
7514+ (messages) => toModelMessages(messages)
7515+ );
7516+ accumulatedMessages = restored.messages;
7517+ laneCompacted = restored.compacted;
7518+ laneInjections = restored.injections;
7519+ if (bootRuntimeState?.queued && bootRuntimeState.queued.length > 0) {
7520+ locals.set(chatBackgroundQueueKey, [
7521+ ...(locals.get(chatBackgroundQueueKey) ?? []),
7522+ ...bootRuntimeState.queued,
7523+ ]);
7524+ }
74247525 } catch (error) {
74257526 logger.warn("chat.agent: toModelMessages failed at boot; starting empty", {
74267527 error: error instanceof Error ? error.message : String(error),
@@ -7949,6 +8050,7 @@ function chatAgent<
79498050 locals.set(chatDeferKey, new Set());
79508051 locals.set(chatCompactionStateKey, undefined);
79518052 locals.set(chatSteeringQueueKey, []);
8053+ locals.set(chatPendingBackgroundKey, []);
79528054 locals.set(chatResponsePartsKey, []);
79538055 // NOTE: chatBackgroundQueueKey is NOT reset here — messages injected
79548056 // by deferred work from the previous turn's onTurnComplete need to
@@ -8092,6 +8194,8 @@ function chatAgent<
80928194 );
80938195 accumulatedUIMessages = [...hydrated] as TUIMessage[];
80948196 accumulatedMessages = await toModelMessages(hydrated);
8197+ laneCompacted = false;
8198+ laneInjections = [];
80958199 locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
80968200 }
80978201
@@ -8129,6 +8233,8 @@ function chatAgent<
81298233 locals.set(chatOverrideMessagesKey, undefined);
81308234 accumulatedUIMessages = [...actionOverride] as TUIMessage[];
81318235 accumulatedMessages = await toModelMessages(actionOverride);
8236+ laneCompacted = false;
8237+ laneInjections = [];
81328238 locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
81338239
81348240 actionChangedHistory = true;
@@ -8261,6 +8367,8 @@ function chatAgent<
82618367
82628368 accumulatedUIMessages = merged;
82638369 accumulatedMessages = await toModelMessages(merged);
8370+ laneCompacted = false;
8371+ laneInjections = [];
82648372 locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
82658373
82668374 // Track new messages for onTurnComplete.newUIMessages.
@@ -8310,6 +8418,8 @@ function chatAgent<
83108418 accumulatedUIMessages.pop();
83118419 }
83128420 accumulatedMessages = await toModelMessages(accumulatedUIMessages);
8421+ laneCompacted = false;
8422+ laneInjections = [];
83138423 } else if (cleanedUIMessages.length > 0) {
83148424 // Submit-message (and the special-cased
83158425 // handover-prepare → submit-message rewrite earlier in
@@ -8363,6 +8473,8 @@ function chatAgent<
83638473 "chat.agent: replaced message not found at the model lane tail; reconverting the lane"
83648474 );
83658475 accumulatedMessages = await toModelMessages(accumulatedUIMessages);
8476+ laneCompacted = false;
8477+ laneInjections = [];
83668478 }
83678479 } else {
83688480 const incomingModelMessages = await toModelMessages(cleanedUIMessages);
@@ -8544,6 +8656,8 @@ function chatAgent<
85448656 locals.set(chatOverrideMessagesKey, undefined);
85458657 accumulatedUIMessages = [...turnStartOverride] as TUIMessage[];
85468658 accumulatedMessages = await toModelMessages(turnStartOverride);
8659+ laneCompacted = false;
8660+ laneInjections = [];
85478661 locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
85488662 }
85498663 },
@@ -8609,7 +8723,12 @@ function chatAgent<
86098723 const lastAccumulated = accumulatedMessages[accumulatedMessages.length - 1];
86108724 const bgQueue = locals.get(chatBackgroundQueueKey);
86118725 if (bgQueue && bgQueue.length > 0 && lastAccumulated?.role !== "tool") {
8612- accumulatedMessages.push(...bgQueue.splice(0));
8726+ const injected = bgQueue.splice(0);
8727+ accumulatedMessages.push(...injected);
8728+ laneInjections.push({
8729+ afterId: accumulatedUIMessages.at(-1)?.id ?? "",
8730+ messages: injected,
8731+ });
86138732 }
86148733
86158734 if (isHeadStartFinalTurn) {
@@ -8802,6 +8921,8 @@ function chatAgent<
88028921 accumulatedMessages = await toModelMessages(
88038922 runOverride.filter((m) => !pendingIds.has(m.id))
88048923 );
8924+ laneCompacted = false;
8925+ laneInjections = [];
88058926 locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
88068927 }
88078928
@@ -8827,6 +8948,8 @@ function chatAgent<
88278948 accumulatedMessages = taskCompactionConfig?.compactModelMessages
88288949 ? await taskCompactionConfig.compactModelMessages(compactEvent)
88298950 : modelOnlyOverride;
8951+ laneCompacted = true;
8952+ laneInjections = [];
88308953
88318954 // Apply UI messages: callback or default (preserve all)
88328955 if (taskCompactionConfig?.compactUIMessages) {
@@ -8845,9 +8968,10 @@ function chatAgent<
88458968 // before the response is appended so the order stays
88468969 // steer-then-answer. Outside the `capturedResponseMessage`
88478970 // branches below, so a turn that captured no response is covered.
8848- const steerTailThisTurn = reconcilePendingSteer({
8849- turnNew: turnNewModelMessages,
8850- }).reduce((n, e) => n + e.model.length, 0);
8971+ const steerTailThisTurn =
8972+ reconcilePendingSteer({
8973+ turnNew: turnNewModelMessages,
8974+ }).reduce((n, e) => n + e.model.length, 0) + reconcilePendingBackground();
88518975
88528976 // Append the assistant's response (partial or complete) to the accumulator.
88538977 // The onFinish callback fires even on abort/stop, so partial responses
@@ -8919,6 +9043,8 @@ function chatAgent<
89199043 "chat.agent: replaced response not found at the model lane tail; reconverting the lane"
89209044 );
89219045 accumulatedMessages = await toModelMessages(accumulatedUIMessages);
9046+ laneCompacted = false;
9047+ laneInjections = [];
89229048 }
89239049 } else {
89249050 accumulatedMessages.push(...responseModelMessages);
@@ -9038,6 +9164,9 @@ function chatAgent<
90389164 },
90399165 ];
90409166
9167+ laneCompacted = true;
9168+ laneInjections = [];
9169+
90419170 // UI messages: callback or default (preserve all)
90429171 if (outerCompaction.compactUIMessages) {
90439172 accumulatedUIMessages = (await outerCompaction.compactUIMessages(
@@ -9142,6 +9271,8 @@ function chatAgent<
91429271 locals.set(chatOverrideMessagesKey, undefined);
91439272 accumulatedUIMessages = [...override] as TUIMessage[];
91449273 accumulatedMessages = await toModelMessages(override);
9274+ laneCompacted = false;
9275+ laneInjections = [];
91459276 locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
91469277 // Update event so onTurnComplete sees compacted messages
91479278 turnCompleteEvent.messages = accumulatedMessages;
@@ -9201,6 +9332,8 @@ function chatAgent<
92019332 locals.set(chatOverrideMessagesKey, undefined);
92029333 accumulatedUIMessages = [...turnCompleteOverride] as TUIMessage[];
92039334 accumulatedMessages = await toModelMessages(turnCompleteOverride);
9335+ laneCompacted = false;
9336+ laneInjections = [];
92049337 locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
92059338 }
92069339 },
@@ -9532,6 +9665,7 @@ function chatAgent<
95329665 let erroredNewModelMessages: ModelMessage[] = [];
95339666
95349667 const reconciledSteer = reconcilePendingSteer();
9668+ const backgroundTailThisTurn = reconcilePendingBackground();
95359669
95369670 if (!responseCommitted) {
95379671 try {
@@ -9567,13 +9701,16 @@ function chatAgent<
95679701 accumulatedMessages,
95689702 erroredUIMessages[partialIdx]!,
95699703 partialResponse!,
9570- reconciledSteer.reduce((n, e) => n + e.model.length, 0)
9704+ reconciledSteer.reduce((n, e) => n + e.model.length, 0) +
9705+ backgroundTailThisTurn
95719706 );
95729707 if (!ok) {
95739708 logger.warn(
95749709 "chat.agent: replaced partial not found at the model lane tail; reconverting the lane"
95759710 );
95769711 accumulatedMessages = await toModelMessages(erroredUIMessagesWithPartial);
9712+ laneCompacted = false;
9713+ laneInjections = [];
95779714 }
95789715 }
95799716 accumulatedUIMessages = erroredUIMessagesWithPartial;
0 commit comments