Skip to content

Commit cf2e954

Browse files
committed
Port mail-message-id storage slice from PR #632 (CL-7450)
Hand-ported the storage-only pieces of RFC 5322 Message-ID threading from the entangled 3a60464 / 5bec1a7 commits on cl-7107-repin-and-re-vendor-audit-against-interchange-main-before-the: the workbench_messages.mail_message_id column and its migration (0029), stampMailMessageId/findByMailMessageId on both the drizzle and in-memory room-message stores, mail-headers.ts (mailMessageIdFor, rowIdFromMailMessageId, mailThreadHeaders, parentMailMessageId, parseReferences) with its unit tests, and mailAncestryOf in threads.ts. Left behind: the vendored-pin changes on that branch, and every dispatch-path change depending on the new pin (chat-orchestrator, turn-queue, folded-runs mail, interaction-tools) — those are out of scope here and land with their own ticket.
1 parent a1d62b4 commit cf2e954

6 files changed

Lines changed: 342 additions & 3 deletions

File tree

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
// Unit tests for the RFC 5322 threading headers a dispatched timeline row
2+
// carries (CL-7104): the derived `Message-ID`, the `References` chain the
3+
// thread parentage produces, and the parent an inbound reply names.
4+
import { describe, expect, test } from "bun:test";
5+
6+
import {
7+
mailMessageIdFor,
8+
mailThreadHeaders,
9+
parentMailMessageId,
10+
parseReferences,
11+
rowIdFromMailMessageId,
12+
} from "./mail-headers";
13+
14+
describe("mailMessageIdFor", () => {
15+
test("frames the row id against the mail domain", () => {
16+
expect(mailMessageIdFor("msg_1", "ten1.workbench.test")).toBe(
17+
"<msg_1@ten1.workbench.test>",
18+
);
19+
});
20+
21+
test("round-trips back to the row it names", () => {
22+
expect(
23+
rowIdFromMailMessageId(mailMessageIdFor("msg_1", "ten1.workbench.test")),
24+
).toBe("msg_1");
25+
});
26+
27+
test("an id from another transport misses the row instead of crashing", () => {
28+
expect(rowIdFromMailMessageId("deadbeef")).toBe("deadbeef");
29+
expect(rowIdFromMailMessageId("")).toBe("");
30+
expect(rowIdFromMailMessageId("<>")).toBe("<>");
31+
});
32+
});
33+
34+
describe("mailThreadHeaders", () => {
35+
test("a root-feed row threads under nothing", () => {
36+
expect(
37+
mailThreadHeaders({ rowId: "msg_1", domain: "d.test", ancestors: [] }),
38+
).toEqual({ messageId: "<msg_1@d.test>" });
39+
});
40+
41+
test("a depth-1 reply names its anchor in both headers", () => {
42+
expect(
43+
mailThreadHeaders({
44+
rowId: "msg_2",
45+
domain: "d.test",
46+
ancestors: ["msg_1"],
47+
}),
48+
).toEqual({
49+
messageId: "<msg_2@d.test>",
50+
inReplyTo: "<msg_1@d.test>",
51+
references: ["<msg_1@d.test>"],
52+
});
53+
});
54+
55+
test("a sub-thread row carries the full ancestry, root first", () => {
56+
expect(
57+
mailThreadHeaders({
58+
rowId: "msg_3",
59+
domain: "d.test",
60+
ancestors: ["msg_1", "msg_2"],
61+
}),
62+
).toEqual({
63+
messageId: "<msg_3@d.test>",
64+
inReplyTo: "<msg_2@d.test>",
65+
references: ["<msg_1@d.test>", "<msg_2@d.test>"],
66+
});
67+
});
68+
});
69+
70+
describe("parentMailMessageId", () => {
71+
test("In-Reply-To names the parent", () => {
72+
expect(parentMailMessageId({ inReplyTo: "<msg_1@d.test>" })).toBe(
73+
"<msg_1@d.test>",
74+
);
75+
});
76+
77+
test("References' tail names the parent when In-Reply-To is absent", () => {
78+
expect(
79+
parentMailMessageId({
80+
references: ["<msg_1@d.test>", "<msg_2@d.test>"],
81+
}),
82+
).toBe("<msg_2@d.test>");
83+
});
84+
85+
test("a reply threading under nothing resolves to no parent", () => {
86+
expect(parentMailMessageId({})).toBeUndefined();
87+
expect(
88+
parentMailMessageId({ inReplyTo: "", references: [] }),
89+
).toBeUndefined();
90+
});
91+
});
92+
93+
describe("parseReferences", () => {
94+
test("splits a folded header value into its Message-IDs", () => {
95+
expect(parseReferences("<a@d.test>\r\n <b@d.test> <c@d.test>")).toEqual([
96+
"<a@d.test>",
97+
"<b@d.test>",
98+
"<c@d.test>",
99+
]);
100+
});
101+
102+
test("an empty header value carries no references", () => {
103+
expect(parseReferences(" ")).toEqual([]);
104+
});
105+
});

