|
3 | 3 | countUserMessages, |
4 | 4 | createChat, |
5 | 5 | createDashboardAgentDb, |
| 6 | + finalizeChatMessage, |
6 | 7 | getChatMessages, |
7 | 8 | getInvestigation, |
8 | 9 | investigationSettlementMessageId, |
@@ -92,6 +93,35 @@ async function rows(prisma: PrismaClient, chatId: string): Promise<StoredRow[]> |
92 | 93 | ); |
93 | 94 | } |
94 | 95 |
|
| 96 | +/** The position allocator itself: what a wasted reservation is visible in. */ |
| 97 | +async function nextPosition(prisma: PrismaClient, chatId: string): Promise<number> { |
| 98 | + const rows = await prisma.$queryRawUnsafe<{ next_message_position: number }[]>( |
| 99 | + `select next_message_position from trigger_dashboard_agent.chats where id = $1`, |
| 100 | + chatId |
| 101 | + ); |
| 102 | + return rows[0]!.next_message_position; |
| 103 | +} |
| 104 | + |
| 105 | +async function chatStamps( |
| 106 | + prisma: PrismaClient, |
| 107 | + chatId: string |
| 108 | +): Promise<{ last_message_at: Date | null; updated_at: Date }[]> { |
| 109 | + return prisma.$queryRawUnsafe( |
| 110 | + `select last_message_at, updated_at from trigger_dashboard_agent.chats where id = $1`, |
| 111 | + chatId |
| 112 | + ); |
| 113 | +} |
| 114 | + |
| 115 | +/** The structural column, which the JSONB payload must never be able to contradict. */ |
| 116 | +async function roleOf(prisma: PrismaClient, chatId: string, messageId: string): Promise<string> { |
| 117 | + const rows = await prisma.$queryRawUnsafe<{ role: string }[]>( |
| 118 | + `select role from trigger_dashboard_agent.chat_messages where chat_id = $1 and message_id = $2`, |
| 119 | + chatId, |
| 120 | + messageId |
| 121 | + ); |
| 122 | + return rows[0]!.role; |
| 123 | +} |
| 124 | + |
95 | 125 | function openState(): InvestigationState { |
96 | 126 | return investigationStateSchema.parse({ |
97 | 127 | outcome: "in_progress", |
@@ -231,32 +261,241 @@ describe("invariant 2: concurrent different messages get distinct positions", () |
231 | 261 | ); |
232 | 262 | }); |
233 | 263 |
|
234 | | -describe("invariant 3: a controlled update changes the body and nothing else", () => { |
| 264 | +describe("invariant 3: an ordinary transcript write can never change a stored message", () => { |
235 | 265 | postgresTest( |
236 | | - "finalising a message keeps its id, its position and its row", |
| 266 | + "a differing body under an existing id leaves the durable row exactly as it was", |
237 | 267 | async ({ prisma, postgresContainer }) => { |
238 | | - const chatId = "chat_finalise"; |
| 268 | + const chatId = "chat_no_implicit_update"; |
239 | 269 | await boot(prisma, postgresContainer.getConnectionUri(), chatId); |
240 | 270 |
|
| 271 | + await persistMessages(agentDb, { chatId, messages: [textMessage("u1")] }); |
| 272 | + // A durable event: the wake that actually fired. |
| 273 | + await appendChatMessageOnceByChatId(agentDb, { |
| 274 | + chatId, |
| 275 | + message: textMessage("wake:watch_1:fired", "The watch on send-order-receipt resolved."), |
| 276 | + }); |
| 277 | + const before = await rows(prisma, chatId); |
| 278 | + |
| 279 | + // A stale snapshot carrying the same id with a different body. `persistMessages` is |
| 280 | + // not a finalisation, so it must not be able to rewrite it. |
241 | 281 | await persistMessages(agentDb, { |
242 | 282 | chatId, |
243 | | - messages: [textMessage("u1"), textMessage("a1", "still working")], |
| 283 | + messages: [textMessage("u1"), textMessage("wake:watch_1:fired", "something else entirely")], |
244 | 284 | }); |
| 285 | + |
| 286 | + expect(await rows(prisma, chatId)).toEqual(before); |
| 287 | + }, |
| 288 | + 30_000 |
| 289 | + ); |
| 290 | + |
| 291 | + postgresTest( |
| 292 | + "persistTurn cannot rewrite a stored message either", |
| 293 | + async ({ prisma, postgresContainer }) => { |
| 294 | + const chatId = "chat_no_implicit_update_turn"; |
| 295 | + await boot(prisma, postgresContainer.getConnectionUri(), chatId); |
| 296 | + |
| 297 | + await persistMessages(agentDb, { chatId, messages: [textMessage("a1", "the answer")] }); |
245 | 298 | const before = await rows(prisma, chatId); |
246 | 299 |
|
| 300 | + await persistTurn(agentDb, { |
| 301 | + chatId, |
| 302 | + messages: [textMessage("a1", "a different answer")], |
| 303 | + session: { publicAccessToken: "pat_store" }, |
| 304 | + }); |
| 305 | + |
| 306 | + expect(await rows(prisma, chatId)).toEqual(before); |
| 307 | + }, |
| 308 | + 30_000 |
| 309 | + ); |
| 310 | + |
| 311 | + postgresTest( |
| 312 | + "a batch carrying the same id twice is refused rather than silently picking one", |
| 313 | + async ({ prisma, postgresContainer }) => { |
| 314 | + const chatId = "chat_dup_in_batch"; |
| 315 | + await boot(prisma, postgresContainer.getConnectionUri(), chatId); |
| 316 | + |
| 317 | + await expect( |
| 318 | + persistMessages(agentDb, { |
| 319 | + chatId, |
| 320 | + messages: [textMessage("a1", "first"), textMessage("a1", "second")], |
| 321 | + }) |
| 322 | + ).rejects.toThrow(/message id a1 twice in one batch/); |
| 323 | + |
| 324 | + // And nothing landed: the throw is before any reservation. |
| 325 | + expect(await rows(prisma, chatId)).toHaveLength(0); |
| 326 | + expect(await nextPosition(prisma, chatId)).toBe(1); |
| 327 | + }, |
| 328 | + 30_000 |
| 329 | + ); |
| 330 | + |
| 331 | + postgresTest( |
| 332 | + "a message with no id or no role is refused by name, not by a NOT NULL violation", |
| 333 | + async ({ prisma, postgresContainer }) => { |
| 334 | + const chatId = "chat_malformed"; |
| 335 | + await boot(prisma, postgresContainer.getConnectionUri(), chatId); |
| 336 | + |
| 337 | + await expect( |
| 338 | + persistMessages(agentDb, { chatId, messages: [{ role: "user", parts: [] }] }) |
| 339 | + ).rejects.toThrow(/Chat chat_malformed was handed a message with no id: .*"role":"user"/); |
| 340 | + |
| 341 | + await expect( |
| 342 | + persistMessages(agentDb, { chatId, messages: [{ id: "a1", parts: [] }] }) |
| 343 | + ).rejects.toThrow(/Chat chat_malformed was handed a message with no role: .*"id":"a1"/); |
| 344 | + }, |
| 345 | + 30_000 |
| 346 | + ); |
| 347 | +}); |
| 348 | + |
| 349 | +describe("invariant 4: a controlled finalisation changes the body and nothing else", () => { |
| 350 | + postgresTest( |
| 351 | + "finalising a message keeps its id, its position and its role", |
| 352 | + async ({ prisma, postgresContainer }) => { |
| 353 | + const chatId = "chat_finalise"; |
| 354 | + await boot(prisma, postgresContainer.getConnectionUri(), chatId); |
| 355 | + |
247 | 356 | await persistMessages(agentDb, { |
248 | 357 | chatId, |
249 | | - messages: [textMessage("u1"), textMessage("a1", "here is the answer")], |
| 358 | + messages: [textMessage("u1"), textMessage("a1", "still working")], |
250 | 359 | }); |
| 360 | + const before = await rows(prisma, chatId); |
| 361 | + |
| 362 | + expect( |
| 363 | + await finalizeChatMessage(agentDb, { |
| 364 | + chatId, |
| 365 | + messageId: "a1", |
| 366 | + expectedRole: "assistant", |
| 367 | + message: textMessage("a1", "here is the answer"), |
| 368 | + }) |
| 369 | + ).toBe(true); |
251 | 370 |
|
252 | 371 | const after = await rows(prisma, chatId); |
253 | 372 | expect(after).toHaveLength(2); |
254 | 373 | expect(after.map((row) => [row.message_id, row.position])).toEqual( |
255 | 374 | before.map((row) => [row.message_id, row.position]) |
256 | 375 | ); |
257 | | - // Only the one message that changed changed. |
| 376 | + // Only the one message named changed. |
258 | 377 | expect(after[0]!.message).toEqual(before[0]!.message); |
259 | 378 | expect(after[1]!.message).toMatchObject({ parts: [{ text: "here is the answer" }] }); |
| 379 | + expect(await roleOf(prisma, chatId, "a1")).toBe("assistant"); |
| 380 | + }, |
| 381 | + 30_000 |
| 382 | + ); |
| 383 | + |
| 384 | + postgresTest( |
| 385 | + "a finalisation aimed at the wrong role writes nothing", |
| 386 | + async ({ prisma, postgresContainer }) => { |
| 387 | + const chatId = "chat_finalise_role"; |
| 388 | + await boot(prisma, postgresContainer.getConnectionUri(), chatId); |
| 389 | + |
| 390 | + await persistMessages(agentDb, { chatId, messages: [textMessage("a1", "still working")] }); |
| 391 | + const before = await rows(prisma, chatId); |
| 392 | + |
| 393 | + // The stored row is an assistant message, so a user finalisation is not its own. |
| 394 | + expect( |
| 395 | + await finalizeChatMessage(agentDb, { |
| 396 | + chatId, |
| 397 | + messageId: "a1", |
| 398 | + expectedRole: "user", |
| 399 | + message: { id: "a1", role: "user", parts: [{ type: "text", text: "hijacked" }] }, |
| 400 | + }) |
| 401 | + ).toBe(false); |
| 402 | + |
| 403 | + expect(await rows(prisma, chatId)).toEqual(before); |
| 404 | + expect(await roleOf(prisma, chatId, "a1")).toBe("assistant"); |
| 405 | + }, |
| 406 | + 30_000 |
| 407 | + ); |
| 408 | + |
| 409 | + postgresTest( |
| 410 | + "the row's role and the body's role cannot be made to disagree", |
| 411 | + async ({ prisma, postgresContainer }) => { |
| 412 | + const chatId = "chat_finalise_drift"; |
| 413 | + await boot(prisma, postgresContainer.getConnectionUri(), chatId); |
| 414 | + |
| 415 | + await persistMessages(agentDb, { chatId, messages: [textMessage("a1")] }); |
| 416 | + |
| 417 | + // The column says assistant, the body would say user. Refused outright rather |
| 418 | + // than stored as a row whose column and payload disagree. |
| 419 | + await expect( |
| 420 | + finalizeChatMessage(agentDb, { |
| 421 | + chatId, |
| 422 | + messageId: "a1", |
| 423 | + expectedRole: "assistant", |
| 424 | + message: { id: "a1", role: "user", parts: [] }, |
| 425 | + }) |
| 426 | + ).rejects.toThrow(/expected role assistant but its body carries user/); |
| 427 | + |
| 428 | + expect(await roleOf(prisma, chatId, "a1")).toBe("assistant"); |
| 429 | + expect((await rows(prisma, chatId))[0]!.message).toMatchObject({ role: "assistant" }); |
| 430 | + }, |
| 431 | + 30_000 |
| 432 | + ); |
| 433 | + |
| 434 | + postgresTest( |
| 435 | + "a finalisation of a message that isn't there writes nothing", |
| 436 | + async ({ prisma, postgresContainer }) => { |
| 437 | + const chatId = "chat_finalise_missing"; |
| 438 | + await boot(prisma, postgresContainer.getConnectionUri(), chatId); |
| 439 | + |
| 440 | + expect( |
| 441 | + await finalizeChatMessage(agentDb, { |
| 442 | + chatId, |
| 443 | + messageId: "never-stored", |
| 444 | + expectedRole: "assistant", |
| 445 | + message: textMessage("never-stored"), |
| 446 | + }) |
| 447 | + ).toBe(false); |
| 448 | + expect(await rows(prisma, chatId)).toHaveLength(0); |
| 449 | + }, |
| 450 | + 30_000 |
| 451 | + ); |
| 452 | +}); |
| 453 | + |
| 454 | +describe("invariant 5: re-sending a snapshot is free", () => { |
| 455 | + postgresTest( |
| 456 | + "a re-sent snapshot reserves no position, touches no row and writes no timestamp", |
| 457 | + async ({ prisma, postgresContainer }) => { |
| 458 | + const chatId = "chat_snapshot_free"; |
| 459 | + await boot(prisma, postgresContainer.getConnectionUri(), chatId); |
| 460 | + |
| 461 | + const snapshot = Array.from({ length: 6 }, (_, i) => textMessage(`m${i}`)); |
| 462 | + await persistMessages(agentDb, { chatId, messages: snapshot }); |
| 463 | + |
| 464 | + const before = await rows(prisma, chatId); |
| 465 | + const positionBefore = await nextPosition(prisma, chatId); |
| 466 | + const chatBefore = await chatStamps(prisma, chatId); |
| 467 | + |
| 468 | + await persistMessages(agentDb, { chatId, messages: snapshot }); |
| 469 | + await persistTurn(agentDb, { |
| 470 | + chatId, |
| 471 | + messages: snapshot, |
| 472 | + session: { publicAccessToken: "pat_store" }, |
| 473 | + }); |
| 474 | + |
| 475 | + expect(await rows(prisma, chatId)).toEqual(before); |
| 476 | + // The allocator is the observable cost: a re-send that reserved slots would grow it. |
| 477 | + expect(await nextPosition(prisma, chatId)).toBe(positionBefore); |
| 478 | + expect(await chatStamps(prisma, chatId)).toEqual(chatBefore); |
| 479 | + }, |
| 480 | + 30_000 |
| 481 | + ); |
| 482 | + |
| 483 | + postgresTest( |
| 484 | + "a transcript grown by re-sent snapshots spends one position per message", |
| 485 | + async ({ prisma, postgresContainer }) => { |
| 486 | + const chatId = "chat_snapshot_slots"; |
| 487 | + await boot(prisma, postgresContainer.getConnectionUri(), chatId); |
| 488 | + |
| 489 | + // The real write pattern: every turn hands over the whole transcript again. With |
| 490 | + // the old insert-everything path this cost 1+2+…+40 = 820 slots for 40 rows. |
| 491 | + const snapshot: ReturnType<typeof textMessage>[] = []; |
| 492 | + for (let i = 0; i < 40; i++) { |
| 493 | + snapshot.push(textMessage(`m${i}`)); |
| 494 | + await persistMessages(agentDb, { chatId, messages: [...snapshot] }); |
| 495 | + } |
| 496 | + |
| 497 | + expect(await rows(prisma, chatId)).toHaveLength(40); |
| 498 | + expect(await nextPosition(prisma, chatId)).toBe(41); |
260 | 499 | }, |
261 | 500 | 30_000 |
262 | 501 | ); |
|
0 commit comments