Skip to content

Commit 3f67c71

Browse files
ericallamTrigger.dev RepoOps
authored andcommitted
fix(sdk): keep chat.history edits made in onTurnComplete after a failed turn
`chat.agent` now keeps a `chat.history` edit made inside `onTurnComplete` after a failed turn. Previously the edit was applied only when the turn succeeded, so a failure record or a card the hook closed on the error path never reached the transcript. Details of the error-path behaviour: - The edit is converted to model messages before any state is replaced, so a conversion that throws (for example from a tool's `toModelOutput`) leaves the history exactly as it was. - The stream's partial answer stays marked non-final only while the message under its id is still that partial by content. A hook that clones the history keeps it partial; a hook that finishes it in place saves it as final. - A history edit left pending by an earlier hook that threw is discarded before the failed turn continues, whether or not the agent defines `onTurnComplete`, so it can never be mistaken for a later hook's edit or leak into the next turn. Includes a changeset for `@trigger.dev/sdk` (patch). Mono-RevId: ac849a203949f8a719a8c33866004cf517d5f795
1 parent 8b72e6c commit 3f67c71

4 files changed

Lines changed: 459 additions & 3 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@trigger.dev/sdk": patch
3+
---
4+
5+
`chat.agent`: a `chat.history` edit made in `onTurnComplete` after a failed turn is now kept. Previously the edit was applied only when the turn succeeded, so a failure record or a card the hook closed on the error path never reached the transcript.

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

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ import {
8181
createTranscriptShadow,
8282
defaultStorage,
8383
diffTranscript,
84+
fingerprintMessage,
8485
parseTranscriptRuntimeState,
8586
restoreModelLane,
8687
type TranscriptChange,
@@ -1138,6 +1139,24 @@ const chatOutGateKey = locals.create<ChatOutGate>("chat.outGate");
11381139
*/
11391140
const CHAT_OUT_GATE_TIMEOUT_MS = 10_000;
11401141

1142+
/**
1143+
* The ids to save non-final after a failed turn: the stream's partial answer,
1144+
* but only while the message under its id is still that partial by content.
1145+
* `onTurnComplete` may hand back a cloned history (same content, new objects),
1146+
* which keeps it partial, or replace it in place, which finishes it.
1147+
* @internal
1148+
*/
1149+
function partialStillUnfinished(
1150+
partial: UIMessage | undefined,
1151+
fingerprint: string | undefined,
1152+
messages: readonly UIMessage[]
1153+
): Set<string> | undefined {
1154+
if (!partial || fingerprint === undefined) return undefined;
1155+
const current = messages.find((message) => message.id === partial.id);
1156+
if (!current || fingerprintMessage(current) !== fingerprint) return undefined;
1157+
return new Set([partial.id]);
1158+
}
1159+
11411160
async function awaitChatOutGate(): Promise<void> {
11421161
const gate = locals.get(chatOutGateKey);
11431162
if (!gate || gate.open) return;
@@ -10098,6 +10117,11 @@ function chatAgent<
1009810117
}
1009910118
}
1010010119
const includePartial = partialResponse != null && !responseCommitted;
10120+
// What the stream left behind, by content. After `onTurnComplete` the
10121+
// partial is still unfinished only if the message under its id is
10122+
// byte-for-byte this: a clone keeps it partial, an edit finishes it.
10123+
const partialFingerprint =
10124+
includePartial && partialResponse ? fingerprintMessage(partialResponse) : undefined;
1010110125
let erroredUIMessagesWithPartial: TUIMessage[] = !includePartial
1010210126
? erroredUIMessages
1010310127
: partialIdx === -1
@@ -10188,6 +10212,12 @@ function chatAgent<
1018810212
}
1018910213
}
1019010214

10215+
// An earlier hook that set the history and then threw (which is one way
10216+
// to get here) left its abandoned edit pending. Discard it before the
10217+
// failed turn continues, so neither the error-path `onTurnComplete`
10218+
// below nor the next turn's history reads mistake it for a real edit.
10219+
locals.set(chatOverrideMessagesKey, undefined);
10220+
1019110221
if (onTurnComplete) {
1019210222
try {
1019310223
await tracer.startActiveSpan(
@@ -10218,6 +10248,24 @@ function chatAgent<
1021810248
error: turnError,
1021910249
lastEventId: errorTurnCompleteResult?.lastEventId,
1022010250
});
10251+
10252+
// The hook may edit the history here too (a failure record, a
10253+
// card the turn left open). Honour it the way the success path
10254+
// does, so the edit reaches the accumulator and the save below.
10255+
const errorTurnOverride = locals.get(chatOverrideMessagesKey);
10256+
if (errorTurnOverride) {
10257+
locals.set(chatOverrideMessagesKey, undefined);
10258+
// Convert first: a rejected conversion (a tool's `toModelOutput`
10259+
// can throw) must leave every lane on the history it had.
10260+
const overrideUIMessages = [...errorTurnOverride] as TUIMessage[];
10261+
const overrideModelMessages = await toModelMessages(errorTurnOverride);
10262+
erroredUIMessagesWithPartial = overrideUIMessages;
10263+
accumulatedUIMessages = overrideUIMessages;
10264+
accumulatedMessages = overrideModelMessages;
10265+
laneCompacted = false;
10266+
laneInjections = [];
10267+
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
10268+
}
1022110269
},
1022210270
{
1022310271
attributes: {
@@ -10232,6 +10280,7 @@ function chatAgent<
1023210280
} catch {
1023310281
// A throwing onTurnComplete on the error path must not crash
1023410282
// the run — keep the conversation alive for the next message.
10283+
locals.set(chatOverrideMessagesKey, undefined);
1023510284
}
1023610285
}
1023710286

@@ -10250,8 +10299,14 @@ function chatAgent<
1025010299
trigger: storageTrigger(currentWirePayload.trigger),
1025110300
clientData: turnClientData,
1025210301
lastOutEventId: lastSnapshotOutEventId,
10253-
nonFinalIds:
10254-
includePartial && partialResponse ? new Set([partialResponse.id]) : undefined,
10302+
// The partial is non-final only while the message under its id is
10303+
// still what the stream left behind. A hook that replaced it (a
10304+
// closed card, a finished body) produced a final message.
10305+
nonFinalIds: partialStillUnfinished(
10306+
partialResponse,
10307+
partialFingerprint,
10308+
erroredUIMessagesWithPartial
10309+
),
1025510310
});
1025610311
} catch (error) {
1025710312
logger.warn("chat.agent: error-path snapshot write failed", {

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,10 @@ export type TranscriptShadow = {
191191
nonFinal: Set<string>;
192192
};
193193

194-
function fingerprintMessage(message: UIMessage): string {
194+
/**
195+
* The content identity the transcript shadow compares messages by. @internal
196+
*/
197+
export function fingerprintMessage(message: UIMessage): string {
195198
return JSON.stringify(message);
196199
}
197200

0 commit comments

Comments
 (0)