Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/transcript-storage-followups.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@trigger.dev/sdk": patch
---

chat.agent transcript fixes: a turn that errors before the model produces any content no longer stores an empty assistant message, an error thrown without a message now shows a generic error instead of a blank one, and a custom transcript storage no longer needs to preserve exact message JSON for a compaction to survive a continuation.
125 changes: 74 additions & 51 deletions packages/trigger-sdk/src/v3/ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,6 @@ import {
defaultStorage,
diffTranscript,
parseTranscriptRuntimeState,
prefixFingerprint,
restoreModelLane,
type TranscriptChange,
type TranscriptChangeReason,
Expand Down Expand Up @@ -1350,7 +1349,8 @@ async function reportChatCustomAgentClientDataError(
error: unknown,
options: { writeToStream: boolean; callHandler?: boolean }
): Promise<void> {
const errorText = error instanceof Error ? error.message : "An unexpected error occurred";
const errorText =
error instanceof Error && error.message ? error.message : "An unexpected error occurred";
logger.warn("chat.customAgent: clientData validation failed", {
chatId: payload.chatId,
trigger: payload.trigger,
Expand Down Expand Up @@ -7171,7 +7171,6 @@ function chatAgent<
compaction: {
modelMessages: accumulatedMessages,
throughId,
fingerprint: prefixFingerprint(shadow, throughId),
},
}
: {}),
Expand Down Expand Up @@ -9150,6 +9149,7 @@ function chatAgent<
// The onFinish callback fires even on abort/stop, so partial responses
// from stopped generation are captured correctly.
let rawResponseMessage: TUIMessage | undefined;
let responseWasSkipped = false;
if (capturedResponseMessage) {
// Keep the raw message before cleanup for users who want custom handling
rawResponseMessage = capturedResponseMessage;
Expand All @@ -9175,56 +9175,65 @@ function chatAgent<
} as TUIMessage;
locals.set(chatResponsePartsKey, []);
}
// Tool-approval continuations: the AI SDK reuses the trailing
// assistant's ID (via originalMessages) so the captured response
// carries the same ID as an existing message. Replace in place
// instead of pushing a duplicate. For action turns this never
// matches because originalMessages is omitted (fresh ID).
const existingIdx = capturedResponseMessage.id
? accumulatedUIMessages.findIndex((m) => m.id === capturedResponseMessage!.id)
: -1;
const previousAtIdx =
existingIdx !== -1 ? accumulatedUIMessages[existingIdx] : undefined;
if (existingIdx !== -1) {
accumulatedUIMessages[existingIdx] = capturedResponseMessage;
} else {
accumulatedUIMessages.push(capturedResponseMessage);
}
turnNewUIMessages.push(capturedResponseMessage);
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
// Record toolCallId → head messageId so a HITL
// continuation next turn can recover the head id
// even if the AI SDK regenerates it. See
// `chatToolCallToMessageIdKey` for the full
// rationale (TRI-9137).
recordToolCallIdsFromMessage(capturedResponseMessage);
try {
const responseModelMessages = await toModelMessages([
stripProviderMetadata(capturedResponseMessage),
]);
const responseHasContent = capturedResponseMessage.parts.some(
(part) => part.type !== "step-start"
);
if (responseHasContent) {
Comment thread
ericallam marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// Tool-approval continuations: the AI SDK reuses the trailing
// assistant's ID (via originalMessages) so the captured response
// carries the same ID as an existing message. Replace in place
// instead of pushing a duplicate. For action turns this never
// matches because originalMessages is omitted (fresh ID).
const existingIdx = capturedResponseMessage.id
? accumulatedUIMessages.findIndex(
(m) => m.id === capturedResponseMessage!.id
)
: -1;
const previousAtIdx =
existingIdx !== -1 ? accumulatedUIMessages[existingIdx] : undefined;
if (existingIdx !== -1) {
const ok =
previousAtIdx !== undefined &&
(await replaceModelRun(
accumulatedMessages,
previousAtIdx,
capturedResponseMessage,
steerTailThisTurn
));
if (!ok) {
logger.warn(
"chat.agent: replaced response not found at the model lane tail; reconverting the lane"
);
accumulatedMessages = await toModelMessages(accumulatedUIMessages);
laneCompacted = false;
laneInjections = [];
}
accumulatedUIMessages[existingIdx] = capturedResponseMessage;
} else {
accumulatedMessages.push(...responseModelMessages);
accumulatedUIMessages.push(capturedResponseMessage);
}
turnNewModelMessages.push(...responseModelMessages);
} catch {
// Conversion failed — skip accumulation for this turn
turnNewUIMessages.push(capturedResponseMessage);
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
// Record toolCallId → head messageId so a HITL
// continuation next turn can recover the head id
// even if the AI SDK regenerates it. See
// `chatToolCallToMessageIdKey` for the full
// rationale (TRI-9137).
recordToolCallIdsFromMessage(capturedResponseMessage);
try {
const responseModelMessages = await toModelMessages([
stripProviderMetadata(capturedResponseMessage),
]);
if (existingIdx !== -1) {
const ok =
previousAtIdx !== undefined &&
(await replaceModelRun(
accumulatedMessages,
previousAtIdx,
capturedResponseMessage,
steerTailThisTurn
));
if (!ok) {
logger.warn(
"chat.agent: replaced response not found at the model lane tail; reconverting the lane"
);
accumulatedMessages = await toModelMessages(accumulatedUIMessages);
laneCompacted = false;
laneInjections = [];
}
} else {
accumulatedMessages.push(...responseModelMessages);
}
turnNewModelMessages.push(...responseModelMessages);
} catch {
// Conversion failed — skip accumulation for this turn
}
} else {
responseWasSkipped = true;
}
}
// If there's no captured response (manual pipe mode) but there are
Expand Down Expand Up @@ -9479,6 +9488,18 @@ function chatAgent<
capturedPartialResponse = capturedResponseMessage;
turnCompleteEvent.responseMessage = capturedResponseMessage;
turnCompleteEvent.uiMessages = accumulatedUIMessages;
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
} else if (responseWasSkipped) {
capturedResponseMessage = {
...capturedResponseMessage,
parts: [...(capturedResponseMessage.parts ?? []), ...lateParts],
} as TUIMessage;
accumulatedUIMessages.push(capturedResponseMessage);
turnNewUIMessages.push(capturedResponseMessage);
capturedPartialResponse = capturedResponseMessage;
turnCompleteEvent.responseMessage = capturedResponseMessage;
turnCompleteEvent.uiMessages = accumulatedUIMessages;
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
}
locals.set(chatResponsePartsKey, []);
}
Expand Down Expand Up @@ -9757,7 +9778,9 @@ function chatAgent<
try {
await withChatWriter(async (writer) => {
const errorText =
turnError instanceof Error ? turnError.message : "An unexpected error occurred";
turnError instanceof Error && turnError.message
? turnError.message
: "An unexpected error occurred";
writer.write({ type: "error", errorText } as any);
});
// Signal turn complete so the client knows this turn is done
Expand Down
7 changes: 1 addition & 6 deletions packages/trigger-sdk/src/v3/test/mock-chat-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -488,15 +488,10 @@ export function mockChatAgent(
} as never;
});

