@@ -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,
@@ -2539,6 +2558,13 @@ function spliceHandoverPartial(
25392558 * @internal
25402559 */
25412560const chatBackgroundQueueKey = locals.create<ModelMessage[]>("chat.backgroundQueue");
2561+ /**
2562+ * Background injections a step-boundary drain handed to the model this turn,
2563+ * with the transcript message they followed. Reconciled into the model lane
2564+ * and the persisted injections once the turn's response is in.
2565+ */
2566+ const chatPendingBackgroundKey =
2567+ locals.create<{ afterId: string; messages: ModelMessage[] }[]>("chat.pendingBackground");
25422568
25432569/**
25442570 * System-role context injected mid-conversation, held for the instructions lane.
@@ -5019,6 +5045,13 @@ function toStreamTextOptions(options?: ToStreamTextOptionsOptions): Record<strin
50195045 if (bgQueue && bgQueue.length > 0) {
50205046 const injected = bgQueue.splice(0); // drain
50215047 resultMessages = [...(resultMessages ?? messages), ...injected];
5048+ const pendingBackground = locals.get(chatPendingBackgroundKey) ?? [];
5049+ pendingBackground.push({
5050+ afterId:
5051+ (locals.get(chatCurrentUIMessagesKey) as UIMessage[] | undefined)?.at(-1)?.id ?? "",
5052+ messages: injected,
5053+ });
5054+ locals.set(chatPendingBackgroundKey, pendingBackground);
50225055 }
50235056
50245057 return resultMessages ? { messages: resultMessages } : undefined;
@@ -6909,6 +6942,23 @@ function chatAgent<
69096942 // durable snapshot + `session.out` replay (or `hydrateMessages` if
69106943 // registered) — the wire is delta-only now, no longer a seed.
69116944 let accumulatedMessages: ModelMessage[] = [];
6945+ /**
6946+ * Give the model accumulator the background injections a step-boundary
6947+ * drain handed to the model this turn, and record them for persistence.
6948+ * Returns how many model messages were appended.
6949+ */
6950+ const reconcilePendingBackground = (): number => {
6951+ const pending = locals.get(chatPendingBackgroundKey);
6952+ if (!pending || pending.length === 0) return 0;
6953+ locals.set(chatPendingBackgroundKey, []);
6954+ let appended = 0;
6955+ for (const entry of pending) {
6956+ accumulatedMessages.push(...entry.messages);
6957+ laneInjections.push(entry);
6958+ appended += entry.messages.length;
6959+ }
6960+ return appended;
6961+ };
69126962 /**
69136963 * Give the model accumulator the steering messages a drain consumed,
69146964 * in the form the model actually received. Appended, never reconverted
@@ -6948,8 +6998,18 @@ function chatAgent<
69486998 // collectively cost ~600ms on every first-message TTFC. Both reads
69496999 // swallow errors internally; the agent stays available either way.
69507000 const sessionIdForSnapshot = payload.sessionId ?? payload.chatId;
6951- const transcriptStorage = defaultStorage;
7001+ const transcriptStorage = transcriptStorageOverride ?? defaultStorage;
69527002 let transcriptShadow: TranscriptShadow = createTranscriptShadow([]);
7003+ let bootTranscriptState: unknown = null;
7004+ /**
7005+ * True while the model lane holds a compaction summary, so it cannot be
7006+ * rebuilt from the transcript and has to be persisted as state. Reset
7007+ * wherever the lane is reconverted from the UI lane.
7008+ */
7009+ let laneCompacted = false;
7010+ /** Conversational `chat.inject` messages in the lane, anchored to the transcript. */
7011+ let laneInjections: NonNullable<TranscriptRuntimeState["injections"]> = [];
7012+ let persistedStateSet = false;
69537013 let bootSnapshot:
69547014 | { messages: TUIMessage[]; lastOutEventId?: string; lastInEventId?: string }
69557015 | undefined;
@@ -6999,6 +7059,30 @@ function chatAgent<
69997059 const { changes, shadow } = diffTranscript(transcriptShadow, opts.messages, {
70007060 nonFinalIds: opts.nonFinalIds,
70017061 });
7062+ const throughId = opts.messages.at(-1)?.id ?? "";
7063+ const queued = locals.get(chatBackgroundQueueKey) ?? [];
7064+ const runtimeState: TranscriptRuntimeState | null =
7065+ laneCompacted || laneInjections.length > 0 || queued.length > 0
7066+ ? {
7067+ v: 1,
7068+ ...(laneCompacted
7069+ ? {
7070+ compaction: {
7071+ modelMessages: accumulatedMessages,
7072+ throughId,
7073+ fingerprint: prefixFingerprint(shadow, throughId),
7074+ },
7075+ }
7076+ : laneInjections.length > 0
7077+ ? { injections: laneInjections }
7078+ : {}),
7079+ ...(queued.length > 0 ? { queued: [...queued] } : {}),
7080+ }
7081+ : null;
7082+ if (runtimeState !== null || persistedStateSet) {
7083+ changes.push({ op: "state", value: runtimeState } satisfies TranscriptChange);
7084+ }
7085+ transcriptState = runtimeState;
70027086 const inCursor = chatInputRouter().resumeFloor();
70037087 await transcriptStorage.save(
70047088 {
@@ -7027,6 +7111,7 @@ function chatAgent<
70277111 }
70287112 );
70297113 transcriptShadow = shadow;
7114+ persistedStateSet = runtimeState !== null;
70307115 };
70317116
70327117 /**
@@ -7111,6 +7196,8 @@ function chatAgent<
71117196 clientData: bootClientData,
71127197 });
71137198 transcriptShadow = createTranscriptShadow(loaded.messages);
7199+ bootTranscriptState = loaded.state;
7200+ persistedStateSet = loaded.state !== null && loaded.state !== undefined;
71147201 bootSnapshot = {
71157202 messages: loaded.messages,
71167203 lastOutEventId: loaded.cursors?.lastOutEventId,
@@ -7450,7 +7537,21 @@ function chatAgent<
74507537 }
74517538 }
74527539 try {
7453- accumulatedMessages = await toModelMessages(accumulatedUIMessages);
7540+ const bootRuntimeState = parseTranscriptRuntimeState(bootTranscriptState);
7541+ const restored = await restoreModelLane(
7542+ accumulatedUIMessages,
7543+ bootRuntimeState,
7544+ (messages) => toModelMessages(messages)
7545+ );
7546+ accumulatedMessages = restored.messages;
7547+ laneCompacted = restored.compacted;
7548+ laneInjections = restored.injections;
7549+ if (bootRuntimeState?.queued && bootRuntimeState.queued.length > 0) {
7550+ locals.set(chatBackgroundQueueKey, [
7551+ ...(locals.get(chatBackgroundQueueKey) ?? []),
7552+ ...bootRuntimeState.queued,
7553+ ]);
7554+ }
74547555 } catch (error) {
74557556 logger.warn("chat.agent: toModelMessages failed at boot; starting empty", {
74567557 error: error instanceof Error ? error.message : String(error),
@@ -7979,6 +8080,7 @@ function chatAgent<
79798080 locals.set(chatDeferKey, new Set());
79808081 locals.set(chatCompactionStateKey, undefined);
79818082 locals.set(chatSteeringQueueKey, []);
8083+ locals.set(chatPendingBackgroundKey, []);
79828084 locals.set(chatResponsePartsKey, []);
79838085 // NOTE: chatBackgroundQueueKey is NOT reset here — messages injected
79848086 // by deferred work from the previous turn's onTurnComplete need to
@@ -8125,6 +8227,8 @@ function chatAgent<
81258227 );
81268228 accumulatedUIMessages = [...hydrated] as TUIMessage[];
81278229 accumulatedMessages = await toModelMessages(hydrated);
8230+ laneCompacted = false;
8231+ laneInjections = [];
81288232 locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
81298233 }
81308234
@@ -8162,6 +8266,8 @@ function chatAgent<
81628266 locals.set(chatOverrideMessagesKey, undefined);
81638267 accumulatedUIMessages = [...actionOverride] as TUIMessage[];
81648268 accumulatedMessages = await toModelMessages(actionOverride);
8269+ laneCompacted = false;
8270+ laneInjections = [];
81658271 locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
81668272
81678273 actionChangedHistory = true;
@@ -8294,6 +8400,8 @@ function chatAgent<
82948400
82958401 accumulatedUIMessages = merged;
82968402 accumulatedMessages = await toModelMessages(merged);
8403+ laneCompacted = false;
8404+ laneInjections = [];
82978405 locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
82988406
82998407 // Track new messages for onTurnComplete.newUIMessages.
@@ -8343,6 +8451,8 @@ function chatAgent<
83438451 accumulatedUIMessages.pop();
83448452 }
83458453 accumulatedMessages = await toModelMessages(accumulatedUIMessages);
8454+ laneCompacted = false;
8455+ laneInjections = [];
83468456 } else if (cleanedUIMessages.length > 0) {
83478457 // Submit-message (and the special-cased
83488458 // handover-prepare → submit-message rewrite earlier in
@@ -8396,6 +8506,8 @@ function chatAgent<
83968506 "chat.agent: replaced message not found at the model lane tail; reconverting the lane"
83978507 );
83988508 accumulatedMessages = await toModelMessages(accumulatedUIMessages);
8509+ laneCompacted = false;
8510+ laneInjections = [];
83998511 }
84008512 } else {
84018513 const incomingModelMessages = await toModelMessages(cleanedUIMessages);
@@ -8605,6 +8717,8 @@ function chatAgent<
86058717 locals.set(chatOverrideMessagesKey, undefined);
86068718 accumulatedUIMessages = [...turnStartOverride] as TUIMessage[];
86078719 accumulatedMessages = await toModelMessages(turnStartOverride);
8720+ laneCompacted = false;
8721+ laneInjections = [];
86088722 locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
86098723 }
86108724 },
@@ -8673,7 +8787,12 @@ function chatAgent<
86738787 const lastAccumulated = accumulatedMessages[accumulatedMessages.length - 1];
86748788 const bgQueue = locals.get(chatBackgroundQueueKey);
86758789 if (bgQueue && bgQueue.length > 0 && lastAccumulated?.role !== "tool") {
8676- accumulatedMessages.push(...bgQueue.splice(0));
8790+ const injected = bgQueue.splice(0);
8791+ accumulatedMessages.push(...injected);
8792+ laneInjections.push({
8793+ afterId: accumulatedUIMessages.at(-1)?.id ?? "",
8794+ messages: injected,
8795+ });
86778796 }
86788797
86798798 if (isHeadStartFinalTurn) {
@@ -8866,6 +8985,8 @@ function chatAgent<
88668985 accumulatedMessages = await toModelMessages(
88678986 runOverride.filter((m) => !pendingIds.has(m.id))
88688987 );
8988+ laneCompacted = false;
8989+ laneInjections = [];
88698990 locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
88708991 }
88718992
@@ -8891,6 +9012,8 @@ function chatAgent<
88919012 accumulatedMessages = taskCompactionConfig?.compactModelMessages
88929013 ? await taskCompactionConfig.compactModelMessages(compactEvent)
88939014 : modelOnlyOverride;
9015+ laneCompacted = true;
9016+ laneInjections = [];
88949017
88959018 // Apply UI messages: callback or default (preserve all)
88969019 if (taskCompactionConfig?.compactUIMessages) {
@@ -8909,9 +9032,10 @@ function chatAgent<
89099032 // before the response is appended so the order stays
89109033 // steer-then-answer. Outside the `capturedResponseMessage`
89119034 // branches below, so a turn that captured no response is covered.
8912- const steerTailThisTurn = reconcilePendingSteer({
8913- turnNew: turnNewModelMessages,
8914- }).reduce((n, e) => n + e.model.length, 0);
9035+ const steerTailThisTurn =
9036+ reconcilePendingSteer({
9037+ turnNew: turnNewModelMessages,
9038+ }).reduce((n, e) => n + e.model.length, 0) + reconcilePendingBackground();
89159039
89169040 // Append the assistant's response (partial or complete) to the accumulator.
89179041 // The onFinish callback fires even on abort/stop, so partial responses
@@ -8983,6 +9107,8 @@ function chatAgent<
89839107 "chat.agent: replaced response not found at the model lane tail; reconverting the lane"
89849108 );
89859109 accumulatedMessages = await toModelMessages(accumulatedUIMessages);
9110+ laneCompacted = false;
9111+ laneInjections = [];
89869112 }
89879113 } else {
89889114 accumulatedMessages.push(...responseModelMessages);
@@ -9102,6 +9228,9 @@ function chatAgent<
91029228 },
91039229 ];
91049230
9231+ laneCompacted = true;
9232+ laneInjections = [];
9233+
91059234 // UI messages: callback or default (preserve all)
91069235 if (outerCompaction.compactUIMessages) {
91079236 accumulatedUIMessages = (await outerCompaction.compactUIMessages(
@@ -9206,6 +9335,8 @@ function chatAgent<
92069335 locals.set(chatOverrideMessagesKey, undefined);
92079336 accumulatedUIMessages = [...override] as TUIMessage[];
92089337 accumulatedMessages = await toModelMessages(override);
9338+ laneCompacted = false;
9339+ laneInjections = [];
92099340 locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
92109341 // Update event so onTurnComplete sees compacted messages
92119342 turnCompleteEvent.messages = accumulatedMessages;
@@ -9265,6 +9396,8 @@ function chatAgent<
92659396 locals.set(chatOverrideMessagesKey, undefined);
92669397 accumulatedUIMessages = [...turnCompleteOverride] as TUIMessage[];
92679398 accumulatedMessages = await toModelMessages(turnCompleteOverride);
9399+ laneCompacted = false;
9400+ laneInjections = [];
92689401 locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
92699402 }
92709403 },
@@ -9599,6 +9732,7 @@ function chatAgent<
95999732 let erroredNewModelMessages: ModelMessage[] = [];
96009733
96019734 const reconciledSteer = reconcilePendingSteer();
9735+ const backgroundTailThisTurn = reconcilePendingBackground();
96029736
96039737 if (!responseCommitted) {
96049738 try {
@@ -9634,13 +9768,16 @@ function chatAgent<
96349768 accumulatedMessages,
96359769 erroredUIMessages[partialIdx]!,
96369770 partialResponse!,
9637- reconciledSteer.reduce((n, e) => n + e.model.length, 0)
9771+ reconciledSteer.reduce((n, e) => n + e.model.length, 0) +
9772+ backgroundTailThisTurn
96389773 );
96399774 if (!ok) {
96409775 logger.warn(
96419776 "chat.agent: replaced partial not found at the model lane tail; reconverting the lane"
96429777 );
96439778 accumulatedMessages = await toModelMessages(erroredUIMessagesWithPartial);
9779+ laneCompacted = false;
9780+ laneInjections = [];
96449781 }
96459782 }
96469783 accumulatedUIMessages = erroredUIMessagesWithPartial;
0 commit comments