Skip to content

Commit 06ca8d1

Browse files
committed
test(webapp): exercise a mid-write turn failure in the durable-resume suite
1 parent bc3e5d0 commit 06ca8d1

1 file changed

Lines changed: 28 additions & 11 deletions

File tree

apps/webapp/test/dashboardAgentDurableResume.test.ts

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -227,49 +227,66 @@ describe("the session cursor a refreshed client resumes from", () => {
227227

228228
describe("a failed snapshot write leaves the next boot a clean replay", () => {
229229
postgresTest(
230-
"a persistTurn that throws commits nothing, and the retry replays with no loss",
230+
"a persistTurn that throws mid-write rolls back what it already wrote, and the retry replays with no loss",
231231
async ({ prisma, postgresContainer }) => {
232232
const chatId = "chat_write_fail";
233233
await boot(prisma, postgresContainer.getConnectionUri(), chatId);
234234

235-
// A durable first turn, and the session cursor it left.
235+
// A durable first turn, its tool call still mid-flight, and the session cursor it left.
236236
await persistTurn(agentDb, {
237237
chatId,
238-
messages: [textMessage("u1", "user"), textMessage("a1")],
238+
messages: [textMessage("u1", "user"), toolMessage("a1", "input-available")],
239239
session: { publicAccessToken: "pat1", lastEventId: "1", runId: "run1" },
240240
});
241241
const positionBefore = await nextPosition(prisma, chatId);
242242

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.
243+
// The next turn's write fails *after* it has written: a message that carries an id but no
244+
// role clears the up-front id check, so the store finalises `a1` in place and reserves the
245+
// slots for the new messages before the missing role throws. Everything already written
246+
// has to come back out.
245247
await expect(
246248
persistTurn(agentDb, {
247249
chatId,
248250
messages: [
249251
textMessage("u1", "user"),
250-
textMessage("a1"),
252+
toolMessage("a1", "output-available"),
251253
textMessage("a2"),
252-
{ role: "assistant", parts: [] } as unknown as { id: string; role: string },
254+
{ id: "a3", parts: [] } as unknown as { id: string; role: string },
253255
],
256+
finalizeMessageIds: ["a1"],
254257
session: { publicAccessToken: "pat_torn", lastEventId: "2", runId: "run_torn" },
255258
})
256-
).rejects.toThrow(/handed a message with no id/);
259+
).rejects.toThrow(/handed a message with no role/);
257260

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.
261+
// The whole turn rolled back. The in-place rewrite the store had already applied is undone:
262+
// `a1` is the mid-flight call again, not the finalised body the torn turn wrote.
260263
expect((await transcript(chatId)).map((m) => m.id)).toEqual(["u1", "a1"]);
264+
const tornA1 = (await transcript(chatId))[1] as unknown as { parts: { state: string }[] };
265+
expect(tornA1.parts[0]!.state).toBe("input-available");
266+
expect(await rowCount(prisma, chatId)).toBe(2);
267+
// The slots it reserved for `a2`/`a3` came back too, so the retry doesn't leave a gap.
261268
expect(await nextPosition(prisma, chatId)).toBe(positionBefore);
269+
// The cursor is still the first turn's: the failed turn never got as far as writing one.
262270
expect(
263271
await getSession(agentDb, { chatId, organizationId: ORG, userId: USER })
264272
).toMatchObject({ publicAccessToken: "pat1", lastEventId: "1" });
265273

266274
// The retry — a clean replay of the same turn — lands everything exactly once.
267275
await persistTurn(agentDb, {
268276
chatId,
269-
messages: [textMessage("u1", "user"), textMessage("a1"), textMessage("a2")],
277+
messages: [
278+
textMessage("u1", "user"),
279+
toolMessage("a1", "output-available"),
280+
textMessage("a2"),
281+
],
282+
finalizeMessageIds: ["a1"],
270283
session: { publicAccessToken: "pat2", lastEventId: "2", runId: "run2" },
271284
});
272285
expect((await transcript(chatId)).map((m) => m.id)).toEqual(["u1", "a1", "a2"]);
286+
const retriedA1 = (await transcript(chatId))[1] as unknown as { parts: { state: string }[] };
287+
expect(retriedA1.parts[0]!.state).toBe("output-available");
288+
// One new row, one new slot: the rolled-back reservation was not double-counted.
289+
expect(await nextPosition(prisma, chatId)).toBe(positionBefore + 1);
273290
expect(
274291
await getSession(agentDb, { chatId, organizationId: ORG, userId: USER })
275292
).toMatchObject({ publicAccessToken: "pat2", lastEventId: "2" });

0 commit comments

Comments
 (0)