‎packages/chat/src/mail-headers.ts‎

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// RFC 5322 threading headers for a workbench timeline row (CL-7104).
2+
//
3+
// Chat is a mail thread: a message row dispatched to an agent carries its
4+
// own `Message-ID`, and the reply that answers it names that id in
5+
// `In-Reply-To` / `References`. Correlation is those headers and nothing
6+
// else — no `Interchange-Correlation-ID`, no reply-to-address heuristic.
7+
//
8+
// A row's Message-ID is derived, never minted separately: `<rowId@domain>`
9+
// over the row's own primary key and the mail domain the workbench's
10+
// participants are addressed in. Deriving it means the same row always
11+
// produces the same header, so stamping it twice is a no-op and reading a
12+
// header back names exactly one row.
13+
import { localPartOf } from "./agent-address";
14+
15+
/** The RFC 5322 `Message-ID` for a timeline row: `<rowId@domain>`. */
16+
export function mailMessageIdFor(rowId: string, domain: string): string {
17+
return `<${rowId}@${domain}>`;
18+
}
19+
20+
/**
21+
* The timeline row a `Message-ID` names — the inverse of
22+
* `mailMessageIdFor`. A value with no `<...>` framing, or no `@`, is
23+
* returned unchanged, so an id minted by any other transport simply
24+
* misses the row lookup rather than crashing it.
25+
*/
26+
export function rowIdFromMailMessageId(header: string): string {
27+
const framed =
28+
header.startsWith("<") && header.endsWith(">") && header.length > 2
29+
? header.slice(1, -1)
30+
: header;
31+
return localPartOf(framed);
32+
}
33+
34+
/**
35+
* The threading headers for a row being dispatched as mail. `ancestors`
36+
* is the row's parent chain, root first (see `mailAncestryOf` in
37+
* `./threads.ts`); `References` is exactly that chain and `In-Reply-To`
38+
* is its tail, per RFC 5322. A root-feed row has neither.
39+
*/
40+
export function mailThreadHeaders(input: {
41+
readonly rowId: string;
42+
readonly domain: string;
43+
readonly ancestors: readonly string[];
44+
}): {
45+
readonly messageId: string;
46+
readonly inReplyTo?: string;
47+
readonly references?: readonly string[];
48+
} {
49+
const messageId = mailMessageIdFor(input.rowId, input.domain);
50+
const references = input.ancestors.map((ancestor) =>
51+
mailMessageIdFor(ancestor, input.domain),
52+
);
53+
const inReplyTo = references[references.length - 1];
54+
return {
55+
messageId,
56+
...(inReplyTo !== undefined ? { inReplyTo } : {}),
57+
...(references.length > 0 ? { references } : {}),
58+
};
59+
}
60+
61+
/**
62+
* The `Message-ID` an inbound reply answers: `In-Reply-To` when it has
63+
* one, otherwise the tail of `References` — the nearest ancestor either
64+
* header names. Undefined when the reply threads under nothing at all,
65+
* which the caller must report rather than guess a parent for.
66+
*/
67+
export function parentMailMessageId(headers: {
68+
readonly inReplyTo?: string;
69+
readonly references?: readonly string[];
70+
}): string | undefined {
71+
if (headers.inReplyTo !== undefined && headers.inReplyTo !== "") {
72+
return headers.inReplyTo;
73+
}
74+
const references = headers.references ?? [];
75+
const tail = references[references.length - 1];
76+
return tail !== undefined && tail !== "" ? tail : undefined;
77+
}
78+
79+
/**
80+
* Splits a raw `References` header value into its Message-IDs.
81+
* RFC 5322 separates them by whitespace (folding included), so any run
82+
* of whitespace is the separator and empty segments are dropped.
83+
*/
84+
export function parseReferences(value: string): readonly string[] {
85+
return value.split(/\s+/).filter((entry) => entry.length > 0);
86+
}