// session.in tail override: each seeded UIMessage becomes a
// { message, metadata: undefined, seqNum: i+1 } entry. Mirrors the
// seq-num pattern from the out-tail stub so cursor-advance logic is
// exercised correctly. `metadata` is `undefined` for seeded users —
// the boot path falls back to `payload.metadata` for those.
__setReplaySessionInTailImplForTests(async () => {
return seededSessionInMessages.map((message, i) => ({
message,
metadata: undefined,
metadata: clientData,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
seqNum: i + 1,
})) as never;
});
Expand Down
54 changes: 13 additions & 41 deletions packages/trigger-sdk/src/v3/transcriptStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -405,15 +405,17 @@ type ModelLaneInjection = { afterId: string; messages: ModelMessage[] };
* model's context that cannot be rebuilt from the transcript. Opaque to a
* storage; only the runtime reads it.
*
* `compaction` is the whole model lane after a compaction, valid for the
* transcript prefix ending at `throughId` whose fingerprint matches, so a
* rollback or edit of that prefix makes it unusable and the next save
* clears it. `injections` are conversational messages `chat.inject` added,
* anchored after the transcript message they followed.
* `compaction` is the whole model lane after a compaction, covering the
* transcript prefix ending at `throughId`. It is used as long as `throughId`
* still exists in the transcript, so a rollback or truncation that removes it
* rebuilds the lane from the transcript instead. Editing a message before
* `throughId` in place is unsupported and does not invalidate the lane.
* `injections` are conversational messages `chat.inject` added, anchored
* after the transcript message they followed.
*/
export type TranscriptRuntimeState = {
v: 1;
compaction?: { modelMessages: ModelMessage[]; throughId: string; fingerprint: string };
compaction?: { modelMessages: ModelMessage[]; throughId: string };
injections?: ModelLaneInjection[];
/** `chat.inject` messages queued but not yet drained into a turn when the save happened. */
queued?: ModelMessage[];
Expand All @@ -429,13 +431,11 @@ export function parseTranscriptRuntimeState(value: unknown): TranscriptRuntimeSt
compaction &&
typeof compaction === "object" &&
Array.isArray(compaction.modelMessages) &&
typeof compaction.throughId === "string" &&
typeof compaction.fingerprint === "string"
typeof compaction.throughId === "string"
) {
out.compaction = {
modelMessages: compaction.modelMessages as ModelMessage[],
throughId: compaction.throughId,
fingerprint: compaction.fingerprint,
};
}
if (Array.isArray(record.injections)) {
Expand All @@ -452,35 +452,11 @@ export function parseTranscriptRuntimeState(value: unknown): TranscriptRuntimeSt
return out;
}

