@@ -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,
@@ -2542,6 +2561,13 @@ function spliceHandoverPartial(
25422561 * @internal
25432562 */
25442563const chatBackgroundQueueKey = locals.create<ModelMessage[]>("chat.backgroundQueue");
2564+ /**
2565+ * Background injections a step-boundary drain handed to the model this turn,
2566+ * with the transcript message they followed. Reconciled into the model lane
2567+ * and the persisted injections once the turn's response is in.
2568+ */
2569+ const chatPendingBackgroundKey =
2570+ locals.create<{ afterId: string; messages: ModelMessage[] }[]>("chat.pendingBackground");
25452571
25462572/**
25472573 * System-role context injected mid-conversation, held for the instructions lane.
@@ -5022,6 +5048,13 @@ function toStreamTextOptions(options?: ToStreamTextOptionsOptions): Record<strin
50225048 if (bgQueue && bgQueue.length > 0) {
50235049 const injected = bgQueue.splice(0); // drain
50245050 resultMessages = [...(resultMessages ?? messages), ...injected];
5051+ const pendingBackground = locals.get(chatPendingBackgroundKey) ?? [];
5052+ pendingBackground.push({
5053+ afterId:
5054+ (locals.get(chatCurrentUIMessagesKey) as UIMessage[] | undefined)?.at(-1)?.id ?? "",
5055+ messages: injected,
5056+ });
5057+ locals.set(chatPendingBackgroundKey, pendingBackground);
50255058 }
50265059
50275060 return resultMessages ? { messages: resultMessages } : undefined;
@@ -6912,6 +6945,23 @@ function chatAgent<
69126945 // durable snapshot + `session.out` replay (or `hydrateMessages` if
69136946 // registered) — the wire is delta-only now, no longer a seed.
69146947 let accumulatedMessages: ModelMessage[] = [];
6948+ /**
6949+ * Give the model accumulator the background injections a step-boundary
6950+ * drain handed to the model this turn, and record them for persistence.
6951+ * Returns how many model messages were appended.
6952+ */
6953+ const reconcilePendingBackground = (): number => {
6954+ const pending = locals.get(chatPendingBackgroundKey);
6955+ if (!pending || pending.length === 0) return 0;
6956+ locals.set(chatPendingBackgroundKey, []);
6957+ let appended = 0;
6958+ for (const entry of pending) {
6959+ accumulatedMessages.push(...entry.messages);
6960+ laneInjections.push(entry);
6961+ appended += entry.messages.length;
6962+ }
6963+ return appended;
6964+ };
69156965 /**
69166966 * Give the model accumulator the steering messages a drain consumed,
69176967 * in the form the model actually received. Appended, never reconverted
@@ -6951,8 +7001,18 @@ function chatAgent<
69517001 // collectively cost ~600ms on every first-message TTFC. Both reads
69527002 // swallow errors internally; the agent stays available either way.
69537003 const sessionIdForSnapshot = payload.sessionId ?? payload.chatId;
6954- const transcriptStorage = defaultStorage;
7004+ const transcriptStorage = transcriptStorageOverride ?? defaultStorage;
69557005 let transcriptShadow: TranscriptShadow = createTranscriptShadow([]);
7006+ let bootTranscriptState: unknown = null;
7007+ /**
7008+ * True while the model lane holds a compaction summary, so it cannot be
7009+ * rebuilt from the transcript and has to be persisted as state. Reset
7010+ * wherever the lane is reconverted from the UI lane.
7011+ */
7012+ let laneCompacted = false;
7013+ /** Conversational `chat.inject` messages in the lane, anchored to the transcript. */
7014+ let laneInjections: NonNullable<TranscriptRuntimeState["injections"]> = [];
7015+ let persistedStateSet = false;
69567016 let bootSnapshot:
69577017 | { messages: TUIMessage[]; lastOutEventId?: string; lastInEventId?: string }
69587018 | undefined;
@@ -7002,6 +7062,30 @@ function chatAgent<
70027062 const { changes, shadow } = diffTranscript(transcriptShadow, opts.messages, {
70037063 nonFinalIds: opts.nonFinalIds,
70047064 });
7065+ const throughId = opts.messages.at(-1)?.id ?? "";
7066+ const queued = locals.get(chatBackgroundQueueKey) ?? [];
7067+ const runtimeState: TranscriptRuntimeState | null =
7068+ laneCompacted || laneInjections.length > 0 || queued.length > 0
7069+ ? {
7070+ v: 1,
7071+ ...(laneCompacted
7072+ ? {
7073+ compaction: {
7074+ modelMessages: accumulatedMessages,
7075+ throughId,
7076+ fingerprint: prefixFingerprint(shadow, throughId),
7077+ },
7078+ }
7079+ : laneInjections.length > 0
7080+ ? { injections: laneInjections }
7081+ : {}),
7082+ ...(queued.length > 0 ? { queued: [...queued] } : {}),
7083+ }
7084+ : null;
7085+ if (runtimeState !== null || persistedStateSet) {
7086+ changes.push({ op: "state", value: runtimeState } satisfies TranscriptChange);
7087+ }
7088+ transcriptState = runtimeState;
70057089 const inCursor = chatInputRouter().resumeFloor();
70067090 await transcriptStorage.save(
70077091 {
@@ -7030,6 +7114,7 @@ function chatAgent<
70307114 }
70317115 );
70327116 transcriptShadow = shadow;
7117+ persistedStateSet = runtimeState !== null;
70337118 };
70347119
70357120 /**
@@ -7114,6 +7199,8 @@ function chatAgent<
71147199 clientData: bootClientData,
71157200 });
71167201 transcriptShadow = createTranscriptShadow(loaded.messages);
7202+ bootTranscriptState = loaded.state;
7203+ persistedStateSet = loaded.state !== null && loaded.state !== undefined;
71177204 bootSnapshot = {
71187205 messages: loaded.messages,
71197206 lastOutEventId: loaded.cursors?.lastOutEventId,
@@ -7453,7 +7540,21 @@ function chatAgent<
74537540 }
74547541 }
74557542 try {
7456- accumulatedMessages = await toModelMessages(accumulatedUIMessages);
7543+ const bootRuntimeState = parseTranscriptRuntimeState(bootTranscriptState);
7544+ const restored = await restoreModelLane(
7545+ accumulatedUIMessages,
7546+ bootRuntimeState,
7547+ (messages) => toModelMessages(messages)
7548+ );
7549+ accumulatedMessages = restored.messages;
7550+ laneCompacted = restored.compacted;
7551+ laneInjections = restored.injections;
7552+ if (bootRuntimeState?.queued && bootRuntimeState.queued.length > 0) {
7553+ locals.set(chatBackgroundQueueKey, [
7554+ ...(locals.get(chatBackgroundQueueKey) ?? []),
7555+ ...bootRuntimeState.queued,
7556+ ]);
7557+ }
74577558 } catch (error) {
74587559 logger.warn("chat.agent: toModelMessages failed at boot; starting empty", {
74597560 error: error instanceof Error ? error.message : String(error),
@@ -7982,6 +8083,7 @@ function chatAgent<
79828083 locals.set(chatDeferKey, new Set());
79838084 locals.set(chatCompactionStateKey, undefined);
79848085 locals.set(chatSteeringQueueKey, []);
8086+ locals.set(chatPendingBackgroundKey, []);
79858087 locals.set(chatResponsePartsKey, []);
79868088 // NOTE: chatBackgroundQueueKey is NOT reset here — messages injected
79878089 // by deferred work from the previous turn's onTurnComplete need to
@@ -8128,6 +8230,8 @@ function chatAgent<
81288230 );
81298231 accumulatedUIMessages = [...hydrated] as TUIMessage[];
81308232 accumulatedMessages = await toModelMessages(hydrated);
8233+ laneCompacted = false;
8234+ laneInjections = [];
81318235 locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
81328236 }
81338237
@@ -8165,6 +8269,8 @@ function chatAgent<
81658269 locals.set(chatOverrideMessagesKey, undefined);
81668270 accumulatedUIMessages = [...actionOverride] as TUIMessage[];
81678271 accumulatedMessages = await toModelMessages(actionOverride);
8272+ laneCompacted = false;
8273+ laneInjections = [];
81688274 locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
81698275
81708276 actionChangedHistory = true;
@@ -8297,6 +8403,8 @@ function chatAgent<
82978403
82988404 accumulatedUIMessages = merged;
82998405 accumulatedMessages = await toModelMessages(merged);
8406+ laneCompacted = false;
8407+ laneInjections = [];
83008408 locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
83018409
83028410 // Track new messages for onTurnComplete.newUIMessages.
@@ -8346,6 +8454,8 @@ function chatAgent<
83468454 accumulatedUIMessages.pop();
83478455 }
83488456 accumulatedMessages = await toModelMessages(accumulatedUIMessages);
8457+ laneCompacted = false;
8458+ laneInjections = [];
83498459 } else if (cleanedUIMessages.length > 0) {
83508460 // Submit-message (and the special-cased
83518461 // handover-prepare → submit-message rewrite earlier in
@@ -8399,6 +8509,8 @@ function chatAgent<
83998509 "chat.agent: replaced message not found at the model lane tail; reconverting the lane"
84008510 );
84018511 accumulatedMessages = await toModelMessages(accumulatedUIMessages);
8512+ laneCompacted = false;
8513+ laneInjections = [];
84028514 }
84038515 } else {
84048516 const incomingModelMessages = await toModelMessages(cleanedUIMessages);
@@ -8608,6 +8720,8 @@ function chatAgent<
86088720 locals.set(chatOverrideMessagesKey, undefined);
86098721 accumulatedUIMessages = [...turnStartOverride] as TUIMessage[];
86108722 accumulatedMessages = await toModelMessages(turnStartOverride);
8723+ laneCompacted = false;
8724+ laneInjections = [];
86118725 locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
86128726 }
86138727 },
@@ -8676,7 +8790,12 @@ function chatAgent<
86768790 const lastAccumulated = accumulatedMessages[accumulatedMessages.length - 1];
86778791 const bgQueue = locals.get(chatBackgroundQueueKey);
86788792 if (bgQueue && bgQueue.length > 0 && lastAccumulated?.role !== "tool") {
8679- accumulatedMessages.push(...bgQueue.splice(0));
8793+ const injected = bgQueue.splice(0);
8794+ accumulatedMessages.push(...injected);
8795+ laneInjections.push({
8796+ afterId: accumulatedUIMessages.at(-1)?.id ?? "",
8797+ messages: injected,
8798+ });
86808799 }
86818800
86828801 if (isHeadStartFinalTurn) {
@@ -8869,6 +8988,8 @@ function chatAgent<
88698988 accumulatedMessages = await toModelMessages(
88708989 runOverride.filter((m) => !pendingIds.has(m.id))
88718990 );
8991+ laneCompacted = false;
8992+ laneInjections = [];
88728993 locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
88738994 }
88748995
@@ -8894,6 +9015,8 @@ function chatAgent<
88949015 accumulatedMessages = taskCompactionConfig?.compactModelMessages
88959016 ? await taskCompactionConfig.compactModelMessages(compactEvent)
88969017 : modelOnlyOverride;
9018+ laneCompacted = true;
9019+ laneInjections = [];
88979020
88989021 // Apply UI messages: callback or default (preserve all)
88999022 if (taskCompactionConfig?.compactUIMessages) {
@@ -8912,9 +9035,10 @@ function chatAgent<
89129035 // before the response is appended so the order stays
89139036 // steer-then-answer. Outside the `capturedResponseMessage`
89149037 // branches below, so a turn that captured no response is covered.
8915- const steerTailThisTurn = reconcilePendingSteer({
8916- turnNew: turnNewModelMessages,
8917- }).reduce((n, e) => n + e.model.length, 0);
9038+ const steerTailThisTurn =
9039+ reconcilePendingSteer({
9040+ turnNew: turnNewModelMessages,
9041+ }).reduce((n, e) => n + e.model.length, 0) + reconcilePendingBackground();
89189042
89199043 // Append the assistant's response (partial or complete) to the accumulator.
89209044 // The onFinish callback fires even on abort/stop, so partial responses
@@ -8986,6 +9110,8 @@ function chatAgent<
89869110 "chat.agent: replaced response not found at the model lane tail; reconverting the lane"
89879111 );
89889112 accumulatedMessages = await toModelMessages(accumulatedUIMessages);
9113+ laneCompacted = false;
9114+ laneInjections = [];
89899115 }
89909116 } else {
89919117 accumulatedMessages.push(...responseModelMessages);
@@ -9105,6 +9231,9 @@ function chatAgent<
91059231 },
91069232 ];
91079233
9234+ laneCompacted = true;
9235+ laneInjections = [];
9236+
91089237 // UI messages: callback or default (preserve all)
91099238 if (outerCompaction.compactUIMessages) {
91109239 accumulatedUIMessages = (await outerCompaction.compactUIMessages(
@@ -9209,6 +9338,8 @@ function chatAgent<
92099338 locals.set(chatOverrideMessagesKey, undefined);
92109339 accumulatedUIMessages = [...override] as TUIMessage[];
92119340 accumulatedMessages = await toModelMessages(override);
9341+ laneCompacted = false;
9342+ laneInjections = [];
92129343 locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
92139344 // Update event so onTurnComplete sees compacted messages
92149345 turnCompleteEvent.messages = accumulatedMessages;
@@ -9268,6 +9399,8 @@ function chatAgent<
92689399 locals.set(chatOverrideMessagesKey, undefined);
92699400 accumulatedUIMessages = [...turnCompleteOverride] as TUIMessage[];
92709401 accumulatedMessages = await toModelMessages(turnCompleteOverride);
9402+ laneCompacted = false;
9403+ laneInjections = [];
92719404 locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
92729405 }
92739406 },
@@ -9602,6 +9735,7 @@ function chatAgent<
96029735 let erroredNewModelMessages: ModelMessage[] = [];
96039736
96049737 const reconciledSteer = reconcilePendingSteer();
9738+ const backgroundTailThisTurn = reconcilePendingBackground();
96059739
96069740 if (!responseCommitted) {
96079741 try {
@@ -9637,13 +9771,16 @@ function chatAgent<
96379771 accumulatedMessages,
96389772 erroredUIMessages[partialIdx]!,
96399773 partialResponse!,
9640- reconciledSteer.reduce((n, e) => n + e.model.length, 0)
9774+ reconciledSteer.reduce((n, e) => n + e.model.length, 0) +
9775+ backgroundTailThisTurn
96419776 );
96429777 if (!ok) {
96439778 logger.warn(
96449779 "chat.agent: replaced partial not found at the model lane tail; reconverting the lane"
96459780 );
96469781 accumulatedMessages = await toModelMessages(erroredUIMessagesWithPartial);
9782+ laneCompacted = false;
9783+ laneInjections = [];
96479784 }
96489785 }
96499786 accumulatedUIMessages = erroredUIMessagesWithPartial;
0 commit comments