Skip to content

Commit fc7e243

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 8ead6fd commit fc7e243

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
@@ -76,10 +76,29 @@ import {
7676
createTranscriptShadow,
7777
defaultStorage,
7878
diffTranscript,
79+
parseTranscriptRuntimeState,
80+
prefixFingerprint,
81+
restoreModelLane,
82+
type TranscriptChange,
7983
type TranscriptChangeReason,
84+
type TranscriptRuntimeState,
8085
type TranscriptShadow,
86+
type TranscriptStorage,
8187
type TranscriptStorageContext,
8288
} from "./transcriptStorage.js";
89+
90+
let transcriptStorageOverride: TranscriptStorage<unknown> | undefined;
91+
92+
/**
93+
* Test-only override for the storage `chat.agent` persists through, so a
94+
* test can capture the exact changesets the runtime produces.
95+
* @internal
96+
*/
97+
export function __setTranscriptStorageForTests(
98+
storage: TranscriptStorage<unknown> | undefined
99+
): void {
100+
transcriptStorageOverride = storage;
101+
}
83102
import {
84103
type ChatInputChunk,
85104
type ChatTaskWirePayload,
@@ -2515,6 +2534,13 @@ function spliceHandoverPartial(
25152534
* @internal
25162535
*/
25172536
const chatBackgroundQueueKey = locals.create<ModelMessage[]>("chat.backgroundQueue");
2537+
/**
2538+
* Background injections a step-boundary drain handed to the model this turn,
2539+
* with the transcript message they followed. Reconciled into the model lane
2540+
* and the persisted injections once the turn's response is in.
2541+
*/
2542+
const chatPendingBackgroundKey =
2543+
locals.create<{ afterId: string; messages: ModelMessage[] }[]>("chat.pendingBackground");
25182544

25192545
/**
25202546
* System-role context injected mid-conversation, held for the instructions lane.
@@ -4970,6 +4996,13 @@ function toStreamTextOptions(options?: ToStreamTextOptionsOptions): Record<strin
49704996
if (bgQueue && bgQueue.length > 0) {
49714997
const injected = bgQueue.splice(0); // drain
49724998
resultMessages = [...(resultMessages ?? messages), ...injected];
4999+
const pendingBackground = locals.get(chatPendingBackgroundKey) ?? [];
5000+
pendingBackground.push({
5001+
afterId:
5002+
(locals.get(chatCurrentUIMessagesKey) as UIMessage[] | undefined)?.at(-1)?.id ?? "",
5003+
messages: injected,
5004+
});
5005+
locals.set(chatPendingBackgroundKey, pendingBackground);
49735006
}
49745007

49755008
return resultMessages ? { messages: resultMessages } : undefined;
@@ -6831,6 +6864,23 @@ function chatAgent<
68316864
// durable snapshot + `session.out` replay (or `hydrateMessages` if
68326865
// registered) — the wire is delta-only now, no longer a seed.
68336866
let accumulatedMessages: ModelMessage[] = [];
6867+
/**
6868+
* Give the model accumulator the background injections a step-boundary
6869+
* drain handed to the model this turn, and record them for persistence.
6870+
* Returns how many model messages were appended.
6871+
*/
6872+
const reconcilePendingBackground = (): number => {
6873+
const pending = locals.get(chatPendingBackgroundKey);
6874+
if (!pending || pending.length === 0) return 0;
6875+
locals.set(chatPendingBackgroundKey, []);
6876+
let appended = 0;
6877+
for (const entry of pending) {
6878+
accumulatedMessages.push(...entry.messages);
6879+
laneInjections.push(entry);
6880+
appended += entry.messages.length;
6881+
}
6882+
return appended;
6883+
};
68346884
/**
68356885
* Give the model accumulator the steering messages a drain consumed,
68366886
* in the form the model actually received. Appended, never reconverted
@@ -6870,8 +6920,18 @@ function chatAgent<
68706920
// collectively cost ~600ms on every first-message TTFC. Both reads
68716921
// swallow errors internally; the agent stays available either way.
68726922
const sessionIdForSnapshot = payload.sessionId ?? payload.chatId;
6873-
const transcriptStorage = defaultStorage;
6923+
const transcriptStorage = transcriptStorageOverride ?? defaultStorage;
68746924
let transcriptShadow: TranscriptShadow = createTranscriptShadow([]);
6925+
let bootTranscriptState: unknown = null;
6926+
/**
6927+
* True while the model lane holds a compaction summary, so it cannot be
6928+
* rebuilt from the transcript and has to be persisted as state. Reset
6929+
* wherever the lane is reconverted from the UI lane.
6930+
*/
6931+
let laneCompacted = false;
6932+
/** Conversational `chat.inject` messages in the lane, anchored to the transcript. */
6933+
let laneInjections: NonNullable<TranscriptRuntimeState["injections"]> = [];
6934+
let persistedStateSet = false;
68756935
let bootSnapshot:
68766936
| { messages: TUIMessage[]; lastOutEventId?: string; lastInEventId?: string }
68776937
| undefined;
@@ -6921,6 +6981,30 @@ function chatAgent<
69216981
const { changes, shadow } = diffTranscript(transcriptShadow, opts.messages, {
69226982
nonFinalIds: opts.nonFinalIds,
69236983
});
6984+
const throughId = opts.messages.at(-1)?.id ?? "";
6985+
const queued = locals.get(chatBackgroundQueueKey) ?? [];
6986+
const runtimeState: TranscriptRuntimeState | null =
6987+
laneCompacted || laneInjections.length > 0 || queued.length > 0
6988+
? {
6989+
v: 1,
6990+
...(laneCompacted
6991+
? {
6992+
compaction: {
6993+
modelMessages: accumulatedMessages,
6994+
throughId,
6995+
fingerprint: prefixFingerprint(shadow, throughId),
6996+
},
6997+
}
6998+
: laneInjections.length > 0
6999+
? { injections: laneInjections }
7000+
: {}),
7001+
...(queued.length > 0 ? { queued: [...queued] } : {}),
7002+
}
7003+
: null;
7004+
if (runtimeState !== null || persistedStateSet) {
7005+
changes.push({ op: "state", value: runtimeState } satisfies TranscriptChange);
7006+
}
7007+
transcriptState = runtimeState;
69247008
const inCursor = chatInputRouter().resumeFloor();
69257009
await transcriptStorage.save(
69267010
{
@@ -6949,6 +7033,7 @@ function chatAgent<
69497033
}
69507034
);
69517035
transcriptShadow = shadow;
7036+
persistedStateSet = runtimeState !== null;
69527037
};
69537038

69547039
/**
@@ -7033,6 +7118,8 @@ function chatAgent<
70337118
clientData: bootClientData,
70347119
});
70357120
transcriptShadow = createTranscriptShadow(loaded.messages);
7121+
bootTranscriptState = loaded.state;
7122+
persistedStateSet = loaded.state !== null && loaded.state !== undefined;
70367123
bootSnapshot = {
70377124
messages: loaded.messages,
70387125
lastOutEventId: loaded.cursors?.lastOutEventId,
@@ -7377,7 +7464,21 @@ function chatAgent<
73777464
}
73787465
}
73797466
try {
7380-
accumulatedMessages = await toModelMessages(accumulatedUIMessages);
7467+
const bootRuntimeState = parseTranscriptRuntimeState(bootTranscriptState);
7468+
const restored = await restoreModelLane(
7469+
accumulatedUIMessages,
7470+
bootRuntimeState,
7471+
(messages) => toModelMessages(messages)
7472+
);
7473+
accumulatedMessages = restored.messages;
7474+
laneCompacted = restored.compacted;
7475+
laneInjections = restored.injections;
7476+
if (bootRuntimeState?.queued && bootRuntimeState.queued.length > 0) {
7477+
locals.set(chatBackgroundQueueKey, [
7478+
...(locals.get(chatBackgroundQueueKey) ?? []),
7479+
...bootRuntimeState.queued,
7480+
]);
7481+
}
73817482
} catch (error) {
73827483
logger.warn("chat.agent: toModelMessages failed at boot; starting empty", {
73837484
error: error instanceof Error ? error.message : String(error),
@@ -7906,6 +8007,7 @@ function chatAgent<
79068007
locals.set(chatDeferKey, new Set());
79078008
locals.set(chatCompactionStateKey, undefined);
79088009
locals.set(chatSteeringQueueKey, []);
8010+
locals.set(chatPendingBackgroundKey, []);
79098011
locals.set(chatResponsePartsKey, []);
79108012
// NOTE: chatBackgroundQueueKey is NOT reset here — messages injected
79118013
// by deferred work from the previous turn's onTurnComplete need to
@@ -8049,6 +8151,8 @@ function chatAgent<
80498151
);
80508152
accumulatedUIMessages = [...hydrated] as TUIMessage[];
80518153
accumulatedMessages = await toModelMessages(hydrated);
8154+
laneCompacted = false;
8155+
laneInjections = [];
80528156
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
80538157
}
80548158

@@ -8086,6 +8190,8 @@ function chatAgent<
80868190
locals.set(chatOverrideMessagesKey, undefined);
80878191
accumulatedUIMessages = [...actionOverride] as TUIMessage[];
80888192
accumulatedMessages = await toModelMessages(actionOverride);
8193+
laneCompacted = false;
8194+
laneInjections = [];
80898195
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
80908196

80918197
actionChangedHistory = true;
@@ -8218,6 +8324,8 @@ function chatAgent<
82188324

82198325
accumulatedUIMessages = merged;
82208326
accumulatedMessages = await toModelMessages(merged);
8327+
laneCompacted = false;
8328+
laneInjections = [];
82218329
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
82228330

82238331
// Track new messages for onTurnComplete.newUIMessages.
@@ -8267,6 +8375,8 @@ function chatAgent<
82678375
accumulatedUIMessages.pop();
82688376
}
82698377
accumulatedMessages = await toModelMessages(accumulatedUIMessages);
8378+
laneCompacted = false;
8379+
laneInjections = [];
82708380
} else if (cleanedUIMessages.length > 0) {
82718381
// Submit-message (and the special-cased
82728382
// handover-prepare → submit-message rewrite earlier in
@@ -8320,6 +8430,8 @@ function chatAgent<
83208430
"chat.agent: replaced message not found at the model lane tail; reconverting the lane"
83218431
);
83228432
accumulatedMessages = await toModelMessages(accumulatedUIMessages);
8433+
laneCompacted = false;
8434+
laneInjections = [];
83238435
}
83248436
} else {
83258437
const incomingModelMessages = await toModelMessages(cleanedUIMessages);
@@ -8501,6 +8613,8 @@ function chatAgent<
85018613
locals.set(chatOverrideMessagesKey, undefined);
85028614
accumulatedUIMessages = [...turnStartOverride] as TUIMessage[];
85038615
accumulatedMessages = await toModelMessages(turnStartOverride);
8616+
laneCompacted = false;
8617+
laneInjections = [];
85048618
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
85058619
}
85068620
},
@@ -8566,7 +8680,12 @@ function chatAgent<
85668680
const lastAccumulated = accumulatedMessages[accumulatedMessages.length - 1];
85678681
const bgQueue = locals.get(chatBackgroundQueueKey);
85688682
if (bgQueue && bgQueue.length > 0 && lastAccumulated?.role !== "tool") {
8569-
accumulatedMessages.push(...bgQueue.splice(0));
8683+
const injected = bgQueue.splice(0);
8684+
accumulatedMessages.push(...injected);
8685+
laneInjections.push({
8686+
afterId: accumulatedUIMessages.at(-1)?.id ?? "",
8687+
messages: injected,
8688+
});
85708689
}
85718690

85728691
if (isHeadStartFinalTurn) {
@@ -8759,6 +8878,8 @@ function chatAgent<
87598878
accumulatedMessages = await toModelMessages(
87608879
runOverride.filter((m) => !pendingIds.has(m.id))
87618880
);
8881+
laneCompacted = false;
8882+
laneInjections = [];
87628883
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
87638884
}
87648885

@@ -8784,6 +8905,8 @@ function chatAgent<
87848905
accumulatedMessages = taskCompactionConfig?.compactModelMessages
87858906
? await taskCompactionConfig.compactModelMessages(compactEvent)
87868907
: modelOnlyOverride;
8908+
laneCompacted = true;
8909+
laneInjections = [];
87878910

87888911
// Apply UI messages: callback or default (preserve all)
87898912
if (taskCompactionConfig?.compactUIMessages) {
@@ -8802,9 +8925,10 @@ function chatAgent<
88028925
// before the response is appended so the order stays
88038926
// steer-then-answer. Outside the `capturedResponseMessage`
88048927
// branches below, so a turn that captured no response is covered.
8805-
const steerTailThisTurn = reconcilePendingSteer({
8806-
turnNew: turnNewModelMessages,
8807-
}).reduce((n, e) => n + e.model.length, 0);
8928+
const steerTailThisTurn =
8929+
reconcilePendingSteer({
8930+
turnNew: turnNewModelMessages,
8931+
}).reduce((n, e) => n + e.model.length, 0) + reconcilePendingBackground();
88088932

88098933
// Append the assistant's response (partial or complete) to the accumulator.
88108934
// The onFinish callback fires even on abort/stop, so partial responses
@@ -8876,6 +9000,8 @@ function chatAgent<
88769000
"chat.agent: replaced response not found at the model lane tail; reconverting the lane"
88779001
);
88789002
accumulatedMessages = await toModelMessages(accumulatedUIMessages);
9003+
laneCompacted = false;
9004+
laneInjections = [];
88799005
}
88809006
} else {
88819007
accumulatedMessages.push(...responseModelMessages);
@@ -8995,6 +9121,9 @@ function chatAgent<
89959121
},
89969122
];
89979123

9124+
laneCompacted = true;
9125+
laneInjections = [];
9126+
89989127
// UI messages: callback or default (preserve all)
89999128
if (outerCompaction.compactUIMessages) {
90009129
accumulatedUIMessages = (await outerCompaction.compactUIMessages(
@@ -9099,6 +9228,8 @@ function chatAgent<
90999228
locals.set(chatOverrideMessagesKey, undefined);
91009229
accumulatedUIMessages = [...override] as TUIMessage[];
91019230
accumulatedMessages = await toModelMessages(override);
9231+
laneCompacted = false;
9232+
laneInjections = [];
91029233
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
91039234
// Update event so onTurnComplete sees compacted messages
91049235
turnCompleteEvent.messages = accumulatedMessages;
@@ -9158,6 +9289,8 @@ function chatAgent<
91589289
locals.set(chatOverrideMessagesKey, undefined);
91599290
accumulatedUIMessages = [...turnCompleteOverride] as TUIMessage[];
91609291
accumulatedMessages = await toModelMessages(turnCompleteOverride);
9292+
laneCompacted = false;
9293+
laneInjections = [];
91619294
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
91629295
}
91639296
},
@@ -9484,6 +9617,7 @@ function chatAgent<
94849617
let erroredNewModelMessages: ModelMessage[] = [];
94859618

94869619
const reconciledSteer = reconcilePendingSteer();
9620+
const backgroundTailThisTurn = reconcilePendingBackground();
94879621

94889622
if (!responseCommitted) {
94899623
try {
@@ -9519,13 +9653,16 @@ function chatAgent<
95199653
accumulatedMessages,
95209654
erroredUIMessages[partialIdx]!,
95219655
partialResponse!,
9522-
reconciledSteer.reduce((n, e) => n + e.model.length, 0)
9656+
reconciledSteer.reduce((n, e) => n + e.model.length, 0) +
9657+
backgroundTailThisTurn
95239658
);
95249659
if (!ok) {
95259660
logger.warn(
95269661
"chat.agent: replaced partial not found at the model lane tail; reconverting the lane"
95279662
);
95289663
accumulatedMessages = await toModelMessages(erroredUIMessagesWithPartial);
9664+
laneCompacted = false;
9665+
laneInjections = [];
95299666
}
95309667
}
95319668
accumulatedUIMessages = erroredUIMessagesWithPartial;

0 commit comments

Comments
 (0)