/**
* A 32-bit FNV-1a hash over the fingerprints of the messages up to and
* including `throughId`, in order. Cheap enough to compute on every save
* because the per-message fingerprints already exist in the shadow.
*/
export function prefixFingerprint(shadow: TranscriptShadow, throughId: string): string {
let hash = 0x811c9dc5;
if (throughId === "") return hash.toString(16).padStart(8, "0");
const mix = (s: string) => {
for (let i = 0; i < s.length; i++) {
hash ^= s.charCodeAt(i);
hash = Math.imul(hash, 0x01000193) >>> 0;
}
};
for (const id of shadow.ids) {
mix(id);
mix("");
mix(shadow.fingerprints.get(id) ?? "");
mix("");
if (id === throughId) return hash.toString(16).padStart(8, "0");
}
return "";
}

/**
* Rebuild the model lane for a transcript at boot. Uses the persisted
* compacted lane when the transcript prefix it covers is unchanged, then
* converts the rest of the transcript, re-inserting persisted injections
* after the messages they followed.
* compacted lane while the transcript still contains its `throughId`
* boundary, then converts the rest of the transcript, re-inserting persisted
* injections after the messages they followed.
*/
export async function restoreModelLane<TUIMessage extends UIMessage>(
messages: TUIMessage[],
Expand All @@ -494,11 +470,7 @@ export async function restoreModelLane<TUIMessage extends UIMessage>(
if (state?.compaction) {
const throughId = state.compaction.throughId;
const idx = throughId === "" ? -1 : messages.findIndex((m) => m.id === throughId);
if (
(idx !== -1 || throughId === "") &&
prefixFingerprint(createTranscriptShadow(messages), throughId) ===
state.compaction.fingerprint
) {
if (idx !== -1 || throughId === "") {
lane.push(...state.compaction.modelMessages);
start = idx + 1;
compacted = true;
Expand Down
30 changes: 30 additions & 0 deletions packages/trigger-sdk/test/recovery-boot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { simulateReadableStream, streamText } from "ai";
import { MockLanguageModelV3 } from "ai/test";
import { TestSessionStreamManager } from "@trigger.dev/core/v3/test";
import { describe, expect, it, vi } from "vitest";
import { z } from "zod/v4";
import type { RecoveryBootEvent, RecoveryBootResult } from "../src/v3/ai.js";
import { __setReplaySessionOutTailImplForTests, chat } from "../src/v3/ai.js";

Expand Down Expand Up @@ -609,4 +610,33 @@ describe("continuation boot — the message that resumed the run", () => {
await harness.close();
}
});

it("re-dispatches a recovered in-flight user for a clientData-scoped agent", async () => {
let modelCalls = 0;
const model = new MockLanguageModelV3({
doStream: async () => {
modelCalls++;
return { stream: textStream("answered") };
},
});
const u1 = userMessage("the interrupted question", "u-1");
const agent = chat.agent({
id: "recovery-boot.clientdata-scoped",
clientDataSchema: z.object({ userId: z.string() }),
run: async ({ messages, signal }) => streamText({ model, messages, abortSignal: signal }),
});
const harness = mockChatAgent(agent, {
chatId: "clientdata-scoped",
continuation: true,
previousRunId: "run_prior",
clientData: { userId: "u_123" },
});
harness.seedSessionInTail([u1 as never]);
try {
await new Promise((r) => setTimeout(r, 100));
expect(modelCalls).toBe(1);
} finally {
await harness.close();
}
});
});
Loading
Loading