Skip to content

Commit 3d3c962

Browse files
committed
fix(webapp): keep a malformed message's own text out of the error, and pin a finalisation to its body id
The malformed-message error carried 200 characters of the payload, which can be user text or tool output. It now names the shape only. A finalisation also verifies that the body's id is the row it targets, so the stored key and the payload cannot name different messages. The legacy-column guard missed a schema-qualified update, and now self-tests both spellings.
1 parent ceaa38d commit 3d3c962

3 files changed

Lines changed: 96 additions & 19 deletions

File tree

apps/webapp/test/dashboardAgentLegacyMessagesColumn.test.ts

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,23 @@ const SCANNED = [
1919
"internal-packages/dashboard-agent-db/src",
2020
];
2121

22+
/**
23+
* A tripwire, not a proof. It does not see SQL assembled from separate fragments, queries
24+
* outside the scanned directories, anything run by hand or by an external tool, or an
25+
* aliased table (`from chats c … c.messages`). Migrations are skipped on purpose.
26+
*/
27+
2228
/** A qualified reference to the dropped column, in any of the spellings Postgres accepts. */
2329
const QUALIFIED = /"?\bchats"?\s*\.\s*"?messages"?/i;
2430

2531
/** An unqualified one, inside a literal that is plainly SQL against `chats`. */
2632
const SQL_LITERAL = /`[^`]*`|"(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'/g;
2733
const SQL_VERB = /\b(select|insert\s+into|update|delete\s+from)\b/i;
28-
const NAMES_CHATS = /(?<![\w."])chats\b/i;
29-
// Word-boundary on both sides and no leading `_`, so `chat_messages` is not a hit.
30-
const BARE_MESSAGES = /(?<![\w."])messages\b/i;
34+
// The quote is part of the match, not excluded before it: a schema-qualified
35+
// `"trigger_dashboard_agent"."chats"` has a `"` immediately before the name.
36+
const NAMES_CHATS = /(?<!\w)"?chats\b"?/i;
37+
// No leading `\w`, so `chat_messages` is not a hit.
38+
const BARE_MESSAGES = /(?<!\w)"?messages\b"?/i;
3139

