Skip to content

Commit 33a92e4

Browse files
committed
Merge branch 'feat/dashboard-agent-flows' into feat/dashboard-agent-ui
2 parents a9f05ce + c025bbf commit 33a92e4

2 files changed

Lines changed: 65 additions & 8 deletions

File tree

apps/webapp/test/dashboardAgentTranscriptStore.test.ts

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,14 @@ function textMessage(id: string, text = id) {
7272
return { id, role: "assistant" as const, parts: [{ type: "text", text }] };
7373
}
7474

75+
function toolMessage(id: string, state: "input-available" | "output-available") {
76+
return {
77+
id,
78+
role: "assistant" as const,
79+
parts: [{ type: "tool-get_query_schema", state, toolCallId: `${id}_call`, input: {} }],
80+
};
81+
}
82+
7583
async function transcript(chatId: string): Promise<{ id: string }[]> {
7684
return (await getChatMessages(agentDb, {
7785
chatId,
@@ -289,21 +297,31 @@ describe("invariant 3: an ordinary transcript write can never change a stored me
289297
);
290298

291299
postgresTest(
292-
"persistTurn cannot rewrite a stored message either",
300+
"a completing turn finalises the body it stored mid-flight",
293301
async ({ prisma, postgresContainer }) => {
294-
const chatId = "chat_no_implicit_update_turn";
302+
// `onTurnStart` stores the turn's messages before the model has finished, so the
303+
// transcript first holds a tool call with no result. The completed turn arrives
304+
// under the same message id, and what the user was shown has to win.
305+
const chatId = "chat_turn_finalises_own_message";
295306
await boot(prisma, postgresContainer.getConnectionUri(), chatId);
296307

297-
await persistMessages(agentDb, { chatId, messages: [textMessage("a1", "the answer")] });
308+
await persistMessages(agentDb, { chatId, messages: [toolMessage("a1", "input-available")] });
298309
const before = await rows(prisma, chatId);
299310

300311
await persistTurn(agentDb, {
301312
chatId,
302-
messages: [textMessage("a1", "a different answer")],
313+
messages: [toolMessage("a1", "output-available")],
303314
session: { publicAccessToken: "pat_store" },
304315
});
305316

306-
expect(await rows(prisma, chatId)).toEqual(before);
317+
const after = await rows(prisma, chatId);
318+
expect(after).toHaveLength(1);
319+
expect(after[0]!.position).toBe(before[0]!.position);
320+
expect(after[0]!.message).toMatchObject({
321+
parts: [{ state: "output-available" }],
322+
});
323+
// A finalisation is not an append: no slot is consumed.
324+
expect(await nextPosition(prisma, chatId)).toBe(2);
307325
},
308326
30_000
309327
);
@@ -620,6 +638,19 @@ describe("a write can no longer lose a message another process appended", () =>
620638
// And the row it belongs to is still terminal, so nothing will re-open it.
621639
const row = await getInvestigation(agentDb, { id: created.id });
622640
expect(investigationStateSchema.parse(row?.state).outcome).toBe("inconclusive");
641+
642+
// A later turn carrying the card in its own snapshot still can't rewrite it:
643+
// finalisation is for the turn's messages, never for a durable event.
644+
const card = (await rows(prisma, chatId)).find((stored) => stored.message_id === cardId)!;
645+
await persistTurn(agentDb, {
646+
chatId,
647+
messages: [{ ...(card.message as Record<string, unknown>), tampered: true }],
648+
session: { publicAccessToken: "pat_store" },
649+
});
650+
const afterCard = (await rows(prisma, chatId)).find(
651+
(stored) => stored.message_id === cardId
652+
)!;
653+
expect(afterCard.message).toEqual(card.message);
623654
},
624655
30_000
625656
);

internal-packages/dashboard-agent-db/src/queries.ts

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,12 @@ async function reserveMessagePositions(
341341
* durable event, and nothing outside `messages` is touched either. Changing a message that
342342
* is already stored is a different operation: {@link finalizeChatMessage}.
343343
*
344+
* `finalizable` is the exception a completing turn needs: the ids it names are rewritten in
345+
* place through {@link finalizeChatMessage} instead of being skipped, so the transcript ends
346+
* up with the message the user was shown rather than the mid-flight version of it. Position
347+
* and id never move. Anything not named — a settlement card, another lane's append — keeps
348+
* the insert-only guarantee.
349+
*
344350
* The batch keeps its incoming order, and a message already stored keeps the position it
345351
* was first given, which is why a mid-turn append sits before the turn's later messages.
346352
*
@@ -349,7 +355,7 @@ async function reserveMessagePositions(
349355
*/
350356
async function storeChatMessages(
351357
tx: DashboardAgentDbOrTx,
352-
params: { chatId: string; messages: unknown[] }
358+
params: { chatId: string; messages: unknown[]; finalizable?: ReadonlySet<string> }
353359
): Promise<void> {
354360
const deduped = new Map<string, unknown>();
355361
for (const message of params.messages) {
@@ -380,7 +386,17 @@ async function storeChatMessages(
380386
inArray(chatMessages.messageId, [...deduped.keys()])
381387
)
382388
);
383-
for (const row of stored) deduped.delete(row.messageId);
389+
for (const row of stored) {
390+
const message = deduped.get(row.messageId);
391+
deduped.delete(row.messageId);
392+
if (message === undefined || !params.finalizable?.has(row.messageId)) continue;
393+
await finalizeChatMessage(tx, {
394+
chatId: params.chatId,
395+
messageId: row.messageId,
396+
expectedRole: messageRoleOf(params.chatId, message),
397+
message,
398+
});
399+
}
384400
if (deduped.size === 0) return;
385401

386402
const start = await reserveMessagePositions(tx, { chatId: params.chatId, count: deduped.size });
@@ -592,7 +608,17 @@ export async function persistTurn(
592608
);
593609
const messages = [...params.messages, ...cards.filter((card) => !existing.has(card.id))];
594610

595-
await storeChatMessages(tx, { chatId: params.chatId, messages });
611+
// `onTurnStart` stores the turn's messages mid-flight, so the completed bodies arrive
612+
// here against ids that already exist: without finalisation the transcript would keep
613+
// the half-finished tool call the user never saw the end of. Settlement cards are
614+
// durable events and stay insert-only.
615+
const finalizable = new Set(
616+
params.messages
617+
.map((message) => messageIdOf(params.chatId, message))
618+
.filter((id) => !id.startsWith(`${INVESTIGATION_SETTLEMENT_MESSAGE_ID_PREFIX}:`))
619+
);
620+
621+
await storeChatMessages(tx, { chatId: params.chatId, messages, finalizable });
596622

597623
await tx
598624
.insert(chatSessions)

0 commit comments

Comments
 (0)