Skip to content

Commit 192cc15

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 192cc15

3 files changed

Lines changed: 657 additions & 16 deletions

File tree

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

Lines changed: 92 additions & 3 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,
@@ -6870,8 +6889,18 @@ function chatAgent<
68706889
// collectively cost ~600ms on every first-message TTFC. Both reads
68716890
// swallow errors internally; the agent stays available either way.
68726891
const sessionIdForSnapshot = payload.sessionId ?? payload.chatId;
6873-
const transcriptStorage = defaultStorage;
6892+
const transcriptStorage = transcriptStorageOverride ?? defaultStorage;
68746893
let transcriptShadow: TranscriptShadow = createTranscriptShadow([]);
6894+
let bootTranscriptState: unknown = null;
6895+
/**
6896+
* True while the model lane holds a compaction summary, so it cannot be
6897+
* rebuilt from the transcript and has to be persisted as state. Reset
6898+
* wherever the lane is reconverted from the UI lane.
6899+
*/
6900+
let laneCompacted = false;
6901+
/** Conversational `chat.inject` messages in the lane, anchored to the transcript. */
6902+
let laneInjections: NonNullable<TranscriptRuntimeState["injections"]> = [];
6903+
let persistedStateSet = false;
68756904
let bootSnapshot:
68766905
| { messages: TUIMessage[]; lastOutEventId?: string; lastInEventId?: string }
68776906
| undefined;
@@ -6921,6 +6950,24 @@ function chatAgent<
69216950
const { changes, shadow } = diffTranscript(transcriptShadow, opts.messages, {
69226951
nonFinalIds: opts.nonFinalIds,
69236952
});
6953+
const lastId = opts.messages.at(-1)?.id;
6954+
const runtimeState: TranscriptRuntimeState | null =
6955+
laneCompacted && lastId !== undefined
6956+
? {
6957+
v: 1,
6958+
compaction: {
6959+
modelMessages: accumulatedMessages,
6960+
throughId: lastId,
6961+
fingerprint: prefixFingerprint(shadow, lastId),
6962+
},
6963+
}
6964+
: laneInjections.length > 0
6965+
? { v: 1, injections: laneInjections }
6966+
: null;
6967+
if (runtimeState !== null || persistedStateSet) {
6968+
changes.push({ op: "state", value: runtimeState } satisfies TranscriptChange);
6969+
}
6970+
transcriptState = runtimeState;
69246971
const inCursor = chatInputRouter().resumeFloor();
69256972
await transcriptStorage.save(
69266973
{
@@ -6949,6 +6996,7 @@ function chatAgent<
69496996
}
69506997
);
69516998
transcriptShadow = shadow;
6999+
persistedStateSet = runtimeState !== null;
69527000
};
69537001

