Skip to content

Commit 516c8de

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 15f04f2 commit 516c8de

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;
@@ -6919,6 +6948,24 @@ function chatAgent<
69196948
const { changes, shadow } = diffTranscript(transcriptShadow, opts.messages, {
69206949
nonFinalIds: opts.nonFinalIds,
69216950
});
6951+
const lastId = opts.messages.at(-1)?.id;
6952+
const runtimeState: TranscriptRuntimeState | null =
6953+
laneCompacted && lastId !== undefined
6954+
? {
6955+
v: 1,
6956+
compaction: {
6957+
modelMessages: accumulatedMessages,
6958+
throughId: lastId,
6959+
fingerprint: prefixFingerprint(shadow, lastId),
6960+
},
6961+
}
6962+
: laneInjections.length > 0
6963+
? { v: 1, injections: laneInjections }
6964+
: null;
6965+
if (runtimeState !== null || persistedStateSet) {
6966+
changes.push({ op: "state", value: runtimeState } satisfies TranscriptChange);
6967+
}
6968+
transcriptState = runtimeState;
69226969
const inCursor = chatInputRouter().resumeFloor();
69236970
await transcriptStorage.save(
69246971
{
@@ -6947,6 +6994,7 @@ function chatAgent<
69476994
}
69486995
);
69496996
transcriptShadow = shadow;
6997+
persistedStateSet = runtimeState !== null;
69506998
};
69516999

