Skip to content

Commit 07cd5c5

Browse files
committed
fix(sdk,ai-chat): auto chat:{chatId} tag on server-mediated start; atomic persist in reference onTurnComplete
- chat.createStartSessionAction now adds 'chat:{chatId}' as the first tag on the triggered run, matching the browser-mediated transport.doStart path. Customer-provided tags merge after, capped at 5. Without this, runs created via server actions were untagged, breaking the dashboard chat-id filter. - references/ai-chat onTurnComplete persists Chat.messages and ChatSession.lastEventId in a single prisma.$transaction. Two parallel reads on the next page load (Promise.all([getChatMessages, getSessionForChat])) can otherwise observe messages post-write but lastEventId pre-write. The transport then resumes from the stale cursor and replays this turn's chunks on top of the already-persisted assistant message, duplicating the render. Applies to both the main chat.agent and the hydrated variant.
1 parent 9582a71 commit 07cd5c5

2 files changed

Lines changed: 34 additions & 24 deletions

File tree

  • packages/trigger-sdk/src/v3
  • references/ai-chat/src/trigger

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

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6942,6 +6942,12 @@ function createChatStartSessionAction(
69426942
// `metadata` is the customer's transport-level `clientData`,
69436943
// threaded through so the agent's `clientDataSchema` validates on
69446944
// the very first turn (the typical schema requires `userId` etc.).
6945+
// Auto-tag every chat.agent run with `chat:{chatId}` so the dashboard /
6946+
// run-list filter by chat works without the customer having to wire it
6947+
// up. Mirrors the browser-mediated `TriggerChatTransport.doStart` path.
6948+
const userTags = params.triggerConfig?.tags ?? options?.triggerConfig?.tags ?? [];
6949+
const tags = [`chat:${params.chatId}`, ...userTags].slice(0, 5);
6950+
69456951
const triggerConfig: SessionTriggerConfig = {
69466952
basePayload: {
69476953
messages: [],
@@ -6956,12 +6962,7 @@ function createChatStartSessionAction(
69566962
...(options?.triggerConfig?.queue || params.triggerConfig?.queue
69576963
? { queue: params.triggerConfig?.queue ?? options?.triggerConfig?.queue }
69586964
: {}),
6959-
...(options?.triggerConfig?.tags || params.triggerConfig?.tags
6960-
? {
6961-
tags:
6962-
params.triggerConfig?.tags ?? options?.triggerConfig?.tags ?? [],
6963-
}
6964-
: {}),
6965+
tags,
69656966
...(options?.triggerConfig?.maxAttempts !== undefined ||
69666967
params.triggerConfig?.maxAttempts !== undefined
69676968
? {

references/ai-chat/src/trigger/chat.ts

Lines changed: 27 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -411,15 +411,21 @@ export const aiChat = chat
411411
toolPartsCount: toolParts.length,
412412
toolParts: toolParts.map((p: any) => ({ type: p.type, state: p.state, toolCallId: p.toolCallId })),
413413
});
414-
await prisma.chat.update({
415-
where: { id: chatId },
416-
data: { messages: uiMessages as unknown as ChatMessagesForWrite },
417-
});
418-
await prisma.chatSession.upsert({
419-
where: { id: chatId },
420-
create: { id: chatId, publicAccessToken: chatAccessToken, lastEventId },
421-
update: { publicAccessToken: chatAccessToken, lastEventId },
422-
});
414+
// Atomic so the page-load `Promise.all([getChatMessages, getSessionForChat])`
415+
// can't observe a state where messages are post-write but lastEventId is
416+
// still pre-write — that race causes resume to replay this turn's chunks
417+
// on top of the persisted assistant message and duplicates the render.
418+
await prisma.$transaction([
419+
prisma.chat.update({
420+
where: { id: chatId },
421+
data: { messages: uiMessages as unknown as ChatMessagesForWrite },
422+
}),
423+
prisma.chatSession.upsert({
424+
where: { id: chatId },
425+
create: { id: chatId, publicAccessToken: chatAccessToken, lastEventId },
426+
update: { publicAccessToken: chatAccessToken, lastEventId },
427+
}),
428+
]);
423429

424430
// Background self-review — a cheap model critiques the response and
425431
// injects coaching into the conversation before the next user message.
@@ -920,15 +926,18 @@ export const aiChatHydrated = chat
920926
},
921927

922928
onTurnComplete: async ({ chatId, uiMessages, runId, chatAccessToken, lastEventId }) => {
923-
await prisma.chat.update({
924-
where: { id: chatId },
925-
data: { messages: uiMessages as unknown as ChatMessagesForWrite },
926-
});
927-
await prisma.chatSession.upsert({
928-
where: { id: chatId },
929-
create: { id: chatId, publicAccessToken: chatAccessToken, lastEventId },
930-
update: { publicAccessToken: chatAccessToken, lastEventId },
931-
});
929+
// See aiChat.onTurnComplete — atomic to avoid the resume-replay race.
930+
await prisma.$transaction([
931+
prisma.chat.update({
932+
where: { id: chatId },
933+
data: { messages: uiMessages as unknown as ChatMessagesForWrite },
934+
}),
935+
prisma.chatSession.upsert({
936+
where: { id: chatId },
937+
create: { id: chatId, publicAccessToken: chatAccessToken, lastEventId },
938+
update: { publicAccessToken: chatAccessToken, lastEventId },
939+
}),
940+
]);
932941
},
933942

934943
run: async ({ messages, clientData, stopSignal }) => {

0 commit comments

Comments
 (0)