69547002
/**
@@ -7033,6 +7081,8 @@ function chatAgent<
70337081
clientData: bootClientData,
70347082
});
70357083
transcriptShadow = createTranscriptShadow(loaded.messages);
7084+
bootTranscriptState = loaded.state;
7085+
persistedStateSet = loaded.state !== null && loaded.state !== undefined;
70367086
bootSnapshot = {
70377087
messages: loaded.messages,
70387088
lastOutEventId: loaded.cursors?.lastOutEventId,
@@ -7377,7 +7427,14 @@ function chatAgent<
73777427
}
73787428
}
73797429
try {
7380-
accumulatedMessages = await toModelMessages(accumulatedUIMessages);
7430+
const restored = await restoreModelLane(
7431+
accumulatedUIMessages,
7432+
parseTranscriptRuntimeState(bootTranscriptState),
7433+
(messages) => toModelMessages(messages)
7434+
);
7435+
accumulatedMessages = restored.messages;
7436+
laneCompacted = restored.compacted;
7437+
laneInjections = restored.injections;
73817438
} catch (error) {
73827439
logger.warn("chat.agent: toModelMessages failed at boot; starting empty", {
73837440
error: error instanceof Error ? error.message : String(error),
@@ -8049,6 +8106,8 @@ function chatAgent<
80498106
);
80508107
accumulatedUIMessages = [...hydrated] as TUIMessage[];
80518108
accumulatedMessages = await toModelMessages(hydrated);
8109+
laneCompacted = false;
8110+
laneInjections = [];
80528111
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
80538112
}
80548113

@@ -8086,6 +8145,8 @@ function chatAgent<
80868145
locals.set(chatOverrideMessagesKey, undefined);
80878146
accumulatedUIMessages = [...actionOverride] as TUIMessage[];
80888147
accumulatedMessages = await toModelMessages(actionOverride);
8148+
laneCompacted = false;
8149+
laneInjections = [];
80898150
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
80908151

80918152
actionChangedHistory = true;
@@ -8218,6 +8279,8 @@ function chatAgent<
82188279

82198280
accumulatedUIMessages = merged;
82208281
accumulatedMessages = await toModelMessages(merged);
8282+
laneCompacted = false;
8283+
laneInjections = [];
82218284
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
82228285

82238286
// Track new messages for onTurnComplete.newUIMessages.
@@ -8267,6 +8330,8 @@ function chatAgent<
82678330
accumulatedUIMessages.pop();
82688331
}
82698332
accumulatedMessages = await toModelMessages(accumulatedUIMessages);
8333+
laneCompacted = false;
8334+
laneInjections = [];
82708335
} else if (cleanedUIMessages.length > 0) {
82718336
// Submit-message (and the special-cased
82728337
// handover-prepare → submit-message rewrite earlier in
@@ -8320,6 +8385,8 @@ function chatAgent<
83208385
"chat.agent: replaced message not found at the model lane tail; reconverting the lane"
83218386
);
83228387
accumulatedMessages = await toModelMessages(accumulatedUIMessages);
8388+
laneCompacted = false;
8389+
laneInjections = [];
83238390
}
83248391
} else {
83258392
const incomingModelMessages = await toModelMessages(cleanedUIMessages);
@@ -8501,6 +8568,8 @@ function chatAgent<
85018568
locals.set(chatOverrideMessagesKey, undefined);
85028569
accumulatedUIMessages = [...turnStartOverride] as TUIMessage[];
85038570
accumulatedMessages = await toModelMessages(turnStartOverride);
8571+
laneCompacted = false;
8572+
laneInjections = [];
85048573
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
85058574
}
85068575
},
@@ -8566,7 +8635,12 @@ function chatAgent<
85668635
const lastAccumulated = accumulatedMessages[accumulatedMessages.length - 1];
85678636
const bgQueue = locals.get(chatBackgroundQueueKey);
85688637
if (bgQueue && bgQueue.length > 0 && lastAccumulated?.role !== "tool") {
8569-
accumulatedMessages.push(...bgQueue.splice(0));
8638+
const injected = bgQueue.splice(0);
8639+
accumulatedMessages.push(...injected);
8640+
laneInjections.push({
8641+
afterId: accumulatedUIMessages.at(-1)?.id ?? "",
8642+
messages: injected,
8643+
});
85708644
}
85718645

85728646
if (isHeadStartFinalTurn) {
@@ -8759,6 +8833,8 @@ function chatAgent<
87598833
accumulatedMessages = await toModelMessages(
87608834
runOverride.filter((m) => !pendingIds.has(m.id))
87618835
);
8836+
laneCompacted = false;
8837+
laneInjections = [];
87628838
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
87638839
}
87648840

@@ -8784,6 +8860,8 @@ function chatAgent<
87848860
accumulatedMessages = taskCompactionConfig?.compactModelMessages
87858861
? await taskCompactionConfig.compactModelMessages(compactEvent)
87868862
: modelOnlyOverride;
8863+
laneCompacted = true;
8864+
laneInjections = [];
87878865

87888866
// Apply UI messages: callback or default (preserve all)
87898867
if (taskCompactionConfig?.compactUIMessages) {
@@ -8876,6 +8954,8 @@ function chatAgent<
88768954
"chat.agent: replaced response not found at the model lane tail; reconverting the lane"
88778955
);
88788956
accumulatedMessages = await toModelMessages(accumulatedUIMessages);
8957+
laneCompacted = false;
8958+
laneInjections = [];
88798959
}
88808960
} else {
88818961
accumulatedMessages.push(...responseModelMessages);
@@ -8995,6 +9075,9 @@ function chatAgent<
89959075
},
89969076
];
89979077

9078+
laneCompacted = true;
9079+
laneInjections = [];
9080+
89989081
// UI messages: callback or default (preserve all)
89999082
if (outerCompaction.compactUIMessages) {
90009083
accumulatedUIMessages = (await outerCompaction.compactUIMessages(
@@ -9099,6 +9182,8 @@ function chatAgent<
90999182
locals.set(chatOverrideMessagesKey, undefined);
91009183
accumulatedUIMessages = [...override] as TUIMessage[];
91019184
accumulatedMessages = await toModelMessages(override);
9185+
laneCompacted = false;
9186+
laneInjections = [];
91029187
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
91039188
// Update event so onTurnComplete sees compacted messages
91049189
turnCompleteEvent.messages = accumulatedMessages;
@@ -9158,6 +9243,8 @@ function chatAgent<
91589243
locals.set(chatOverrideMessagesKey, undefined);
91599244
accumulatedUIMessages = [...turnCompleteOverride] as TUIMessage[];
91609245
accumulatedMessages = await toModelMessages(turnCompleteOverride);
9246+
laneCompacted = false;
9247+
laneInjections = [];
91619248
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
91629249
}
91639250
},
@@ -9526,6 +9613,8 @@ function chatAgent<
95269613
"chat.agent: replaced partial not found at the model lane tail; reconverting the lane"
95279614
);
95289615
accumulatedMessages = await toModelMessages(erroredUIMessagesWithPartial);
9616+
laneCompacted = false;
9617+
laneInjections = [];
95299618
}
95309619
}
95319620
accumulatedUIMessages = erroredUIMessagesWithPartial;

0 commit comments

Comments
 (0)