69527000
/**
@@ -7031,6 +7079,8 @@ function chatAgent<
70317079
clientData: bootClientData,
70327080
});
70337081
transcriptShadow = createTranscriptShadow(loaded.messages);
7082+
bootTranscriptState = loaded.state;
7083+
persistedStateSet = loaded.state !== null && loaded.state !== undefined;
70347084
bootSnapshot = {
70357085
messages: loaded.messages,
70367086
lastOutEventId: loaded.cursors?.lastOutEventId,
@@ -7375,7 +7425,14 @@ function chatAgent<
73757425
}
73767426
}
73777427
try {
7378-
accumulatedMessages = await toModelMessages(accumulatedUIMessages);
7428+
const restored = await restoreModelLane(
7429+
accumulatedUIMessages,
7430+
parseTranscriptRuntimeState(bootTranscriptState),
7431+
(messages) => toModelMessages(messages)
7432+
);
7433+
accumulatedMessages = restored.messages;
7434+
laneCompacted = restored.compacted;
7435+
laneInjections = restored.injections;
73797436
} catch (error) {
73807437
logger.warn("chat.agent: toModelMessages failed at boot; starting empty", {
73817438
error: error instanceof Error ? error.message : String(error),
@@ -8047,6 +8104,8 @@ function chatAgent<
80478104
);
80488105
accumulatedUIMessages = [...hydrated] as TUIMessage[];
80498106
accumulatedMessages = await toModelMessages(hydrated);
8107+
laneCompacted = false;
8108+
laneInjections = [];
80508109
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
80518110
}
80528111

@@ -8084,6 +8143,8 @@ function chatAgent<
80848143
locals.set(chatOverrideMessagesKey, undefined);
80858144
accumulatedUIMessages = [...actionOverride] as TUIMessage[];
80868145
accumulatedMessages = await toModelMessages(actionOverride);
8146+
laneCompacted = false;
8147+
laneInjections = [];
80878148
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
80888149

80898150
actionChangedHistory = true;
@@ -8216,6 +8277,8 @@ function chatAgent<
82168277

82178278
accumulatedUIMessages = merged;
82188279
accumulatedMessages = await toModelMessages(merged);
8280+
laneCompacted = false;
8281+
laneInjections = [];
82198282
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
82208283

82218284
// Track new messages for onTurnComplete.newUIMessages.
@@ -8265,6 +8328,8 @@ function chatAgent<
82658328
accumulatedUIMessages.pop();
82668329
}
82678330
accumulatedMessages = await toModelMessages(accumulatedUIMessages);
8331+
laneCompacted = false;
8332+
laneInjections = [];
82688333
} else if (cleanedUIMessages.length > 0) {
82698334
// Submit-message (and the special-cased
82708335
// handover-prepare → submit-message rewrite earlier in
@@ -8318,6 +8383,8 @@ function chatAgent<
83188383
"chat.agent: replaced message not found at the model lane tail; reconverting the lane"
83198384
);
83208385
accumulatedMessages = await toModelMessages(accumulatedUIMessages);
8386+
laneCompacted = false;
8387+
laneInjections = [];
83218388
}
83228389
} else {
83238390
const incomingModelMessages = await toModelMessages(cleanedUIMessages);
@@ -8499,6 +8566,8 @@ function chatAgent<
84998566
locals.set(chatOverrideMessagesKey, undefined);
85008567
accumulatedUIMessages = [...turnStartOverride] as TUIMessage[];
85018568
accumulatedMessages = await toModelMessages(turnStartOverride);
8569+
laneCompacted = false;
8570+
laneInjections = [];
85028571
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
85038572
}
85048573
},
@@ -8564,7 +8633,12 @@ function chatAgent<
85648633
const lastAccumulated = accumulatedMessages[accumulatedMessages.length - 1];
85658634
const bgQueue = locals.get(chatBackgroundQueueKey);
85668635
if (bgQueue && bgQueue.length > 0 && lastAccumulated?.role !== "tool") {
8567-
accumulatedMessages.push(...bgQueue.splice(0));
8636+
const injected = bgQueue.splice(0);
8637+
accumulatedMessages.push(...injected);
8638+
laneInjections.push({
8639+
afterId: accumulatedUIMessages.at(-1)?.id ?? "",
8640+
messages: injected,
8641+
});
85688642
}
85698643

85708644
if (isHeadStartFinalTurn) {
@@ -8757,6 +8831,8 @@ function chatAgent<
87578831
accumulatedMessages = await toModelMessages(
87588832
runOverride.filter((m) => !pendingIds.has(m.id))
87598833
);
8834+
laneCompacted = false;
8835+
laneInjections = [];
87608836
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
87618837
}
87628838

@@ -8782,6 +8858,8 @@ function chatAgent<
87828858
accumulatedMessages = taskCompactionConfig?.compactModelMessages
87838859
? await taskCompactionConfig.compactModelMessages(compactEvent)
87848860
: modelOnlyOverride;
8861+
laneCompacted = true;
8862+
laneInjections = [];
87858863

87868864
// Apply UI messages: callback or default (preserve all)
87878865
if (taskCompactionConfig?.compactUIMessages) {
@@ -8874,6 +8952,8 @@ function chatAgent<
88748952
"chat.agent: replaced response not found at the model lane tail; reconverting the lane"
88758953
);
88768954
accumulatedMessages = await toModelMessages(accumulatedUIMessages);
8955+
laneCompacted = false;
8956+
laneInjections = [];
88778957
}
88788958
} else {
88798959
accumulatedMessages.push(...responseModelMessages);
@@ -8993,6 +9073,9 @@ function chatAgent<
89939073
},
89949074
];
89959075

9076+
laneCompacted = true;
9077+
laneInjections = [];
9078+
89969079
// UI messages: callback or default (preserve all)
89979080
if (outerCompaction.compactUIMessages) {
89989081
accumulatedUIMessages = (await outerCompaction.compactUIMessages(
@@ -9097,6 +9180,8 @@ function chatAgent<
90979180
locals.set(chatOverrideMessagesKey, undefined);
90989181
accumulatedUIMessages = [...override] as TUIMessage[];
90999182
accumulatedMessages = await toModelMessages(override);
9183+
laneCompacted = false;
9184+
laneInjections = [];
91009185
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
91019186
// Update event so onTurnComplete sees compacted messages
91029187
turnCompleteEvent.messages = accumulatedMessages;
@@ -9156,6 +9241,8 @@ function chatAgent<
91569241
locals.set(chatOverrideMessagesKey, undefined);
91579242
accumulatedUIMessages = [...turnCompleteOverride] as TUIMessage[];
91589243
accumulatedMessages = await toModelMessages(turnCompleteOverride);
9244+
laneCompacted = false;
9245+
laneInjections = [];
91599246
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
91609247
}
91619248
},
@@ -9524,6 +9611,8 @@ function chatAgent<
95249611
"chat.agent: replaced partial not found at the model lane tail; reconverting the lane"
95259612
);
95269613
accumulatedMessages = await toModelMessages(erroredUIMessagesWithPartial);
9614+
laneCompacted = false;
9615+
laneInjections = [];
95279616
}
95289617
}
95299618
accumulatedUIMessages = erroredUIMessagesWithPartial;

0 commit comments

Comments
 (0)