3240
function sourceFiles(dir: string): string[] {
3341
const found: string[] = [];
@@ -46,24 +54,26 @@ function sourceFiles(dir: string): string[] {
4654
return found;
4755
}
4856

49-
function offences(file: string): string[] {
50-
const text = readFileSync(file, "utf8");
57+
function offencesForText(text: string, label: string): string[] {
5158
const found: string[] = [];
5259

5360
for (const [index, line] of text.split("\n").entries()) {
54-
if (QUALIFIED.test(line))
55-
found.push(`${path.relative(ROOT, file)}:${index + 1} ${line.trim()}`);
61+
if (QUALIFIED.test(line)) found.push(`${label}:${index + 1} ${line.trim()}`);
5662
}
5763

5864
for (const literal of text.match(SQL_LITERAL) ?? []) {
5965
if (!SQL_VERB.test(literal) || !NAMES_CHATS.test(literal)) continue;
6066
if (!BARE_MESSAGES.test(literal)) continue;
61-
found.push(`${path.relative(ROOT, file)} (sql literal) ${literal.slice(0, 120)}`);
67+
found.push(`${label} (sql literal) ${literal.slice(0, 120)}`);
6268
}
6369

6470
return found;
6571
}
6672

73+
function offences(file: string): string[] {
74+
return offencesForText(readFileSync(file, "utf8"), path.relative(ROOT, file));
75+
}
76+
6777
describe("the dropped chats.messages column", () => {
6878
it("is not referenced by any production source, including in raw SQL", () => {
6979
const files = SCANNED.flatMap((dir) => sourceFiles(path.join(ROOT, dir)));
@@ -72,4 +82,21 @@ describe("the dropped chats.messages column", () => {
7282

7383
expect(files.flatMap(offences)).toEqual([]);
7484
});
85+
86+
// Without these the scan could rot into a pass-everything no-op.
87+
it("catches a schema-qualified update of the column", () => {
88+
expect(
89+
offencesForText(
90+
'sql`UPDATE "trigger_dashboard_agent"."chats" SET "messages" = ${value} WHERE "id" = ${chatId}`',
91+
"fixture"
92+
)
93+
).not.toEqual([]);
94+
});
95+
96+
it("catches an unqualified update, and leaves chat_messages alone", () => {
97+
expect(offencesForText("sql`update chats set messages = ${next}`", "fixture")).not.toEqual([]);
98+
expect(
99+
offencesForText("sql`insert into chat_messages (message) values (${row})`", "fixture")
100+
).toEqual([]);
101+
});
75102
});

apps/webapp/test/dashboardAgentTranscriptStore.test.ts

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -334,13 +334,30 @@ describe("invariant 3: an ordinary transcript write can never change a stored me
334334
const chatId = "chat_malformed";
335335
await boot(prisma, postgresContainer.getConnectionUri(), chatId);
336336

337+
// The shape, never the values: a malformed message can carry user text.
337338
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"/);
339+
persistMessages(agentDb, {
340+
chatId,
341+
messages: [
342+
{ role: "user", parts: [{ type: "text", text: "card 4242 for alice@x.test" }] },
343+
],
344+
})
345+
).rejects.toThrow(
346+
/Chat chat_malformed was handed a message with no id: object with keys: role, parts$/
347+
);
340348

341349
await expect(
342350
persistMessages(agentDb, { chatId, messages: [{ id: "a1", parts: [] }] })
343-
).rejects.toThrow(/Chat chat_malformed was handed a message with no role: .*"id":"a1"/);
351+
).rejects.toThrow(
352+
/Chat chat_malformed was handed a message with no role: object with keys: id, parts$/
353+
);
354+
355+
const leaked = await persistMessages(agentDb, {
356+
chatId,
357+
messages: [{ role: "user", parts: [{ type: "text", text: "alice@x.test" }] }],
358+
}).catch((error: Error) => error.message);
359+
expect(leaked).not.toContain("alice@x.test");
360+
expect(leaked).not.toContain("4242");
344361
},
345362
30_000
346363
);
@@ -406,6 +423,31 @@ describe("invariant 4: a controlled finalisation changes the body and nothing el
406423
30_000
407424
);
408425

426+
postgresTest(
427+
"a finalisation whose body names another message is refused",
428+
async ({ prisma, postgresContainer }) => {
429+
const chatId = "chat_finalise_id";
430+
await boot(prisma, postgresContainer.getConnectionUri(), chatId);
431+
432+
await persistMessages(agentDb, { chatId, messages: [textMessage("a1", "still working")] });
433+
const before = await rows(prisma, chatId);
434+
435+
// The row key would stay `a1` while the body claims `a2`, so a later read
436+
// would hand the UI a message under the wrong identity.
437+
await expect(
438+
finalizeChatMessage(agentDb, {
439+
chatId,
440+
messageId: "a1",
441+
expectedRole: "assistant",
442+
message: { id: "a2", role: "assistant", parts: [{ type: "text", text: "done" }] },
443+
})
444+
).rejects.toThrow(/finalisation target a1 carries body id a2/);
445+
446+
expect(await rows(prisma, chatId)).toEqual(before);
447+
},
448+
30_000
449+
);
450+
409451
postgresTest(
410452
"the row's role and the body's role cannot be made to disagree",
411453
async ({ prisma, postgresContainer }) => {

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

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -281,19 +281,20 @@ export async function softDeleteChat(
281281
}
282282

283283
/** Enough of the payload to recognise it in an error, without logging a whole transcript. */
284-
function describeMessage(message: unknown): string {
285-
try {
286-
return JSON.stringify(message)?.slice(0, 200) ?? String(message);
287-
} catch {
288-
return String(message);
289-
}
284+
// Shape only. A malformed message can carry user text, tool output or source.
285+
function describeMessageShape(message: unknown): string {
286+
if (!message || typeof message !== "object") return typeof message;
287+
const keys = Object.keys(message);
288+
return `object with keys: ${keys.slice(0, 10).join(", ")}${keys.length > 10 ? ", …" : ""}`;
290289
}
291290

292291
/** Row identity. Checked here so a malformed message names itself, not a `NOT NULL` violation. */
293292
function messageIdOf(chatId: string, message: unknown): string {
294293
const id = (message as { id?: unknown } | null | undefined)?.id;
295294
if (typeof id !== "string" || id.length === 0) {
296-
throw new Error(`Chat ${chatId} was handed a message with no id: ${describeMessage(message)}`);
295+
throw new Error(
296+
`Chat ${chatId} was handed a message with no id: ${describeMessageShape(message)}`
297+
);
297298
}
298299
return id;
299300
}
@@ -303,7 +304,7 @@ function messageRoleOf(chatId: string, message: unknown): string {
303304
const role = (message as { role?: unknown } | null | undefined)?.role;
304305
if (typeof role !== "string" || role.length === 0) {
305306
throw new Error(
306-
`Chat ${chatId} was handed a message with no role: ${describeMessage(message)}`
307+
`Chat ${chatId} was handed a message with no role: ${describeMessageShape(message)}`
307308
);
308309
}
309310
return role;
@@ -420,6 +421,13 @@ export async function finalizeChatMessage(
420421
);
421422
}
422423

424+
const bodyMessageId = messageIdOf(params.chatId, params.message);
425+
if (bodyMessageId !== params.messageId) {
426+
throw new Error(
427+
`Chat ${params.chatId} finalisation target ${params.messageId} carries body id ${bodyMessageId}`
428+
);
429+
}
430+
423431
const rows = await db
424432
.update(chatMessages)
425433
.set({ message: params.message })

0 commit comments

Comments
 (0)