Skip to content

Commit 4d2129d

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 5b764c7 commit 4d2129d

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,
@@ -2521,6 +2540,13 @@ function spliceHandoverPartial(
25212540
* @internal
25222541
*/
25232542
const 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

Comments
 (0)