Skip to content

Commit a5b1482

Browse files
authored
test(webapp): chat.agent durability regression suite (#4550)
## What & why Test-only hardening for chat.agent durability. chat.agent gets its durability from the primitive — object-store snapshot + S2 `.in`/`.out` replay + continuation boot — but several store-level mechanisms that replay lands on had no regression test, and two of them were criticals in the 2026-06-10 chat.agent audit: **cross-tenant isolation** and **no duplicate mid-stream turn**. This adds those cases against a real Postgres table (testcontainers, no mocks). [TRI-11166](https://linear.app/triggerdotdev/issue/TRI-11166). ## Stack Stacked on **#4549** (query boundary). Merge that first. ## What's inside | # | Mechanism | Test | Coverage | Control-broken | |---|-----------|------|----------|----------------| | 1 | **Cross-tenant isolation** (audit critical) | `dashboardAgentTenantIsolation.test.ts` | Full at the store seam we own: getChatMessages / getSession / chatExists / listChats / countUserMessages / appendChatMessageOnce all refuse a foreign (org, user) — a foreign tenant reads not-found, never a transcript or the session's public access token | Yes | | 2 | **No duplicate mid-stream turn** (audit critical) | `dashboardAgentDurableResume.test.ts` | Full: a streamed-then-resumed turn finalises in place and appends nothing; row count and the position allocator both pinned | Yes | | 3 | **Crash-resume reconstructs state** | `dashboardAgentDurableResume.test.ts` | Full: replay keeps the mid-turn append, finalises the turn's own message, loses no messages, and rebuilds the session cursor read back via getSession | — | | 4 | **Mid-stream refresh resumes in-flight turn (Last-Event-ID)** | `dashboardAgentDurableResume.test.ts` | Seam-only: the cursor getSession hands a refreshed client, and a later turn advances (never appends) it. Client-side reconnect / Last-Event-ID replay is already covered in `packages/trigger-sdk/src/v3/chat.test.ts` — not duplicated | — | | 5 | **Snapshot write-failure path** | `dashboardAgentDurableResume.test.ts` | Full at this seam: a persistTurn that throws commits nothing (no rows, allocator untouched, cursor unchanged — the version-mismatch case), and the retry replays with no loss | — | | 6 | **`.out` trimming / OOM retry restarts cleanly** | `dashboardAgentDurableResume.test.ts` | Seam-only: a restarted turn that re-sends its snapshot loses no data and doubles nothing. `.out` trimming and the OOM restart itself are inside the closed primitive (not reachable) | — | The "Full" vs "Seam-only" column is the honest distinction: full means the whole mechanism is exercised from the repos we own; seam-only means we pin the store contract the primitive depends on, and the primitive-internal half lives where we can't reach it. ## Key decisions - **The two criticals were control-broken first.** Isolation: removing the `organizationId` filter from `getChatMessages` leaks org A's transcript to the owner's user id under another org — the test fails at `toBeNull()`. No-duplicate: removing the stored-id drop in `storeChatMessages` makes a replayed persistTurn over-reserve positions (next free slot jumps 3 → 7) — the test fails on the allocator assertion. Both reverted. - **Real Postgres, no mocks.** testcontainers against an actual table, so the store contract is proven, not asserted against a stub. ## Residual follow-ups These live inside the closed chat.agent primitive package and can't be unit-tested from the repos we own; the tests above are the store-level backstop they depend on: - The snapshot URL's own auth gate (the audit's snapshot-URL auth gap) — enforced in the primitive; here we prove the webapp store never hands a foreign tenant the PAT it would boot from. - Object-store snapshot write + S2 `.in`/`.out` replay at the transport level. - `.out` trimming never dropping in-flight data, and the OOM restart mechanism itself. ## Testing `pnpm run test --filter webapp` — `dashboardAgentTenantIsolation.test.ts` and `dashboardAgentDurableResume.test.ts`.
1 parent cfaf934 commit a5b1482

2 files changed

Lines changed: 587 additions & 0 deletions

File tree

Lines changed: 347 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,347 @@
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 mid-write rolls back what it already wrote, 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, its tool call still mid-flight, and the session cursor it left.
236+
await persistTurn(agentDb, {
237+
chatId,
238+
messages: [textMessage("u1", "user"), toolMessage("a1", "input-available")],
239+
session: { publicAccessToken: "pat1", lastEventId: "1", runId: "run1" },
240+
});
241+
const positionBefore = await nextPosition(prisma, chatId);
242+
243+
// Tear the next turn at the INSERT itself, so the failure lands after `a1` is finalised
244+
// in place and after the slots are reserved no matter how the store orders its up-front
245+
// validation. A row planted directly at the position the allocator is about to hand out
246+
// makes that insert violate `chat_messages_chat_position_key`. Scaffolding, not part of
247+
// the transcript under test — removed once the tear has fired.
248+
await prisma.$executeRawUnsafe(
249+
`insert into trigger_dashboard_agent.chat_messages (chat_id, message_id, position, role, message)
250+
values ($1, 'planted_collision', $2, 'assistant', '{}'::jsonb)`,
251+
chatId,
252+
positionBefore
253+
);
254+
255+
// The driver names the failing statement, so the rejection itself pins where the tear fired.
256+
await expect(
257+
persistTurn(agentDb, {
258+
chatId,
259+
messages: [
260+
textMessage("u1", "user"),
261+
toolMessage("a1", "output-available"),
262+
textMessage("a2"),
263+
],
264+
finalizeMessageIds: ["a1"],
265+
session: { publicAccessToken: "pat_torn", lastEventId: "2", runId: "run_torn" },
266+
})
267+
).rejects.toThrow(/Failed query: insert into .*chat_messages/);
268+
269+
await prisma.$executeRawUnsafe(
270+
`delete from trigger_dashboard_agent.chat_messages where chat_id = $1 and message_id = 'planted_collision'`,
271+
chatId
272+
);
273+
274+
// The whole turn rolled back. The in-place rewrite the store had already applied is undone:
275+
// `a1` is the mid-flight call again, not the finalised body the torn turn wrote.
276+
expect((await transcript(chatId)).map((m) => m.id)).toEqual(["u1", "a1"]);
277+
const tornA1 = (await transcript(chatId))[1] as unknown as { parts: { state: string }[] };
278+
expect(tornA1.parts[0]!.state).toBe("input-available");
279+
expect(await rowCount(prisma, chatId)).toBe(2);
280+
// The slot it reserved for `a2` came back too, so the retry doesn't leave a gap.
281+
expect(await nextPosition(prisma, chatId)).toBe(positionBefore);
282+
// The cursor is still the first turn's: the failed turn never got as far as writing one.
283+
expect(
284+
await getSession(agentDb, { chatId, organizationId: ORG, userId: USER })
285+
).toMatchObject({ publicAccessToken: "pat1", lastEventId: "1" });
286+
287+
// The retry — a clean replay of the same turn — lands everything exactly once.
288+
await persistTurn(agentDb, {
289+
chatId,
290+
messages: [
291+
textMessage("u1", "user"),
292+
toolMessage("a1", "output-available"),
293+
textMessage("a2"),
294+
],
295+
finalizeMessageIds: ["a1"],
296+
session: { publicAccessToken: "pat2", lastEventId: "2", runId: "run2" },
297+
});
298+
expect((await transcript(chatId)).map((m) => m.id)).toEqual(["u1", "a1", "a2"]);
299+
const retriedA1 = (await transcript(chatId))[1] as unknown as { parts: { state: string }[] };
300+
expect(retriedA1.parts[0]!.state).toBe("output-available");
301+
// One new row, one new slot: the rolled-back reservation was not double-counted.
302+
expect(await nextPosition(prisma, chatId)).toBe(positionBefore + 1);
303+
expect(
304+
await getSession(agentDb, { chatId, organizationId: ORG, userId: USER })
305+
).toMatchObject({ publicAccessToken: "pat2", lastEventId: "2" });
306+
},
307+
30_000
308+
);
309+
});
310+
311+
describe("an OOM restart replays the turn cleanly", () => {
312+
postgresTest(
313+
"a restarted turn that re-sends its snapshot loses no data and doubles nothing",
314+
async ({ prisma, postgresContainer }) => {
315+
// The store seam an OOM restart lands on: the primitive restarts the run, replays `.in`,
316+
// and re-persists. `.out` trimming and the OOM restart itself are inside the primitive
317+
// (not reachable here) — this pins that a re-run's re-sent snapshot is idempotent.
318+
const chatId = "chat_oom_restart";
319+
await boot(prisma, postgresContainer.getConnectionUri(), chatId);
320+
321+
const firstAttempt = [textMessage("u1", "user"), toolMessage("a1", "input-available")];
322+
await persistMessages(agentDb, { chatId, messages: firstAttempt });
323+
const positionAfterFirst = await nextPosition(prisma, chatId);
324+
325+
// The run OOMs and restarts. It replays the same input, produces the same ids, and
326+
// finalises the turn it now completes.
327+
const restarted = {
328+
chatId,
329+
messages: [
330+
textMessage("u1", "user"),
331+
toolMessage("a1", "output-available"),
332+
textMessage("a2"),
333+
],
334+
finalizeMessageIds: ["a1", "a2"],
335+
session: { publicAccessToken: "pat", lastEventId: "5", runId: "run_restarted" },
336+
};
337+
await persistTurn(agentDb, restarted);
338+
// A second restart delivering the same turn again still converges.
339+
await persistTurn(agentDb, restarted);
340+
341+
expect((await transcript(chatId)).map((m) => m.id)).toEqual(["u1", "a1", "a2"]);
342+
// The replayed u1/a1 reserved no new slots; only a2 was genuinely new.
343+
expect(await nextPosition(prisma, chatId)).toBe(positionAfterFirst + 1);
344+
},
345+
30_000
346+
);
347+
});

0 commit comments

Comments
 (0)