Skip to content

Commit 2d6f77d

Browse files
committed
feat(sdk): persist compaction and injected context through the transcript storage state
The model lane after a compaction cannot be rebuilt from the transcript, so every continuation used to re-read the whole conversation and summarise it again. The runtime now records the compacted lane in the storage's state slot, with the transcript id it covers and a fingerprint of that prefix, and rebuilds from it at boot when the prefix is unchanged. A rollback or edit that reconverts the lane clears the state in the same changeset as the truncate. Conversational messages added with chat.inject are recorded the same way, anchored to the transcript message they followed, so they survive a continuation instead of living only in the worker that received them. Adds an in-memory storage that logs the changesets it receives, and a test-only override for the storage the runtime persists through, so the exact changesets for a turn, a mid-turn steer, a compaction, a rollback and an injection are asserted.
1 parent a075eb1 commit 2d6f77d

3 files changed

Lines changed: 888 additions & 20 deletions

File tree

packages/trigger-sdk/src/v3/ai.ts

Lines changed: 144 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
}
87106
import {
88107
type ChatInputChunk,
89108
type ChatTaskWirePayload,
@@ -2539,6 +2558,13 @@ function spliceHandoverPartial(
25392558
* @internal
25402559
*/
25412560
const 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

Comments
 (0)