‎packages/chat/src/migrations.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -545,6 +545,23 @@ export const chatMigrations: readonly ChatMigration[] = [
545545
);
546546
`,
547547
},
548+
{
549+
// CL-7104: the RFC 5322 `Message-ID` a timeline row was dispatched
550+
// as. Derived from the row's own id, so there is nothing to
551+
// backfill: a row dispatched before this landed answers to the same
552+
// `<id@domain>` it always did, and a row that was never dispatched
553+
// stays null. Indexed for the inbound direction — a reply's
554+
// `In-Reply-To` names a Message-ID, and the row it belongs to is
555+
// found by that header, never by the sender's address.
556+
name: "0029_workbench_messages_mail_message_id",
557+
sql: `
558+
ALTER TABLE "chat"."workbench_messages"
559+
ADD COLUMN IF NOT EXISTS "mail_message_id" text;
560+
561+
CREATE INDEX IF NOT EXISTS "workbench_messages_mail_message_id_idx"
562+
ON "chat"."workbench_messages" ("tenant_id", "mail_message_id");
563+
`,
564+
},
548565
];
549566

550567
/**

‎packages/chat/src/room-messages.ts‎

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,12 @@ export interface RoomMessage {
3939
/** The agent run this message came out of; null for a human's. */
4040
readonly runId: string | null;
4141
readonly threadId: string | null;
42+
/**
43+
* The RFC 5322 `Message-ID` this row was dispatched to an agent as
44+
* (CL-7104); null for a row nobody was ever asked to answer. See
45+
* `./mail-headers.ts` for how it is derived.
46+
*/
47+
readonly mailMessageId: string | null;
4248
readonly parts: readonly Part[];
4349
}
4450

@@ -95,6 +101,26 @@ export interface RoomMessageStore {
95101
readonly workbenchId: string;
96102
readonly messageId: string;
97103
}): Promise<RoomMessage | undefined>;
104+
/**
105+
* Records the `Message-ID` a row went out as (CL-7104). The header is
106+
* derived from the row's own id, so writing it twice writes the same
107+
* value — this is a stamp, not a mint, and needs no claim.
108+
*/
109+
stampMailMessageId(input: {
110+
readonly tenantId: string;
111+
readonly workbenchId: string;
112+
readonly messageId: string;
113+
readonly mailMessageId: string;
114+
}): Promise<void>;
115+
/**
116+
* The row a `Message-ID` names — the inbound half of correlation. An
117+
* `In-Reply-To` no row answers to resolves to undefined, which the
118+
* caller reports rather than attributing to a guess.
119+
*/
120+
findByMailMessageId(input: {
121+
readonly tenantId: string;
122+
readonly mailMessageId: string;
123+
}): Promise<RoomMessage | undefined>;
98124
listActivity(
99125
input: ListRoomActivityInput,
100126
): Promise<Record<string, RoomActivitySummary>>;
@@ -292,6 +318,7 @@ interface MessageRow {
292318
senderPrincipalId: string | null;
293319
runId: string | null;
294320
threadId: string | null;
321+
mailMessageId: string | null;
295322
parts: unknown;
296323
createdAt: Date;
297324
}
@@ -305,6 +332,7 @@ function toRoomMessage(row: MessageRow): RoomMessage {
305332
senderPrincipalId: row.senderPrincipalId,
306333
runId: row.runId,
307334
threadId: row.threadId,
335+
mailMessageId: row.mailMessageId,
308336
parts: row.parts as Part[],
309337
};
310338
}
@@ -382,6 +410,33 @@ export function createDrizzleRoomMessageStore(
382410
return row === undefined ? undefined : toRoomMessage(row as MessageRow);
383411
},
384412

413+
async stampMailMessageId(input) {
414+
await db
415+
.update(workbenchMessages)
416+
.set({ mailMessageId: input.mailMessageId })
417+
.where(
418+
and(
419+
eq(workbenchMessages.id, input.messageId),
420+
eq(workbenchMessages.tenantId, input.tenantId),
421+
eq(workbenchMessages.workbenchId, input.workbenchId),
422+
),
423+
);
424+
},
425+
426+
async findByMailMessageId(input) {
427+
const [row] = await db
428+
.select()
429+
.from(workbenchMessages)
430+
.where(
431+
and(
432+
eq(workbenchMessages.tenantId, input.tenantId),
433+
eq(workbenchMessages.mailMessageId, input.mailMessageId),
434+
),
435+
)
436+
.limit(1);
437+
return row === undefined ? undefined : toRoomMessage(row as MessageRow);
438+
},
439+
385440
async listActivity(input) {
386441
if (input.workbenches.length === 0) return {};
387442
const workbenchIds = input.workbenches.map(
@@ -485,13 +540,39 @@ export function createInMemoryRoomMessageStore(): RoomMessageStore {
485540
senderPrincipalId: input.senderPrincipalId ?? null,
486541
runId: input.runId ?? null,
487542
threadId: input.threadId ?? null,
543+
mailMessageId: null,
488544
parts: input.parts,
489545
};
490546
const key = keyOf(input.tenantId, input.workbenchId);
491547
byWorkbench.set(key, [...(byWorkbench.get(key) ?? []), message]);
492548
return message;
493549
},
494550

551+
async stampMailMessageId(input) {
552+
const key = keyOf(input.tenantId, input.workbenchId);
553+
const messages = byWorkbench.get(key);
554+
if (messages === undefined) return;
555+
byWorkbench.set(
556+
key,
557+
messages.map((message) =>
558+
message.id === input.messageId
559+
? { ...message, mailMessageId: input.mailMessageId }
560+
: message,
561+
),
562+
);
563+
},
564+
565+
async findByMailMessageId(input) {
566+
for (const [key, messages] of byWorkbench) {
567+
if (!key.startsWith(`${input.tenantId}:`)) continue;
568+
const match = messages.find(
569+
(message) => message.mailMessageId === input.mailMessageId,
570+
);
571+
if (match !== undefined) return match;
572+
}
573+
return undefined;
574+
},
575+
495576
async listMessages(input) {
496577
const messages =
497578
byWorkbench.get(keyOf(input.tenantId, input.workbenchId)) ?? [];

‎packages/chat/src/schema.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,14 @@ export const workbenchMessages = chatSchema.table(
175175
senderPrincipalId: text("sender_principal_id"),
176176
runId: text("run_id"),
177177
threadId: text("thread_id"),
178+
/**
179+
* The RFC 5322 `Message-ID` this row went out as when it was
180+
* dispatched to an agent (CL-7104) — `<id@domain>`, derived from the
181+
* row's own primary key and stamped once at dispatch. Null for a row
182+
* that was never dispatched as mail (a join notice, an event, a
183+
* message nobody was asked to answer).
184+
*/
185+
mailMessageId: text("mail_message_id"),
178186
parts: jsonb("parts").notNull(),
179187
// Millisecond precision, not the default microsecond: a cursor is a
180188
// JS `Date` rendered to an ISO string, which carries milliseconds

0 commit comments

Comments
 (0)