Skip to content

Commit 34996fd

Browse files
committed
test(webapp): pin chat.agent turn durability across crash and resume
The primitive resumes a turn by replaying its snapshot; pin the store seam that replay lands on: a streamed-then-resumed turn is not double-appended, a crash mid-turn keeps the mid-turn append and rebuilds the session cursor, a failed write commits nothing and the retry replays with no loss, and an OOM restart replays idempotently. TRI-11166.
1 parent 4efe0d1 commit 34996fd

1 file changed

Lines changed: 317 additions & 0 deletions

File tree

Lines changed: 317 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,317 @@
1+
import {
2+
appendChatMessageOnceByChatId,
3+
createChat,
4+
createDashboardAgentDb,
5+
getChatMessages,
6+
getSession,
7+
persistMessages,
8+
persistTurn,
9+
type DashboardAgentDb,
10+
type DashboardAgentDbClient,
11+
} from "@internal/dashboard-agent-db";
12+
import { postgresTest } from "@internal/testcontainers";
13+
import type { PrismaClient } from "@trigger.dev/database";
14+
import { readdirSync, readFileSync } from "node:fs";
15+
import path from "node:path";
16+
import { afterEach, describe, expect } from "vitest";
17+
18+
/**
19+
* Durability of a chat.agent turn across a crash and a resume, against a real table
20+
* (TRI-11166).
21+
*
22+
* The primitive gives chat.agent durability by snapshotting the transcript and replaying it
23+
* on the next boot. These tests pin the store seam that replay lands on: the completing turn
24+
* re-sends its whole snapshot, so the store has to fold that replay into exactly one row per
25+
* message — no double-appended turn, no lost mid-turn message — and reconstruct the session
26+
* cursor a refreshed client resumes from.
27+
*
28+
* What is NOT covered here, because it lives inside the closed chat.agent primitive package
29+
* (object-store snapshot write, S2 `.in`/`.out` replay, `.out` trimming, OOM restart): the
30+
* transport-level replay and the snapshot URL's own auth. The client-side reconnect / Last-
31+
* Event-ID replay is covered in packages/trigger-sdk/src/v3/chat.test.ts. These tests are the
32+
* store-level backstop those depend on. See the PR body for the residual follow-ups.
33+
*/
34+
35+
let agentDb: DashboardAgentDb;
36+
let agentDbClient: DashboardAgentDbClient | undefined;
37+
38+
const MIGRATIONS = path.resolve(__dirname, "../../../internal-packages/dashboard-agent-db/drizzle");
39+
40+
async function applyAgentSchema(prisma: PrismaClient) {
41+
for (const name of readdirSync(MIGRATIONS)
42+
.filter((file) => file.endsWith(".sql"))
43+
.sort()) {
44+
const sql = readFileSync(path.join(MIGRATIONS, name), "utf8");
45+
for (const statement of sql.split("--> statement-breakpoint")) {
46+
const trimmed = statement.trim();
47+
if (trimmed.length > 0) await prisma.$executeRawUnsafe(trimmed);
48+
}
49+
}
50+
}
51+
52+
const ORG = "org_resume";
53+
const USER = "user_resume";
54+
55+
async function boot(prisma: PrismaClient, connectionUri: string, chatId: string) {
56+
await applyAgentSchema(prisma);
57+
agentDbClient = createDashboardAgentDb(connectionUri, { max: 4 });
58+
agentDb = agentDbClient.db;
59+
await createChat(agentDb, { id: chatId, organizationId: ORG, userId: USER });
60+
}
61+
62+
afterEach(async () => {
63+
await agentDbClient?.close();
64+
agentDbClient = undefined;
65+
});
66+
67+
function textMessage(id: string, role: "user" | "assistant" = "assistant", text = id) {
68+
return { id, role, parts: [{ type: "text", text }] };
69+
}
70+
71+
/** A tool part, so a mid-flight call and its completed result share an id but differ in body. */
72+
function toolMessage(id: string, state: "input-available" | "output-available") {
73+
return {
74+
id,
75+
role: "assistant" as const,
76+
parts: [{ type: "tool-get_query_schema", state, toolCallId: `${id}_call`, input: {} }],
77+
};
78+
}
79+
80+
async function transcript(chatId: string): Promise<{ id: string }[]> {
81+
return (await getChatMessages(agentDb, { chatId, organizationId: ORG, userId: USER })) as {
82+
id: string;
83+
}[];
84+
}
85+
86+
/** The allocator, where a wasted/duplicated slot is observable. */
87+
async function nextPosition(prisma: PrismaClient, chatId: string): Promise<number> {
88+
const rows = await prisma.$queryRawUnsafe<{ next_message_position: number }[]>(
89+
`select next_message_position from trigger_dashboard_agent.chats where id = $1`,
90+
chatId
91+
);
92+
return rows[0]!.next_message_position;
93+
}
94+
95+
async function rowCount(prisma: PrismaClient, chatId: string): Promise<number> {
96+
const rows = await prisma.$queryRawUnsafe<{ count: bigint }[]>(
97+
`select count(*)::int as count from trigger_dashboard_agent.chat_messages where chat_id = $1`,
98+
chatId
99+
);
100+
return Number(rows[0]!.count);
101+
}
102+
103+
describe("a streamed-then-resumed turn is not double-appended", () => {
104+
postgresTest(
105+
"re-delivering the completing turn finalises in place and appends nothing",
106+
async ({ prisma, postgresContainer }) => {
107+
const chatId = "chat_no_double";
108+
await boot(prisma, postgresContainer.getConnectionUri(), chatId);
109+
110+
// The turn started: onTurnStart stored the user turn and the tool call mid-flight.
111+
await persistMessages(agentDb, {
112+
chatId,
113+
messages: [textMessage("u1", "user"), toolMessage("a1", "input-available")],
114+
});
115+
expect(await rowCount(prisma, chatId)).toBe(2);
116+
117+
const completing = {
118+
chatId,
119+
messages: [textMessage("u1", "user"), toolMessage("a1", "output-available")],
120+
finalizeMessageIds: ["a1"],
121+
session: { publicAccessToken: "pat", lastEventId: "7", runId: "run" },
122+
};
123+
124+
// The turn completes, replaying its whole snapshot. `a1` is finalised, not re-added.
125+
await persistTurn(agentDb, completing);
126+
// The resume: the same completed turn is delivered again (client reconnected and the
127+
// host re-persisted). It must converge — no second `a1`, no extra row of any kind.
128+
await persistTurn(agentDb, completing);
129+
130+
expect((await transcript(chatId)).map((m) => m.id)).toEqual(["u1", "a1"]);
131+
expect(await rowCount(prisma, chatId)).toBe(2);
132+
// Only u1 and a1 ever reserved a slot (allocator starts at 1); the finalisation and the
133+
// replay reserve none, so the next free position is still 3.
134+
expect(await nextPosition(prisma, chatId)).toBe(3);
135+
// And `a1` is the completed body the user saw, not the mid-flight call.
136+
const stored = (await transcript(chatId))[1] as unknown as {
137+
parts: { state: string }[];
138+
};
139+
expect(stored.parts[0]!.state).toBe("output-available");
140+
},
141+
30_000
142+
);
143+
});
144+
145+
describe("a crash mid-turn is reconstructed by the next boot's replay", () => {
146+
postgresTest(
147+
"the resumed turn keeps the mid-turn append, finalises its own message, and rebuilds the session cursor",
148+
async ({ prisma, postgresContainer }) => {
149+
const chatId = "chat_crash_resume";
150+
await boot(prisma, postgresContainer.getConnectionUri(), chatId);
151+
152+
// Turn in flight: the snapshot it started from, stored before the model finished.
153+
const snapshot = [textMessage("u1", "user"), toolMessage("a1", "input-available")];
154+
await persistMessages(agentDb, { chatId, messages: snapshot });
155+
156+
// A wake lands mid-turn, off its own lane — the message the old replace-the-array
157+
// write used to lose.
158+
await appendChatMessageOnceByChatId(agentDb, {
159+
chatId,
160+
message: textMessage("wake:w1"),
161+
});
162+
163+
// Before the crash there is no session row to resume from.
164+
expect(await getSession(agentDb, { chatId, organizationId: ORG, userId: USER })).toBeNull();
165+
166+
// Boot after the crash: replay the whole transcript, finalise the turn's own message,
167+
// and write the session the client resumes from — all in one persistTurn.
168+
await persistTurn(agentDb, {
169+
chatId,
170+
messages: [
171+
textMessage("u1", "user"),
172+
toolMessage("a1", "output-available"),
173+
textMessage("a2"),
174+
],
175+
finalizeMessageIds: ["a1", "a2"],
176+
session: { publicAccessToken: "pat_resumed", lastEventId: "99", runId: "run_resumed" },
177+
});
178+
179+
// Nothing was lost and the wake sits where it happened: after the snapshot, before the
180+
// reply the turn went on to produce.
181+
expect((await transcript(chatId)).map((m) => m.id)).toEqual(["u1", "a1", "wake:w1", "a2"]);
182+
183+
const session = await getSession(agentDb, { chatId, organizationId: ORG, userId: USER });
184+
expect(session).toMatchObject({
185+
publicAccessToken: "pat_resumed",
186+
lastEventId: "99",
187+
runId: "run_resumed",
188+
});
189+
},
190+
30_000
191+
);
192+
});
193+
194+
describe("the session cursor a refreshed client resumes from", () => {
195+
postgresTest(
196+
"getSession returns the last persisted cursor, and a later turn advances it",
197+
async ({ prisma, postgresContainer }) => {
198+
const chatId = "chat_cursor";
199+
await boot(prisma, postgresContainer.getConnectionUri(), chatId);
200+
201+
await persistTurn(agentDb, {
202+
chatId,
203+
messages: [textMessage("u1", "user"), textMessage("a1")],
204+
session: { publicAccessToken: "pat1", lastEventId: "10", runId: "run1" },
205+
});
206+
// A mid-stream refresh reads exactly this cursor and resumes .out from it.
207+
expect(
208+
(await getSession(agentDb, { chatId, organizationId: ORG, userId: USER }))?.lastEventId
209+
).toBe("10");
210+
211+
// The next turn overwrites the cursor — a stale value is replaced, never appended.
212+
await persistTurn(agentDb, {
213+
chatId,
214+
messages: [textMessage("u1", "user"), textMessage("a1"), textMessage("a2")],
215+
session: { publicAccessToken: "pat2", lastEventId: "25", runId: "run2" },
216+
});
217+
const session = await getSession(agentDb, { chatId, organizationId: ORG, userId: USER });
218+
expect(session).toMatchObject({
219+
publicAccessToken: "pat2",
220+
lastEventId: "25",
221+
runId: "run2",
222+
});
223+
},
224+
30_000
225+
);
226+
});
227+
228+
describe("a failed snapshot write leaves the next boot a clean replay", () => {
229+
postgresTest(
230+
"a persistTurn that throws commits nothing, and the retry replays with no loss",
231+
async ({ prisma, postgresContainer }) => {
232+
const chatId = "chat_write_fail";
233+
await boot(prisma, postgresContainer.getConnectionUri(), chatId);
234+
235+
// A durable first turn, and the session cursor it left.
236+
await persistTurn(agentDb, {
237+
chatId,
238+
messages: [textMessage("u1", "user"), textMessage("a1")],
239+
session: { publicAccessToken: "pat1", lastEventId: "1", runId: "run1" },
240+
});
241+
const positionBefore = await nextPosition(prisma, chatId);
242+
243+
// The next turn's write fails partway — a malformed message with no id throws inside the
244+
// transaction, after the (would-be) settlement/message work has begun.
245+
await expect(
246+
persistTurn(agentDb, {
247+
chatId,
248+
messages: [
249+
textMessage("u1", "user"),
250+
textMessage("a1"),
251+
textMessage("a2"),
252+
{ role: "assistant", parts: [] } as unknown as { id: string; role: string },
253+
],
254+
session: { publicAccessToken: "pat_torn", lastEventId: "2", runId: "run_torn" },
255+
})
256+
).rejects.toThrow(/handed a message with no id/);
257+
258+
// The whole turn rolled back: no new rows, allocator untouched, and — the version-
259+
// mismatch case — the session cursor is still the first turn's, not the torn one's.
260+
expect((await transcript(chatId)).map((m) => m.id)).toEqual(["u1", "a1"]);
261+
expect(await nextPosition(prisma, chatId)).toBe(positionBefore);
262+
expect(
263+
await getSession(agentDb, { chatId, organizationId: ORG, userId: USER })
264+
).toMatchObject({ publicAccessToken: "pat1", lastEventId: "1" });
265+
266+
// The retry — a clean replay of the same turn — lands everything exactly once.
267+
await persistTurn(agentDb, {
268+
chatId,
269+
messages: [textMessage("u1", "user"), textMessage("a1"), textMessage("a2")],
270+
session: { publicAccessToken: "pat2", lastEventId: "2", runId: "run2" },
271+
});
272+
expect((await transcript(chatId)).map((m) => m.id)).toEqual(["u1", "a1", "a2"]);
273+
expect(
274+
await getSession(agentDb, { chatId, organizationId: ORG, userId: USER })
275+
).toMatchObject({ publicAccessToken: "pat2", lastEventId: "2" });
276+
},
277+
30_000
278+
);
279+
});
280+
281+
describe("an OOM restart replays the turn cleanly", () => {
282+
postgresTest(
283+
"a restarted turn that re-sends its snapshot loses no data and doubles nothing",
284+
async ({ prisma, postgresContainer }) => {
285+
// The store seam an OOM restart lands on: the primitive restarts the run, replays `.in`,
286+
// and re-persists. `.out` trimming and the OOM restart itself are inside the primitive
287+
// (not reachable here) — this pins that a re-run's re-sent snapshot is idempotent.
288+
const chatId = "chat_oom_restart";
289+
await boot(prisma, postgresContainer.getConnectionUri(), chatId);
290+
291+
const firstAttempt = [textMessage("u1", "user"), toolMessage("a1", "input-available")];
292+
await persistMessages(agentDb, { chatId, messages: firstAttempt });
293+
const positionAfterFirst = await nextPosition(prisma, chatId);
294+
295+
// The run OOMs and restarts. It replays the same input, produces the same ids, and
296+
// finalises the turn it now completes.
297+
const restarted = {
298+
chatId,
299+
messages: [
300+
textMessage("u1", "user"),
301+
toolMessage("a1", "output-available"),
302+
textMessage("a2"),
303+
],
304+
finalizeMessageIds: ["a1", "a2"],
305+
session: { publicAccessToken: "pat", lastEventId: "5", runId: "run_restarted" },
306+
};
307+
await persistTurn(agentDb, restarted);
308+
// A second restart delivering the same turn again still converges.
309+
await persistTurn(agentDb, restarted);
310+
311+
expect((await transcript(chatId)).map((m) => m.id)).toEqual(["u1", "a1", "a2"]);
312+
// The replayed u1/a1 reserved no new slots; only a2 was genuinely new.
313+
expect(await nextPosition(prisma, chatId)).toBe(positionAfterFirst + 1);
314+
},
315+
30_000
316+
);
317+
});

0 commit comments

Comments
 (0)