From f6d4764703d4267a0dccddd6a9cadf1a0cc45b7c Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 15 Sep 2026 15:32:03 -0700 Subject: [PATCH 01/12] feat(chat): add message_parts sidecar for native-mail timeline (CL-7594) --- packages/chat/src/index.ts | 10 + packages/chat/src/message-parts.ts | 152 +++++++++++++ packages/chat/src/migrations.ts | 24 +++ packages/chat/src/schema.ts | 31 +++ .../chat/test/message-parts.drizzle.test.ts | 200 ++++++++++++++++++ 5 files changed, 417 insertions(+) create mode 100644 packages/chat/src/message-parts.ts create mode 100644 packages/chat/test/message-parts.drizzle.test.ts diff --git a/packages/chat/src/index.ts b/packages/chat/src/index.ts index bd000ce4ee..187576fcdb 100644 --- a/packages/chat/src/index.ts +++ b/packages/chat/src/index.ts @@ -371,6 +371,16 @@ export type { PostRoomMessageInput, } from "./room-messages"; +export { + createDrizzleMessagePartsStore, + createInMemoryMessagePartsStore, +} from "./message-parts"; +export type { + MessagePartsRow, + MessagePartsStore, + RecordMessagePartsInput, +} from "./message-parts"; + export { resolveWorkbenchIdForAgentFrame } from "./mail-headers"; export { diff --git a/packages/chat/src/message-parts.ts b/packages/chat/src/message-parts.ts new file mode 100644 index 0000000000..f30a99cf17 --- /dev/null +++ b/packages/chat/src/message-parts.ts @@ -0,0 +1,152 @@ +// Chat-owned rich-part sidecar for timeline rows that live as native +// @corbits/mailbox frames (CL-7594). One row per frame, keyed by the +// frame's own RFC 5322 Message-ID, carrying the `Part[]` payload the +// room UI's blocks render from — the same "row's existence is the fact" +// presence-as-truth shape `./client-ids.ts`, `./pins.ts`, and +// `./reactions.ts` follow. The mailbox owns the frame and knows nothing +// of this table; a frame with no sidecar row renders its text-only +// fallback, never an error. + +import { and, eq, inArray } from "drizzle-orm"; +import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; + +import { type Part, parsePart } from "./parts"; +import { messageParts } from "./schema"; + +export interface MessagePartsRow { + readonly tenantId: string; + readonly mailMessageId: string; + readonly workbenchId: string; + readonly parts: readonly Part[]; +} + +export interface RecordMessagePartsInput { + readonly tenantId: string; + readonly mailMessageId: string; + readonly workbenchId: string; + readonly parts: readonly Part[]; +} + +export interface MessagePartsStore { + recordMessageParts(input: RecordMessagePartsInput): Promise; + /** + * The confirmed `Part[]` for one frame, or null when no sidecar row + * exists (or its payload no longer parses — a corrupt row renders the + * fallback, it never breaks the timeline). + */ + readMessageParts( + tenantId: string, + mailMessageId: string, + ): Promise; + /** + * Every sidecar row across the given Message-IDs, in one query — the + * batched read the room timeline calls once per page rather than once + * per frame. An empty `mailMessageIds` short-circuits to `[]` without + * touching the store at all. + */ + listMessagePartsForFrames( + tenantId: string, + mailMessageIds: readonly string[], + ): Promise; +} + +function parseStoredParts(data: unknown): readonly Part[] | null { + if (!Array.isArray(data)) return null; + try { + return data.map((entry) => parsePart(entry)); + } catch { + return null; + } +} + +export function createInMemoryMessagePartsStore(): MessagePartsStore { + const rows = new Map(); + + return { + async recordMessageParts(input) { + rows.set(`${input.tenantId}::${input.mailMessageId}`, { + tenantId: input.tenantId, + mailMessageId: input.mailMessageId, + workbenchId: input.workbenchId, + parts: [...input.parts], + }); + }, + + async readMessageParts(tenantId, mailMessageId) { + const row = rows.get(`${tenantId}::${mailMessageId}`); + return row ? row.parts : null; + }, + + async listMessagePartsForFrames(tenantId, mailMessageIds) { + if (mailMessageIds.length === 0) return []; + const wanted = new Set(mailMessageIds); + return [...rows.values()].filter( + (row) => row.tenantId === tenantId && wanted.has(row.mailMessageId), + ); + }, + }; +} + +export type MessagePartsDb< + TSchema extends Record = Record, +> = PostgresJsDatabase; + +export function createDrizzleMessagePartsStore< + TSchema extends Record, +>(db: MessagePartsDb): MessagePartsStore { + return { + async recordMessageParts(input) { + await db + .insert(messageParts) + .values({ + tenantId: input.tenantId, + mailMessageId: input.mailMessageId, + workbenchId: input.workbenchId, + parts: [...input.parts], + }) + .onConflictDoNothing({ + target: [messageParts.tenantId, messageParts.mailMessageId], + }); + }, + + async readMessageParts(tenantId, mailMessageId) { + const rows = await db + .select({ parts: messageParts.parts }) + .from(messageParts) + .where( + and( + eq(messageParts.tenantId, tenantId), + eq(messageParts.mailMessageId, mailMessageId), + ), + ) + .limit(1); + if (rows.length === 0) return null; + return parseStoredParts(rows[0].parts); + }, + + async listMessagePartsForFrames(tenantId, mailMessageIds) { + if (mailMessageIds.length === 0) return []; + const rows = await db + .select({ + tenantId: messageParts.tenantId, + mailMessageId: messageParts.mailMessageId, + workbenchId: messageParts.workbenchId, + parts: messageParts.parts, + }) + .from(messageParts) + .where( + and( + eq(messageParts.tenantId, tenantId), + inArray(messageParts.mailMessageId, mailMessageIds), + ), + ); + const parsed: MessagePartsRow[] = []; + for (const row of rows) { + const parts = parseStoredParts(row.parts); + if (parts === null) continue; + parsed.push({ ...row, parts }); + } + return parsed; + }, + }; +} diff --git a/packages/chat/src/migrations.ts b/packages/chat/src/migrations.ts index d8d02da322..2440fd0bab 100644 --- a/packages/chat/src/migrations.ts +++ b/packages/chat/src/migrations.ts @@ -588,6 +588,30 @@ export const chatMigrations: readonly ChatMigration[] = [ ("tenant_id", "agent_address", "occurrence"); `, }, + { + // CL-7594: chat-owned rich-part sidecar for timeline rows that now + // live as native @corbits/mailbox frames. The mailbox owns a frame's + // identity, threading, and read state; this table owns only the chat + // rendering — the `Part[]` payload the room UI's blocks were built + // from — keyed by the frame's own RFC 5322 Message-ID. No backfill: + // pre-cutover rows keep their parts inline in `workbench_messages` + // until migration 0032 drops that table, and a frame with no sidecar + // row renders its text-only fallback, never an error. + name: "0031_message_parts_sidecar", + sql: ` + CREATE TABLE IF NOT EXISTS "chat"."message_parts" ( + "tenant_id" text NOT NULL, + "mail_message_id" text NOT NULL, + "workbench_id" text NOT NULL, + "parts" jsonb NOT NULL, + "created_at" timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY ("tenant_id", "mail_message_id") + ); + + CREATE INDEX IF NOT EXISTS "message_parts_workbench_idx" + ON "chat"."message_parts" ("tenant_id", "workbench_id"); + `, + }, ]; // Bookkeeping table for this package's own migrations. Named diff --git a/packages/chat/src/schema.ts b/packages/chat/src/schema.ts index dc241070ea..304e2a5189 100644 --- a/packages/chat/src/schema.ts +++ b/packages/chat/src/schema.ts @@ -542,6 +542,37 @@ export const turnMailCorrelation = chatSchema.table( ], ); +/** + * Chat-owned rich-part sidecar for timeline rows that live as native + * @corbits/mailbox frames (CL-7594): one row per frame, keyed by the + * frame's own RFC 5322 Message-ID, carrying the `Part[]` payload the + * room UI's blocks render from. The mailbox owns the frame — identity, + * threading, read state — and knows nothing of this table; a frame with + * no row here renders its text-only fallback. Presence-as-truth like + * `messageClientIds`: a missing row is a normal state, never an error. + */ +export const messageParts = chatSchema.table( + "message_parts", + { + tenantId: text("tenant_id").notNull(), + mailMessageId: text("mail_message_id").notNull(), + workbenchId: text("workbench_id").notNull(), + parts: jsonb("parts").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => [ + primaryKey({ + columns: [table.tenantId, table.mailMessageId], + }), + index("message_parts_workbench_idx").on( + table.tenantId, + table.workbenchId, + ), + ], +); + /** * The turn projection (CL-6329): one row per agent turn, opened by the * dispatch seam and closed when the turn settles. Traceability is a diff --git a/packages/chat/test/message-parts.drizzle.test.ts b/packages/chat/test/message-parts.drizzle.test.ts new file mode 100644 index 0000000000..b9b77bf9fa --- /dev/null +++ b/packages/chat/test/message-parts.drizzle.test.ts @@ -0,0 +1,200 @@ +// DB-gated for the Drizzle round-trip, plain unit tests otherwise — +// mirroring `block-responses.drizzle.test.ts`. Runs against its own +// scratch database. +// +// Proves the CL-7594 rich-part sidecar: `recordMessageParts` persists a +// frame's `Part[]` under its Message-ID, `readMessageParts` returns it, +// `listMessagePartsForFrames` batches one page in one query, a frame with +// no row reads null (the timeline's text-only fallback, never an error), +// a corrupt row likewise reads null, and a second record for the same +// Message-ID is first-write-wins so a retried send never clobbers the +// confirmed parts. +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { drizzle } from "drizzle-orm/postgres-js"; +import postgres from "postgres"; + +import { e2eDatabaseUrl } from "../../../scripts/e2e/database-url"; +import { dbGate } from "../../../scripts/e2e/db-gate"; +import { applyChatMigrations } from "../src/migrations"; +import { + createDrizzleMessagePartsStore, + createInMemoryMessagePartsStore, + type MessagePartsStore, +} from "../src/message-parts"; +import type { Part } from "../src/parts"; + +const TEXT_PART: Part = { + kind: "text", + text: "hello from the native timeline", +}; + +const BLOCK_PART: Part = { + kind: "block", + block: { type: "text", data: { text: "rich" } }, +}; + +function textPart(): Part { + return { kind: "text", text: "hello from the native timeline" }; +} + +describe("createInMemoryMessagePartsStore", () => { + test("records and reads back a frame's parts", async () => { + const store = createInMemoryMessagePartsStore(); + await store.recordMessageParts({ + tenantId: "ten_1", + mailMessageId: "", + workbenchId: "run_1", + parts: [textPart()], + }); + expect( + await store.readMessageParts("ten_1", ""), + ).toEqual([TEXT_PART]); + }); + + test("a frame with no row reads null", async () => { + const store = createInMemoryMessagePartsStore(); + expect(await store.readMessageParts("ten_1", "")).toBeNull(); + }); + + test("batch read returns only the wanted frames", async () => { + const store: MessagePartsStore = createInMemoryMessagePartsStore(); + await store.recordMessageParts({ + tenantId: "ten_1", + mailMessageId: "", + workbenchId: "run_1", + parts: [textPart()], + }); + await store.recordMessageParts({ + tenantId: "ten_1", + mailMessageId: "", + workbenchId: "run_1", + parts: [BLOCK_PART], + }); + const rows = await store.listMessagePartsForFrames("ten_1", [ + "", + "", + ]); + expect(rows).toHaveLength(1); + expect(rows[0].mailMessageId).toBe(""); + expect(rows[0].parts).toEqual([TEXT_PART]); + expect(await store.listMessagePartsForFrames("ten_1", [])).toEqual([]); + }); +}); + +function scratchUrlFor(e2eUrl: string): string { + const url = new URL(e2eUrl); + const database = url.pathname.replace(/^\//, ""); + url.pathname = `/${database}_chat_message_parts_drizzle_test`; + return url.toString(); +} + +const databaseUrl = e2eDatabaseUrl(); +const describeIfDb = dbGate(databaseUrl, import.meta.path); + +describeIfDb("createDrizzleMessagePartsStore", () => { + const scratchUrl = scratchUrlFor( + databaseUrl ?? "postgres://localhost:5432/unused", + ); + const scratchTarget = new URL(scratchUrl); + const scratchDatabase = scratchTarget.pathname.replace(/^\//, ""); + + beforeAll(async () => { + const maintenanceUrl = new URL(scratchUrl); + maintenanceUrl.pathname = "/postgres"; + const maintenance = postgres(maintenanceUrl.toString(), { + max: 1, + onnotice: () => undefined, + }); + try { + await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); + await maintenance.unsafe(`CREATE DATABASE "${scratchDatabase}"`); + } finally { + await maintenance.end(); + } + await applyChatMigrations(scratchUrl); + }); + + afterAll(async () => { + const maintenanceUrl = new URL(scratchUrl); + maintenanceUrl.pathname = "/postgres"; + const maintenance = postgres(maintenanceUrl.toString(), { + max: 1, + onnotice: () => undefined, + }); + try { + await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); + } finally { + await maintenance.end(); + } + }); + + test("round-trips parts, batches a page, and misses read null", async () => { + const sql = postgres(scratchUrl, { max: 1, onnotice: () => undefined }); + try { + const store = createDrizzleMessagePartsStore(drizzle(sql)); + await store.recordMessageParts({ + tenantId: "ten_1", + mailMessageId: "", + workbenchId: "run_1", + parts: [textPart(), BLOCK_PART], + }); + expect(await store.readMessageParts("ten_1", "")).toEqual([ + TEXT_PART, + BLOCK_PART, + ]); + expect( + await store.readMessageParts("ten_1", ""), + ).toBeNull(); + const rows = await store.listMessagePartsForFrames("ten_1", [ + "", + ]); + expect(rows).toHaveLength(1); + expect(rows[0].workbenchId).toBe("run_1"); + expect(rows[0].parts).toEqual([TEXT_PART, BLOCK_PART]); + } finally { + await sql.end(); + } + }); + + test("a second record for the same Message-ID never clobbers the first", async () => { + const sql = postgres(scratchUrl, { max: 1, onnotice: () => undefined }); + try { + const store = createDrizzleMessagePartsStore(drizzle(sql)); + await store.recordMessageParts({ + tenantId: "ten_1", + mailMessageId: "", + workbenchId: "run_1", + parts: [textPart()], + }); + await store.recordMessageParts({ + tenantId: "ten_1", + mailMessageId: "", + workbenchId: "run_1", + parts: [BLOCK_PART], + }); + expect(await store.readMessageParts("ten_1", "")).toEqual([ + TEXT_PART, + ]); + } finally { + await sql.end(); + } + }); + + test("a corrupt sidecar row reads null rather than breaking the page", async () => { + const sql = postgres(scratchUrl, { max: 1, onnotice: () => undefined }); + try { + await sql.unsafe( + `INSERT INTO "chat"."message_parts" ("tenant_id", "mail_message_id", "workbench_id", "parts") VALUES ('ten_1', '', 'run_1', '[{"kind": "no-such-part"}]')`, + ); + const store = createDrizzleMessagePartsStore(drizzle(sql)); + expect( + await store.readMessageParts("ten_1", ""), + ).toBeNull(); + expect( + await store.listMessagePartsForFrames("ten_1", [""]), + ).toEqual([]); + } finally { + await sql.end(); + } + }); +}); From 8c5ed8165bcd9ebf179c5db55ca0ac271e29753f Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 15 Sep 2026 16:24:07 -0700 Subject: [PATCH 02/12] fix(chat): satisfy noUncheckedIndexedAccess in message_parts sidecar --- packages/chat/src/message-parts.ts | 4 +++- packages/chat/test/message-parts.drizzle.test.ts | 8 ++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/chat/src/message-parts.ts b/packages/chat/src/message-parts.ts index f30a99cf17..7f4575e3d0 100644 --- a/packages/chat/src/message-parts.ts +++ b/packages/chat/src/message-parts.ts @@ -121,7 +121,9 @@ export function createDrizzleMessagePartsStore< ) .limit(1); if (rows.length === 0) return null; - return parseStoredParts(rows[0].parts); + const first = rows[0]; + if (first === undefined) return null; + return parseStoredParts(first.parts); }, async listMessagePartsForFrames(tenantId, mailMessageIds) { diff --git a/packages/chat/test/message-parts.drizzle.test.ts b/packages/chat/test/message-parts.drizzle.test.ts index b9b77bf9fa..7a0673a51b 100644 --- a/packages/chat/test/message-parts.drizzle.test.ts +++ b/packages/chat/test/message-parts.drizzle.test.ts @@ -75,8 +75,8 @@ describe("createInMemoryMessagePartsStore", () => { "", ]); expect(rows).toHaveLength(1); - expect(rows[0].mailMessageId).toBe(""); - expect(rows[0].parts).toEqual([TEXT_PART]); + expect(rows[0]?.mailMessageId).toBe(""); + expect(rows[0]?.parts).toEqual([TEXT_PART]); expect(await store.listMessagePartsForFrames("ten_1", [])).toEqual([]); }); }); @@ -149,8 +149,8 @@ describeIfDb("createDrizzleMessagePartsStore", () => { "", ]); expect(rows).toHaveLength(1); - expect(rows[0].workbenchId).toBe("run_1"); - expect(rows[0].parts).toEqual([TEXT_PART, BLOCK_PART]); + expect(rows[0]?.workbenchId).toBe("run_1"); + expect(rows[0]?.parts).toEqual([TEXT_PART, BLOCK_PART]); } finally { await sql.end(); } From db04d5f8c0c159f07612c18b0c47e2bca111587d Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 15 Sep 2026 16:34:13 -0700 Subject: [PATCH 03/12] CL-7594: descriptor threads and native timeline reads --- .../chat/src/room-messages-native.test.ts | 212 ++++++ packages/chat/src/room-messages-native.ts | 446 +++++++++++ packages/chat/src/threads-native.test.ts | 294 ++++++++ packages/chat/src/threads-native.ts | 714 ++++++++++++++++++ packages/chat/src/threads.ts | 27 +- 5 files changed, 1686 insertions(+), 7 deletions(-) create mode 100644 packages/chat/src/room-messages-native.test.ts create mode 100644 packages/chat/src/room-messages-native.ts create mode 100644 packages/chat/src/threads-native.test.ts create mode 100644 packages/chat/src/threads-native.ts diff --git a/packages/chat/src/room-messages-native.test.ts b/packages/chat/src/room-messages-native.test.ts new file mode 100644 index 0000000000..a003e2f4f7 --- /dev/null +++ b/packages/chat/src/room-messages-native.test.ts @@ -0,0 +1,212 @@ +// The native timeline (CL-7594): frames in, `RoomMessage`s out, with the +// parts sidecar joined per page and text fallbacks for sidecar misses. +import { describe, expect, it } from "bun:test"; + +import { createInMemoryMessagePartsStore } from "./message-parts"; +import { + createNativeRoomMessageStore, + type NativeTimelineDeps, + type NativeTimelineFrame, +} from "./room-messages-native"; +import { rootThreadId } from "./threads-native"; + +const TENANT = "ten_1"; +const BENCH = "run_1"; +const READER = "prn_reader"; + +function frame( + msgId: string, + extra?: Partial, +): NativeTimelineFrame { + return { + id: `pm_${msgId}`, + messageId: `<${msgId}@mail.test>`, + fromAddress: `${READER}@mail.test`, + date: "2026-09-01T00:00:00.000Z", + direction: "outbound", + refs: [{ kind: "workbench", id: BENCH }], + ...extra, + }; +} + +function depsFor(frames: readonly NativeTimelineFrame[]): NativeTimelineDeps { + const byMsgId = new Map( + frames.map((entry) => [ + entry.messageId.replace(/^<(.+?)@.+>$/, "$1"), + entry, + ]), + ); + return { + listWorkbenchFrames: async () => ({ frames: [...frames] }), + readFrame: async (_tenantId, _principalId, messageId) => + byMsgId.get(messageId), + findFrame: async (_tenantId, mailMessageId) => + frames.find((entry) => entry.messageId === mailMessageId), + findFrames: async (_tenantId, messageIds) => + frames.filter((entry) => + messageIds.includes( + entry.messageId.replace(/^<(.+?)@.+>$/, "$1"), + ), + ), + countFramesSince: async () => frames.length, + resolveRunIds: async () => new Map(), + resolveSenderNames: async (_tenantId, addresses) => + new Map(addresses.map((address) => [address, "Reader"] as const)), + resolveTenantDomain: async () => "mail.test", + parts: createInMemoryMessagePartsStore(), + }; +} + +describe("createNativeRoomMessageStore", () => { + it("maps a page of frames onto timeline messages", async () => { + const deps = depsFor([ + frame("msg_b", { date: "2026-09-02T00:00:00.000Z" }), + frame("msg_a"), + ]); + await deps.parts.recordMessageParts({ + tenantId: TENANT, + mailMessageId: "", + workbenchId: BENCH, + parts: [{ kind: "text", text: "hello" }], + }); + const store = createNativeRoomMessageStore(deps, { + threadIdOf: async () => rootThreadId(BENCH), + }); + const page = await store.listMessages({ + tenantId: TENANT, + workbenchId: BENCH, + principalId: READER, + }); + expect(page.items.map((entry) => entry.id)).toEqual(["msg_b", "msg_a"]); + expect(page.items[0]?.mailMessageId).toBe(""); + expect(page.items[0]?.parts).toEqual([]); + expect(page.items[1]?.parts).toEqual([{ kind: "text", text: "hello" }]); + expect(page.items[1]?.threadId).toBe(rootThreadId(BENCH)); + expect(page.items[1]?.senderPrincipalId).toBe(READER); + expect(page.items[1]?.sender.name).toBe("Reader"); + expect(page.nextCursor).toBeUndefined(); + }); + + it("falls back to frame text for sidecar misses, empty when unknown", async () => { + const deps = depsFor([ + frame("msg_b", { fallbackText: "plain body" }), + frame("msg_c"), + ]); + const store = createNativeRoomMessageStore(deps, { + threadIdOf: async () => null, + }); + const page = await store.listMessages({ + tenantId: TENANT, + workbenchId: BENCH, + principalId: READER, + }); + expect(page.items[0]?.parts).toEqual([{ kind: "text", text: "plain body" }]); + expect(page.items[1]?.parts).toEqual([]); + }); + + it("reads agent frames with no principal and inbound humans by address", async () => { + const deps = depsFor([ + frame("msg_agent", { + fromAddress: "agent+writer@mail.test", + direction: "inbound", + }), + frame("msg_in", { + fromAddress: "prn_other@mail.test", + direction: "inbound", + }), + ]); + const store = createNativeRoomMessageStore(deps, { + threadIdOf: async () => null, + }); + const page = await store.listMessages({ + tenantId: TENANT, + workbenchId: BENCH, + principalId: READER, + }); + expect(page.items[0]?.senderPrincipalId).toBeNull(); + expect(page.items[1]?.senderPrincipalId).toBe("prn_other"); + }); + + it("requires the reader for mailbox-scoped reads", async () => { + const store = createNativeRoomMessageStore(depsFor([]), { + threadIdOf: async () => null, + }); + await expect( + store.listMessages({ tenantId: TENANT, workbenchId: BENCH }), + ).rejects.toThrow(/principalId/); + await expect( + store.listActivity({ tenantId: TENANT, workbenches: [] }), + ).rejects.toThrow(/principalId/); + }); + + it("resolves single frames by msg_ id with and without a reader", async () => { + const deps = depsFor([frame("msg_a")]); + const store = createNativeRoomMessageStore(deps, { + threadIdOf: async () => null, + }); + const withReader = await store.getMessage({ + tenantId: TENANT, + workbenchId: BENCH, + messageId: "msg_a", + principalId: READER, + }); + expect(withReader?.id).toBe("msg_a"); + const systematic = await store.getMessage({ + tenantId: TENANT, + workbenchId: BENCH, + messageId: "msg_a", + }); + expect(systematic?.id).toBe("msg_a"); + const found = await store.findByMailMessageId({ + tenantId: TENANT, + mailMessageId: "", + }); + expect(found?.workbenchId).toBe(BENCH); + }); + + it("summarizes activity off the newest frame and unread counts", async () => { + const deps = depsFor([frame("msg_a")]); + await deps.parts.recordMessageParts({ + tenantId: TENANT, + mailMessageId: "", + workbenchId: BENCH, + parts: [{ kind: "text", text: "newest" }], + }); + const store = createNativeRoomMessageStore(deps, { + threadIdOf: async () => null, + }); + const activity = await store.listActivity({ + tenantId: TENANT, + principalId: READER, + workbenches: [{ workbenchId: BENCH, sinceCreatedAt: undefined }], + }); + expect(activity[BENCH]?.unreadCount).toBe(1); + expect(activity[BENCH]?.lastActivityAt).toBe("2026-09-01T00:00:00.000Z"); + expect(activity[BENCH]?.preview).toBe("newest"); + }); + + it("has no writes left: insert, stamp, and delete all throw", async () => { + const store = createNativeRoomMessageStore(depsFor([]), { + threadIdOf: async () => null, + }); + await expect( + store.insertMessage({ + tenantId: TENANT, + workbenchId: BENCH, + sender: { name: null, address: "u@mail.test" }, + parts: [], + }), + ).rejects.toThrow(); + await expect( + store.stampMailMessageId({ + tenantId: TENANT, + workbenchId: BENCH, + messageId: "msg_a", + mailMessageId: "", + }), + ).rejects.toThrow(); + await expect( + store.deleteMessage({ tenantId: TENANT, messageId: "msg_a" }), + ).rejects.toThrow(); + }); +}); diff --git a/packages/chat/src/room-messages-native.ts b/packages/chat/src/room-messages-native.ts new file mode 100644 index 0000000000..29d4591f9a --- /dev/null +++ b/packages/chat/src/room-messages-native.ts @@ -0,0 +1,446 @@ +// The room's timeline read straight off native mail (CL-7594): a +// workbench's messages ARE the `@corbits/mailbox` frames carrying its +// workbench ref, and this store maps those frames back onto `RoomMessage`. +// Rich rendering comes from the `message_parts` sidecar (see +// `./message-parts.ts`); a frame with no sidecar row renders its text-only +// fallback, never an error — which is also what pre-cutover frames read as, +// since there is deliberately no backfill. +// +// Reads are principal-scoped exactly like the mailbox they read: the +// caller's own copies are what its timeline shows, so `listMessages` and +// `listActivity` both take the reader's `principalId`. Single-frame lookups +// (`getMessage`, `getMessages`, `findByMailMessageId`) resolve tenant-wide — +// every copy of a frame shares its Message-ID and headers, so any copy maps +// the same — except where the caller already has a reader, in which case it +// passes it through. +// +// The mailbox-savvy seams (`listWorkbenchFrames`, `readFrame`, `findFrame`, +// `countFramesSince`, `resolveRunIds`, `resolveSenderNames`) are implemented +// once at the hub root; this package owns only the mapping. That keeps the +// bridge narrow: everything the hub must know about mail lives in one place, +// and everything chat knows about rooms stays here. +import { localPartOf } from "./agent-address"; +import { isAgentAddress } from "./mentions"; +import { + mailMessageIdFor, + parentMailMessageId, + rowIdFromMailMessageId, +} from "./mail-headers"; +import type { MessagePartsStore } from "./message-parts"; +import type { Part } from "./parts"; +import { + pageOf, + summaryOf, + type ListedRoomMessages, + type RoomActivitySummary, + type RoomMessage, + type RoomMessageStore, +} from "./room-messages"; + +export interface NativeTimelineFrame { + /** The reader's own `principal_mail` row id for this copy. */ + readonly id: string; + /** The frame's RFC 5322 `Message-ID`, shared by every copy. */ + readonly messageId: string; + readonly fromAddress: string; + readonly date: string; + readonly direction: "inbound" | "outbound"; + readonly inReplyTo?: string; + readonly subject?: string; + /** Delivery descriptors ride here — see `./threads-native.ts`. */ + readonly refs: readonly { + readonly kind: string; + readonly id: string; + readonly label?: string; + }[]; + /** + * Text-only fallback for a frame with no sidecar row: the frame's own + * body (or snippet), present only when the seam already had it in hand. + * Absent means no fallback text is known — the frame reads empty, never + * an error. + */ + readonly fallbackText?: string; +} + +export interface NativeWorkbenchFrames { + readonly frames: readonly NativeTimelineFrame[]; + readonly nextCursor?: string; +} + +export interface NativeTimelineDeps { + /** + * One page of the reader's own frames carrying the workbench ref, newest + * first, keyset-paged on the same opaque cursor this returns. Implemented + * at the hub root over `@corbits/mailbox`'s thread walk plus one refs + * query per page. + */ + readonly listWorkbenchFrames: ( + tenantId: string, + principalId: string, + workbenchId: string, + cursor?: string, + ) => Promise; + /** + * The tenant's mail domain, to frame msg_ ids as `Message-ID`s when no + * reader is in scope (system lookups by msg_ id). + */ + readonly resolveTenantDomain: (tenantId: string) => Promise; + /** + * The reader's own copy of one frame by its msg_ id, for `getMessage`. + * Undefined when the reader holds no copy — which the caller reports + * rather than guessing from another principal's mailbox. + */ + readonly readFrame: ( + tenantId: string, + principalId: string, + messageId: string, + ) => Promise; + /** + * Any copy of the frame a `Message-ID` names, tenant-wide, for + * `findByMailMessageId` and the correlation reads. Every copy shares the + * Message-ID and headers, so any copy maps the same. + */ + readonly findFrame: ( + tenantId: string, + mailMessageId: string, + ) => Promise; + /** + * Any copies of the frames a list of msg_ ids names, tenant-wide, for + * `getMessages` (pin checks, reaction targets). Returns only the frames + * that resolve — a missing id is absent, never an error. + */ + readonly findFrames: ( + tenantId: string, + messageIds: readonly string[], + ) => Promise; + /** + * How many of the reader's frames in the workbench are newer than + * `sinceCreatedAt`, for `listActivity` unread counts. Counts the same + * rows `listWorkbenchFrames` pages over. + */ + readonly countFramesSince: ( + tenantId: string, + principalId: string, + workbenchId: string, + sinceCreatedAt: string | undefined, + ) => Promise; + /** + * The agent run each frame came out of, by frame Message-ID — read off + * `agent_turns`' surviving `reply_message_id`, in one query. Frames with + * no recorded run are absent from the map, never null-valued. + */ + readonly resolveRunIds: ( + tenantId: string, + mailMessageIds: readonly string[], + ) => Promise>; + /** + * Display names for a page's sender addresses, by bare address — the + * principal directory first, workbench participants (agents included) + * second. Unresolvable addresses are absent; the frame's own address is + * then all the timeline shows. + */ + readonly resolveSenderNames: ( + tenantId: string, + addresses: readonly string[], + ) => Promise>; + readonly parts: MessagePartsStore; +} + + +function textFallbackParts(text: string): Part[] { + return [{ kind: "text", text }]; +} + +function senderPrincipalIdOf( + frame: NativeTimelineFrame, + readerPrincipalId: string | undefined, +): string | null { + if (isAgentAddress(frame.fromAddress)) return null; + if ( + frame.direction === "outbound" && + readerPrincipalId !== undefined && + frame.fromAddress.length > 0 + ) { + return readerPrincipalId; + } + const local = localPartOf(frame.fromAddress); + return local.length > 0 ? local : null; +} + +export function toNativeRoomMessage( + frame: NativeTimelineFrame, + input: { + readonly tenantId: string; + readonly workbenchId: string; + readonly parts: readonly Part[] | null; + readonly runId: string | undefined; + readonly senderName: string | undefined; + readonly threadId: string | null; + readonly readerPrincipalId?: string; + }, +): RoomMessage { + return { + id: rowIdFromMailMessageId(frame.messageId), + workbenchId: input.workbenchId, + createdAt: frame.date, + sender: { + name: input.senderName ?? null, + address: frame.fromAddress, + }, + senderPrincipalId: senderPrincipalIdOf(frame, input.readerPrincipalId), + runId: input.runId ?? null, + threadId: input.threadId, + mailMessageId: frame.messageId, + parts: + input.parts !== null + ? [...input.parts] + : frame.fallbackText !== undefined + ? textFallbackParts(frame.fallbackText) + : [], + }; +} + +/** + * Maps one page of native frames onto timeline messages: one sidecar batch + * for the page's parts, one run-id batch, one sender-name batch. Thread + * ids come from the caller-supplied descriptor fn — descriptors are pure + * (see `./threads-native.ts`), so mapping never queries per frame. + */ +export async function mapNativeFrames( + deps: NativeTimelineDeps, + input: { + readonly tenantId: string; + readonly workbenchId: string; + readonly frames: readonly NativeTimelineFrame[]; + readonly readerPrincipalId?: string; + readonly threadIdOf: ( + frame: NativeTimelineFrame, + ) => string | null | Promise; + }, +): Promise { + if (input.frames.length === 0) return []; + const mailMessageIds = input.frames.map((frame) => frame.messageId); + const [sidecarRows, runIds, names] = await Promise.all([ + deps.parts.listMessagePartsForFrames(input.tenantId, mailMessageIds), + deps.resolveRunIds(input.tenantId, mailMessageIds), + deps.resolveSenderNames( + input.tenantId, + [...new Set(input.frames.map((frame) => frame.fromAddress))].filter( + (address) => address.length > 0, + ), + ), + ]); + const partsByMessageId = new Map( + sidecarRows.map((row) => [row.mailMessageId, row.parts] as const), + ); + return Promise.all( + input.frames.map(async (frame) => { + const stored = partsByMessageId.get(frame.messageId) ?? null; + // Tenant-wide lookups resolve frames whose workbench ref the caller + // never named (pin checks, correlation reads): the frame's own ref is + // the authority, and it agrees with the requested workbench whenever + // the caller did name one. + const workbenchId = + frame.refs.find((ref) => ref.kind === "workbench")?.id ?? + input.workbenchId; + return toNativeRoomMessage(frame, { + tenantId: input.tenantId, + workbenchId, + parts: stored !== null ? [...stored] : null, + runId: runIds.get(frame.messageId), + senderName: names.get(frame.fromAddress), + threadId: await input.threadIdOf(frame), + ...(input.readerPrincipalId !== undefined + ? { readerPrincipalId: input.readerPrincipalId } + : {}), + }); + }), + ); +} + +/** The delivery ref that promotes a frame out of the thread its headers + * would otherwise put it in — see `./threads-native.ts`. */ +export function deliveryRefOf(frame: Pick): + | { readonly id: string; readonly label?: string } + | undefined { + return frame.refs.find((ref) => ref.kind === "chat-delivery"); +} + +/** + * Creates the native `RoomMessageStore`: every read off the reader's (or, + * for single-frame lookups, any) mailbox copies plus the parts sidecar. + * `threadIdOf` is the descriptor derivation from `./threads-native.ts`, + * injected so this module never imports the thread scheme itself. + * + * Writes (`insertMessage`, `stampMailMessageId`, `deleteMessage`) are gone: + * sends are mailbox-first now (see `sendWorkbenchMessage` in + * `./workbench-service.ts`), so there is no row to insert, stamp, or delete + * on failure — the mailbox batch is the one durable write. + */ +export function createNativeRoomMessageStore( + deps: NativeTimelineDeps, + thread: { + readonly threadIdOf: ( + tenantId: string, + workbenchId: string, + frame: Pick, + ) => Promise; + }, +): RoomMessageStore { + async function mapPage( + tenantId: string, + workbenchId: string, + frames: readonly NativeTimelineFrame[], + readerPrincipalId?: string, + ): Promise { + return mapNativeFrames(deps, { + tenantId, + workbenchId, + frames, + ...(readerPrincipalId !== undefined ? { readerPrincipalId } : {}), + threadIdOf: (frame) => thread.threadIdOf(tenantId, workbenchId, frame), + }); + } + + return { + async insertMessage() { + throw new Error( + "insertMessage is gone: sends write mailbox frames first (CL-7594), there is no timeline row to insert", + ); + }, + async stampMailMessageId() { + throw new Error( + "stampMailMessageId is gone: a frame's Message-ID is minted at send time (CL-7594), never stamped after", + ); + }, + async deleteMessage() { + throw new Error( + "deleteMessage is gone: mailbox-first sends have no just-inserted row to roll back (CL-7594)", + ); + }, + + async listMessages(input) { + const principalId = input.principalId; + if (principalId === undefined) { + throw new Error( + "listMessages needs the reader's principalId: the timeline reads its own mailbox copies (CL-7594)", + ); + } + const page = await deps.listWorkbenchFrames( + input.tenantId, + principalId, + input.workbenchId, + input.cursor, + ); + const items = await mapPage( + input.tenantId, + input.workbenchId, + page.frames, + principalId, + ); + return pageOfWithCursor(items, page.nextCursor); + }, + + async getMessages(input) { + const frames = await deps.findFrames(input.tenantId, input.messageIds); + const wanted = new Set(input.messageIds); + const belonging = frames.filter((frame) => + wanted.has(rowIdFromMailMessageId(frame.messageId)), + ); + return mapPage(input.tenantId, "", belonging); + }, + + async getMessage(input) { + const principalId = input.principalId; + const frame = + principalId !== undefined + ? await deps.readFrame(input.tenantId, principalId, input.messageId) + : await deps.findFrame( + input.tenantId, + // System lookups hold only the msg_ id: frame it the way + // sends mint it, then resolve tenant-wide. + mailMessageIdFor( + input.messageId, + await deps.resolveTenantDomain(input.tenantId), + ), + ); + if (frame === undefined) return undefined; + const [mapped] = await mapPage( + input.tenantId, + input.workbenchId, + [frame], + principalId, + ); + return mapped; + }, + + async findByMailMessageId(input) { + const frame = await deps.findFrame(input.tenantId, input.mailMessageId); + if (frame === undefined) return undefined; + const [mapped] = await mapPage(input.tenantId, "", [frame]); + return mapped; + }, + + async listActivity(input) { + const principalId = input.principalId; + if (principalId === undefined) { + throw new Error( + "listActivity needs the reader's principalId: unread counts read its own mailbox copies (CL-7594)", + ); + } + const result: Record = {}; + await Promise.all( + input.workbenches.map(async (workbench) => { + const [page, unreadCount] = await Promise.all([ + deps.listWorkbenchFrames( + input.tenantId, + principalId, + workbench.workbenchId, + undefined, + ), + deps.countFramesSince( + input.tenantId, + principalId, + workbench.workbenchId, + workbench.sinceCreatedAt, + ), + ]); + const newest = page.frames[0]; + if (newest === undefined) return; + // The summary preview sometimes reads back past the newest + // message; map the page head the way the legacy store mapped its + // preview page. + const mapped = await mapPage( + input.tenantId, + workbench.workbenchId, + page.frames.slice(0, 5), + principalId, + ); + const [first] = mapped; + if (first === undefined) return; + result[workbench.workbenchId] = summaryOf(first, unreadCount, mapped); + }), + ); + return result; + }, + }; +} + +function pageOfWithCursor( + items: readonly RoomMessage[], + nextCursor: string | undefined, +): ListedRoomMessages { + const page = pageOf(items); + // `pageOf` slices at its own page size; the seam already sized the page, + // so only adopt the seam's cursor when there is one — never invent a + // second page boundary on top of it. + if (nextCursor === undefined) return { items: page.items }; + return { items: page.items, nextCursor }; +} + +/** The nearest ancestor a frame answers, for descriptor derivation. */ +export function parentOfFrame( + frame: Pick, +): string | undefined { + return parentMailMessageId({ inReplyTo: frame.inReplyTo }); +} diff --git a/packages/chat/src/threads-native.test.ts b/packages/chat/src/threads-native.test.ts new file mode 100644 index 0000000000..32f486bae8 --- /dev/null +++ b/packages/chat/src/threads-native.test.ts @@ -0,0 +1,294 @@ +// Descriptor threads (CL-7594): ids parse back into anchors, opens are +// idempotent constructors, and membership derives off headers. +import { describe, expect, it } from "bun:test"; + +import { + ancestryOfDescriptor, + createDescriptorThreadStore, + deliveryThreadId, + deriveThreadId, + deriveThreadIds, + parseThreadDescriptor, + replyThreadId, + rootThreadId, + subThreadId, + threadAncestryMessageIds, + type DescriptorThreadDeps, + type NativeFrameHeaders, +} from "./threads-native"; +import { ThreadDepthCapError } from "./threads"; + +const TENANT = "ten_1"; +const BENCH = "run_1"; + +function header( + msgId: string, + extra?: Partial, +): NativeFrameHeaders { + return { + messageId: `<${msgId}@mail.test>`, + msgId, + workbenchId: BENCH, + date: "2026-09-01T00:00:00.000Z", + ...extra, + }; +} + +function depsFor(headers: readonly NativeFrameHeaders[]): DescriptorThreadDeps { + const byMsgId = new Map(headers.map((entry) => [entry.msgId, entry])); + return { + findFrameHeaders: async (_tenantId, messageIds) => { + const result = new Map(); + for (const id of messageIds) { + const found = byMsgId.get(id); + if (found !== undefined) result.set(id, found); + } + return result; + }, + listWorkbenchFrameHeaders: async () => [...headers], + }; +} + +describe("parseThreadDescriptor", () => { + it("round-trips every descriptor shape", () => { + expect(parseThreadDescriptor(rootThreadId(BENCH))).toEqual({ + kind: "root", + workbenchId: BENCH, + }); + expect(parseThreadDescriptor(replyThreadId("msg_abc"))).toEqual({ + kind: "reply", + parentMessageId: "msg_abc", + }); + expect( + parseThreadDescriptor(subThreadId(replyThreadId("msg_a"), "msg_b")), + ).toEqual({ + kind: "sub", + parentThreadId: replyThreadId("msg_a"), + anchorMessageId: "msg_b", + }); + expect(parseThreadDescriptor(deliveryThreadId("run_9"))).toEqual({ + kind: "delivery", + runRef: "run_9", + }); + }); + + it("rejects legacy thr_ ids and truncated prefixes", () => { + expect(parseThreadDescriptor("thr_abcdef123456")).toBeUndefined(); + expect(parseThreadDescriptor("thr_root_")).toBeUndefined(); + expect(parseThreadDescriptor("thr_reply_")).toBeUndefined(); + expect(parseThreadDescriptor("thr_delivery_")).toBeUndefined(); + expect(parseThreadDescriptor("thr_sub_nothing")).toBeUndefined(); + }); + + it("keeps run refs with underscores intact through sub-threads", () => { + const parent = deliveryThreadId("run_with_underscores"); + const sub = subThreadId(parent, "msg_b"); + expect(parseThreadDescriptor(sub)).toEqual({ + kind: "sub", + parentThreadId: parent, + anchorMessageId: "msg_b", + }); + }); + + it("rejects sub-threads under root or sub-threads", () => { + expect( + parseThreadDescriptor(subThreadId(rootThreadId(BENCH), "msg_b")), + ).toBeUndefined(); + expect( + parseThreadDescriptor( + `${"thr_sub_"}${subThreadId(replyThreadId("msg_a"), "msg_b")}_msg_c`, + ), + ).toBeUndefined(); + }); +}); + +describe("ancestryOfDescriptor", () => { + it("answers the anchor chain root-first, capped at two", () => { + expect(ancestryOfDescriptor(rootThreadId(BENCH))).toEqual([]); + expect(ancestryOfDescriptor(deliveryThreadId("run_1"))).toEqual([]); + expect(ancestryOfDescriptor(replyThreadId("msg_a"))).toEqual(["msg_a"]); + expect( + ancestryOfDescriptor(subThreadId(replyThreadId("msg_a"), "msg_b")), + ).toEqual(["msg_a", "msg_b"]); + expect(ancestryOfDescriptor("thr_legacy")).toEqual([]); + }); + + it("frames ancestry as Message-IDs for dispatches", () => { + expect( + threadAncestryMessageIds(subThreadId(replyThreadId("msg_a"), "msg_b"), (rowId) => `<${rowId}@mail.test>`), + ).toEqual(["", ""]); + expect(threadAncestryMessageIds(null, (rowId) => rowId)).toEqual([]); + }); +}); + +describe("deriveThreadId", () => { + const a = header("msg_a"); + const b = header("msg_b", { inReplyTo: "" }); + const c = header("msg_c", { inReplyTo: "" }); + const d = header("msg_d", { inReplyTo: "" }); + const byMsgId = new Map([ + ["msg_a", a], + ["msg_b", b], + ["msg_c", c], + ["msg_d", d], + ]); + + it("reads root-feed frames into the root descriptor", () => { + expect(deriveThreadId(a, byMsgId, BENCH)).toBe(rootThreadId(BENCH)); + }); + + it("opens reply threads under root-feed parents", () => { + expect(deriveThreadId(b, byMsgId, BENCH)).toBe(replyThreadId("msg_a")); + }); + + it("nests sub-threads under depth-1 parents and siblings under depth-2", () => { + expect(deriveThreadId(c, byMsgId, BENCH)).toBe( + subThreadId(replyThreadId("msg_a"), "msg_b"), + ); + expect(deriveThreadId(d, byMsgId, BENCH)).toBe( + subThreadId(replyThreadId("msg_a"), "msg_c"), + ); + }); + + it("prefers stamped refs over header derivation", () => { + const stamped = header("msg_x", { + inReplyTo: "", + threadRef: replyThreadId("msg_z"), + }); + expect( + deriveThreadId(stamped, new Map([["msg_x", stamped], ...byMsgId]), BENCH), + ).toBe(replyThreadId("msg_z")); + const delivered = header("msg_y", { deliveryRef: { id: "run_7" } }); + expect( + deriveThreadId(delivered, new Map([["msg_y", delivered]]), BENCH), + ).toBe(deliveryThreadId("run_7")); + }); + + it("roots frames answering foreign or non-chat parents", () => { + const foreignParent = header("msg_f", { + inReplyTo: "", + }); + const withForeign = new Map([ + ["msg_f", foreignParent], + [ + "msg_other", + header("msg_other", { + inReplyTo: "", + workbenchId: "run_other", + }), + ], + ]); + expect(deriveThreadId(foreignParent, withForeign, BENCH)).toBe( + rootThreadId(BENCH), + ); + const bracket = header("msg_g", { inReplyTo: "" }); + expect( + deriveThreadId(bracket, new Map([["msg_g", bracket]]), BENCH), + ).toBe(rootThreadId(BENCH)); + }); + + it("derives a whole scan consistently", () => { + const derived = deriveThreadIds([a, b, c, d], BENCH); + expect(derived.get("msg_d")).toBe( + subThreadId(replyThreadId("msg_a"), "msg_c"), + ); + expect(derived.size).toBe(4); + }); +}); + +describe("createDescriptorThreadStore", () => { + it("constructs the root idempotently without mail", async () => { + const store = createDescriptorThreadStore(depsFor([])); + const first = await store.ensureRootThread(TENANT, BENCH); + const second = await store.ensureRootThread(TENANT, BENCH); + expect(first.id).toBe(rootThreadId(BENCH)); + expect(second.id).toBe(first.id); + expect(first.kind).toBe("root"); + }); + + it("opens reply threads under root-feed messages and refuses depth-2", async () => { + const a = header("msg_a"); + const b = header("msg_b", { inReplyTo: "" }); + const c = header("msg_c", { inReplyTo: "" }); + const store = createDescriptorThreadStore(depsFor([a, b, c])); + const opened = await store.openReplyThread({ + tenantId: TENANT, + workbenchId: BENCH, + parentMessageId: "msg_a", + }); + expect(opened.id).toBe(replyThreadId("msg_a")); + expect(opened.parentThreadId).toBe(rootThreadId(BENCH)); + await expect( + store.openReplyThread({ + tenantId: TENANT, + workbenchId: BENCH, + parentMessageId: "msg_c", + }), + ).rejects.toBeInstanceOf(ThreadDepthCapError); + }); + + it("redirects forks off depth-2 messages to a sibling sub-thread", async () => { + const a = header("msg_a"); + const b = header("msg_b", { inReplyTo: "" }); + const c = header("msg_c", { inReplyTo: "" }); + const store = createDescriptorThreadStore(depsFor([a, b, c])); + const forked = await store.forkThread({ + tenantId: TENANT, + workbenchId: BENCH, + parentMessageId: "msg_c", + }); + expect(forked.id).toBe( + subThreadId(replyThreadId("msg_a"), "msg_c"), + ); + }); + + it("resolves delivery titles off the reader's copies", async () => { + const delivered = header("msg_y", { + deliveryRef: { id: "run_7", label: "Nightly review" }, + }); + const store = createDescriptorThreadStore(depsFor([delivered])); + const threadId = deliveryThreadId("run_7"); + const withoutReader = await store.getThread(TENANT, BENCH, threadId); + expect(withoutReader?.runRef).toBe("run_7"); + expect(withoutReader?.title).toBeNull(); + const withReader = await store.getThread(TENANT, BENCH, threadId, "u_1"); + expect(withReader?.title).toBe("Nightly review"); + expect(withReader?.kind).toBe("delivery"); + expect(await store.getThread(TENANT, BENCH, "thr_legacy")).toBeUndefined(); + }); + + it("lists threads and assignments off a scan", async () => { + const a = header("msg_a"); + const b = header("msg_b", { inReplyTo: "" }); + const delivered = header("msg_y", { + deliveryRef: { id: "run_7", label: "Nightly review" }, + }); + const store = createDescriptorThreadStore(depsFor([a, b, delivered])); + const threads = await store.listThreads(TENANT, BENCH, "u_1"); + expect(threads.map((entry) => entry.id).sort()).toEqual( + [rootThreadId(BENCH), replyThreadId("msg_a"), deliveryThreadId("run_7")].sort(), + ); + const assignments = await store.listThreadAssignments(TENANT, BENCH, "u_1"); + expect(assignments.get("msg_a")).toBe(rootThreadId(BENCH)); + expect(assignments.get("msg_b")).toBe(replyThreadId("msg_a")); + expect(await store.listMessageIds(TENANT, BENCH, replyThreadId("msg_a"), "u_1")).toEqual([ + "msg_b", + ]); + expect(await store.threadIdForMessage(TENANT, BENCH, "msg_b")).toBe( + replyThreadId("msg_a"), + ); + expect(await store.threadIdForMessage(TENANT, BENCH, "msg_missing")).toBeUndefined(); + }); + + it("creates delivery descriptors purely", async () => { + const store = createDescriptorThreadStore(depsFor([])); + const created = await store.createDeliveryThread({ + tenantId: TENANT, + workbenchId: BENCH, + runRef: "run_7", + title: "Nightly review", + }); + expect(created.id).toBe(deliveryThreadId("run_7")); + expect(created.title).toBe("Nightly review"); + }); +}); diff --git a/packages/chat/src/threads-native.ts b/packages/chat/src/threads-native.ts new file mode 100644 index 0000000000..e4dd9563ed --- /dev/null +++ b/packages/chat/src/threads-native.ts @@ -0,0 +1,714 @@ +// Threads as descriptors, not rows (CL-7594): with `workbench_threads` +// and `workbench_thread_messages` dropped, a thread is a deterministic id +// derived from its anchor — the same anchor always names the same thread, +// so opening is idempotent by construction and nothing is ever written. +// +// A thread's ANCESTRY is structural (parsed out of the id), but a frame's +// MEMBERSHIP — which thread it reads in — comes from the frame itself: +// 1. the `chat-thread` ref a chat send stamps (exact, no lookup), then +// 2. the `chat-delivery` ref a delivery send stamps, then +// 3. the threading headers, walked at most two ancestors up (exact for +// every shape a send can produce — see `deriveThreadId`), then +// 4. the root feed, for frames answering nothing (or nothing local). +// +// Delivery threads keep their run identity in the `chat-delivery` ref +// (`{ id: runRef, label: title }`), which is what "delivery descriptors +// from mail refs" means: the descriptor names the run, the ref carries the +// title, and `getThread` reunites them off a workbench scan. +import { localPartOf } from "./agent-address"; +import { + resolveThreadAnchor, + ThreadDepthCapError, + type CreateDeliveryThreadInput, + type ForkThreadInput, + type OpenReplyThreadInput, + type ThreadKind, + type ThreadStore, + type WorkbenchThread, +} from "./threads"; + +export const CHAT_THREAD_REF_KIND = "chat-thread"; +export const CHAT_DELIVERY_REF_KIND = "chat-delivery"; + +const ROOT_PREFIX = "thr_root_"; +const REPLY_PREFIX = "thr_reply_"; +const SUB_PREFIX = "thr_sub_"; +const DELIVERY_PREFIX = "thr_delivery_"; + +export type ThreadDescriptor = + | { readonly kind: "root"; readonly workbenchId: string } + | { readonly kind: "reply"; readonly parentMessageId: string } + | { + readonly kind: "sub"; + readonly parentThreadId: string; + readonly anchorMessageId: string; + } + | { readonly kind: "delivery"; readonly runRef: string }; + +/** The root feed's thread: one per workbench, never written. */ +export function rootThreadId(workbenchId: string): string { + return `${ROOT_PREFIX}${workbenchId}`; +} + +/** The depth-1 reply thread anchored on a message: idempotent — opening + * twice names the same thread, which is what makes `openReplyThread` a + * pure constructor. */ +export function replyThreadId(parentMessageId: string): string { + return `${REPLY_PREFIX}${parentMessageId}`; +} + +/** A depth-2 sub-thread: the depth-1 parent's descriptor plus the anchor + * message. The anchor is split back off on the LAST `_msg_` — msg_ ids + * never contain one themselves, so the parent (which may itself embed + * underscores, e.g. a delivery run ref) stays intact. */ +export function subThreadId( + parentThreadId: string, + anchorMessageId: string, +): string { + return `${SUB_PREFIX}${parentThreadId}_${anchorMessageId}`; +} + +/** A routine-run delivery thread: the run ref, unescaped — parsing strips + * the prefix and takes the rest whole, so no run ref shape can break it. */ +export function deliveryThreadId(runRef: string): string { + return `${DELIVERY_PREFIX}${runRef}`; +} + +/** Parses a descriptor back into its anchor. Undefined for anything that + * was never a descriptor — notably the legacy `thr_` rows, which + * read as unknown rather than guessing a thread. */ +export function parseThreadDescriptor(threadId: string): ThreadDescriptor | undefined { + if (threadId.startsWith(ROOT_PREFIX) && threadId.length > ROOT_PREFIX.length) { + return { kind: "root", workbenchId: threadId.slice(ROOT_PREFIX.length) }; + } + if ( + threadId.startsWith(REPLY_PREFIX) && + threadId.length > REPLY_PREFIX.length + ) { + return { + kind: "reply", + parentMessageId: threadId.slice(REPLY_PREFIX.length), + }; + } + if ( + threadId.startsWith(DELIVERY_PREFIX) && + threadId.length > DELIVERY_PREFIX.length + ) { + return { kind: "delivery", runRef: threadId.slice(DELIVERY_PREFIX.length) }; + } + if (threadId.startsWith(SUB_PREFIX)) { + const rest = threadId.slice(SUB_PREFIX.length); + const anchorAt = rest.lastIndexOf("_msg_"); + if (anchorAt < 0) return undefined; + const parentThreadId = rest.slice(0, anchorAt); + const anchorMessageId = rest.slice(anchorAt + 1); + const parent = parseThreadDescriptor(parentThreadId); + if (parent === undefined || parent.kind === "root" || parent.kind === "sub") { + return undefined; + } + return { kind: "sub", parentThreadId, anchorMessageId }; + } + return undefined; +} + +function depthOf(descriptor: ThreadDescriptor): number { + switch (descriptor.kind) { + case "root": + return 0; + case "reply": + case "delivery": + return 1; + case "sub": + return 2; + } +} + +/** + * The parent chain of a thread, root first — the descriptor successor to + * `mailAncestryOf`: a reply thread answers its anchor message, a sub-thread + * answers its parent's anchor then its own. Empty for the root feed and + * delivery threads, which answer nothing. Bounded by the two-level cap, so + * the chain is never longer than two entries. + */ +export function ancestryOfDescriptor(threadId: string): readonly string[] { + const descriptor = parseThreadDescriptor(threadId); + if (descriptor === undefined) return []; + switch (descriptor.kind) { + case "root": + case "delivery": + return []; + case "reply": + return [descriptor.parentMessageId]; + case "sub": { + const parent = parseThreadDescriptor(descriptor.parentThreadId); + if (parent === undefined) return [descriptor.anchorMessageId]; + if (parent.kind === "reply") { + return [parent.parentMessageId, descriptor.anchorMessageId]; + } + return [descriptor.anchorMessageId]; + } + } +} + +/** The `Message-ID` values `mailThreadHeaders` wants for a thread: the + * descriptor ancestry framed for the tenant's domain. Unknown (legacy) + * thread ids dispatch headerless — a root-feed send, never a guess. */ +export function threadAncestryMessageIds( + threadId: string | null, + mailMessageIdForId: (rowId: string) => string, +): readonly string[] { + if (threadId === null) return []; + return ancestryOfDescriptor(threadId).map((ancestor) => + mailMessageIdForId(ancestor), + ); +} + +export interface NativeFrameHeaders { + /** The frame's RFC 5322 `Message-ID`, shared by every copy. */ + readonly messageId: string; + /** The msg_ id — `rowIdFromMailMessageId(messageId)`. */ + readonly msgId: string; + readonly inReplyTo?: string; + /** The workbench ref's id — which workbench this copy reads in. */ + readonly workbenchId: string; + /** The `chat-thread` ref's id, stamped by chat sends. */ + readonly threadRef?: string; + readonly deliveryRef?: { readonly id: string; readonly label?: string }; + readonly date: string; +} + +export interface DescriptorThreadDeps { + /** + * Headers for a batch of msg_ ids, tenant-wide — any copy's headers do, + * since every copy of a frame shares them. Implemented at the hub root + * as one `principal_mail` query. Only the ids that resolve are present. + */ + readonly findFrameHeaders: ( + tenantId: string, + messageIds: readonly string[], + ) => Promise>; + /** + * Every frame carrying the workbench ref in the reader's own mailbox, + * newest first. The reader scope is also what makes delivery titles + * resolvable: `getThread` finds the frame carrying the delivery ref. + */ + readonly listWorkbenchFrameHeaders: ( + tenantId: string, + principalId: string, + workbenchId: string, + ) => Promise; +} + +/** The parent msg_ a frame answers, when the parent is a chat frame at + * all: anything not shaped `msg_*` (foreign mail, bracket ids) threads + * under nothing. */ +export function parentMsgIdOf(frame: Pick): + | string + | undefined { + const parent = frame.inReplyTo; + if (parent === undefined || parent === "") return undefined; + const local = localPartOf( + parent.startsWith("<") && parent.endsWith(">") && parent.length > 2 + ? parent.slice(1, -1) + : parent, + ); + return local.startsWith("msg_") ? local : undefined; +} + +/** + * Which thread one frame reads in, given the workbench's headers by msg_. + * Exact for every shape a send can produce: chat sends stamp the + * `chat-thread` ref, delivery sends the `chat-delivery` ref, and the + * header walk mirrors the send-time `resolveThreadAnchor` rule — a reply + * under a root-feed message opens a reply thread, under a depth-1 thread + * a sub-thread, under a depth-2 thread a sibling sub-thread. A parent + * outside the map (a foreign workbench's frame, a delegation hop, a frame + * mail never dispatched) reads in the root feed, the way unassigned rows + * always defaulted there. + */ +export function deriveThreadId( + frame: NativeFrameHeaders, + byMsgId: ReadonlyMap, + workbenchId: string, +): string { + const root = rootThreadId(workbenchId); + if (frame.threadRef !== undefined) return frame.threadRef; + if (frame.deliveryRef !== undefined) { + return deliveryThreadId(frame.deliveryRef.id); + } + const parentMessageId = parentMsgIdOf(frame); + if (parentMessageId === undefined) return root; + const parent = byMsgId.get(parentMessageId); + if (parent === undefined || parent.workbenchId !== workbenchId) return root; + const container = deriveThreadId(parent, byMsgId, workbenchId); + const parsed = parseThreadDescriptor(container); + if (parsed === undefined) return replyThreadId(parentMessageId); + const depth = depthOf(parsed); + if (depth === 0) return replyThreadId(parentMessageId); + if (depth === 1) { + return subThreadId(container, parentMessageId); + } + if (parsed.kind === "sub") { + return subThreadId(parsed.parentThreadId, parentMessageId); + } + return replyThreadId(parentMessageId); +} + +/** `deriveThreadId` over a closed header set — a full workbench scan + * resolves every same-bench chain in-map, genesis included. */ +export function deriveThreadIds( + headers: readonly NativeFrameHeaders[], + workbenchId: string, +): ReadonlyMap { + const byMsgId = new Map(headers.map((header) => [header.msgId, header])); + const result = new Map(); + for (const header of headers) { + result.set(header.msgId, deriveThreadId(header, byMsgId, workbenchId)); + } + return result; +} + +function threadForDescriptor( + tenantId: string, + workbenchId: string, + threadId: string, + input: { + readonly parentMessageId: string | null; + readonly parentThreadId: string | null; + readonly runRef: string | null; + readonly title: string | null; + readonly createdAt: Date; + }, +): WorkbenchThread { + const descriptor = parseThreadDescriptor(threadId); + const kind: ThreadKind = + descriptor?.kind === "delivery" + ? "delivery" + : descriptor?.kind === "root" + ? "root" + : "reply"; + return { + id: threadId, + tenantId, + workbenchId, + kind, + parentMessageId: input.parentMessageId, + parentThreadId: input.parentThreadId, + runRef: input.runRef, + title: input.title, + createdAt: input.createdAt, + }; +} + +/** + * Creates the descriptor `ThreadStore`: opens construct ids, reads derive + * off mailbox scans, and membership writes are gone — a frame's thread is + * stamped (chat sends) or derived (everything else), never recorded. + * + * `getThread` and the scans take the reader's `principalId` where they + * must: delivery titles and thread listings resolve off the reader's own + * copies. Ancestry-only callers (`mailAncestryOf`-style dispatch paths) + * omit it and get structural answers — descriptors parse without mail. + */ +export function createDescriptorThreadStore( + deps: DescriptorThreadDeps, +): ThreadStore { + async function headersFor( + tenantId: string, + messageIds: readonly string[], + ): Promise> { + if (messageIds.length === 0) return new Map(); + return deps.findFrameHeaders(tenantId, messageIds); + } + + /** The thread a message reads in, for the depth-cap check and the fork + * redirect: the message's own headers plus two ancestors up — the same + * bound `threadIdForMessage` uses, and exact for every send-time shape + * (sends cap chains at depth 2, so two hops always reach a root-feed or + * stamped frame). Returns the thread's id with its parsed descriptor. + */ + async function containerOfMessage( + tenantId: string, + workbenchId: string, + messageId: string, + ): Promise<{ readonly threadId: string; readonly descriptor: ThreadDescriptor }> { + const root: ThreadDescriptor = { kind: "root", workbenchId }; + const found = await headersFor(tenantId, [messageId]); + const header = found.get(messageId); + if (header === undefined || header.workbenchId !== workbenchId) { + return { threadId: rootThreadId(workbenchId), descriptor: root }; + } + // Two hops decide the container exactly — see `threadIdForMessage`. + const byMsgId = new Map([[messageId, header]]); + let frontier = [header]; + for (let round = 0; round < 2; round += 1) { + const wanted = frontier + .map((entry) => parentMsgIdOf(entry)) + .filter( + (parent): parent is string => + parent !== undefined && !byMsgId.has(parent), + ); + if (wanted.length === 0) break; + const more = await headersFor(tenantId, wanted); + let grew = false; + for (const [id, next] of more) { + if (!byMsgId.has(id)) { + byMsgId.set(id, next); + grew = true; + } + } + frontier = [...more.values()]; + if (!grew) break; + } + const threadId = deriveThreadId(header, byMsgId, workbenchId); + // deriveThreadId only ever constructs descriptors, so this parse is + // total — the fallback is dead code against a logic change elsewhere. + const descriptor = parseThreadDescriptor(threadId) ?? root; + return { + threadId: descriptor === root ? rootThreadId(workbenchId) : threadId, + descriptor, + }; + } + + /** The container as a `WorkbenchThread` for `resolveThreadAnchor`: the + * anchor rule reads the container's own parent linkage, so the thread + * is rebuilt from the parsed descriptor — blank fields would read every + * container as root-adjacent. */ + function containerThread( + tenantId: string, + workbenchId: string, + container: { readonly threadId: string; readonly descriptor: ThreadDescriptor }, + ): WorkbenchThread { + switch (container.descriptor.kind) { + case "root": + return threadForDescriptor(tenantId, workbenchId, container.threadId, { + parentMessageId: null, + parentThreadId: null, + runRef: null, + title: null, + createdAt: new Date(0), + }); + case "reply": + return threadForDescriptor(tenantId, workbenchId, container.threadId, { + parentMessageId: container.descriptor.parentMessageId, + parentThreadId: rootThreadId(workbenchId), + runRef: null, + title: null, + createdAt: new Date(0), + }); + case "sub": + return threadForDescriptor(tenantId, workbenchId, container.threadId, { + parentMessageId: container.descriptor.anchorMessageId, + parentThreadId: container.descriptor.parentThreadId, + runRef: null, + title: null, + createdAt: new Date(0), + }); + case "delivery": + return threadForDescriptor(tenantId, workbenchId, container.threadId, { + parentMessageId: null, + parentThreadId: null, + runRef: container.descriptor.runRef, + title: null, + createdAt: new Date(0), + }); + } + } + + return { + async ensureRootThread(tenantId, workbenchId) { + return threadForDescriptor(tenantId, workbenchId, rootThreadId(workbenchId), { + parentMessageId: null, + parentThreadId: null, + runRef: null, + title: null, + createdAt: new Date(0), + }); + }, + + async createDeliveryThread(input: CreateDeliveryThreadInput) { + // Pure: the descriptor IS the thread, and the title rides the + // delivery ref the send stamps — `getThread` reunites them. + return threadForDescriptor( + input.tenantId, + input.workbenchId, + deliveryThreadId(input.runRef), + { + parentMessageId: null, + parentThreadId: null, + runRef: input.runRef, + title: input.title ?? null, + createdAt: new Date(), + }, + ); + }, + + async openReplyThread(input: OpenReplyThreadInput) { + const container = await containerOfMessage( + input.tenantId, + input.workbenchId, + input.parentMessageId, + ); + const anchor = resolveThreadAnchor( + await this.ensureRootThread(input.tenantId, input.workbenchId), + containerThread(input.tenantId, input.workbenchId, container), + ); + if (anchor.blocked) throw new ThreadDepthCapError(); + if (anchor.parentThreadId === rootThreadId(input.workbenchId)) { + return threadForDescriptor( + input.tenantId, + input.workbenchId, + replyThreadId(input.parentMessageId), + { + parentMessageId: input.parentMessageId, + parentThreadId: anchor.parentThreadId, + runRef: null, + title: input.title ?? null, + createdAt: new Date(), + }, + ); + } + return threadForDescriptor( + input.tenantId, + input.workbenchId, + subThreadId(anchor.parentThreadId, input.parentMessageId), + { + parentMessageId: input.parentMessageId, + parentThreadId: anchor.parentThreadId, + runRef: null, + title: input.title ?? null, + createdAt: new Date(), + }, + ); + }, + + async forkThread(input: ForkThreadInput) { + const container = await containerOfMessage( + input.tenantId, + input.workbenchId, + input.parentMessageId, + ); + const anchor = resolveThreadAnchor( + await this.ensureRootThread(input.tenantId, input.workbenchId), + containerThread(input.tenantId, input.workbenchId, container), + ); + // A fork never refuses: off a depth-2 thread it redirects to a + // sibling under that thread's parent — the CL-5948 rule, now + // structural rather than row-read. + const parentThreadId = anchor.parentThreadId; + if (parentThreadId === rootThreadId(input.workbenchId)) { + return threadForDescriptor( + input.tenantId, + input.workbenchId, + replyThreadId(input.parentMessageId), + { + parentMessageId: input.parentMessageId, + parentThreadId, + runRef: null, + title: input.title ?? null, + createdAt: new Date(), + }, + ); + } + return threadForDescriptor( + input.tenantId, + input.workbenchId, + subThreadId(parentThreadId, input.parentMessageId), + { + parentMessageId: input.parentMessageId, + parentThreadId, + runRef: null, + title: input.title ?? null, + createdAt: new Date(), + }, + ); + }, + + async getThread(tenantId, workbenchId, threadId, principalId?) { + const descriptor = parseThreadDescriptor(threadId); + if (descriptor === undefined) return undefined; + switch (descriptor.kind) { + case "root": + if (descriptor.workbenchId !== workbenchId) return undefined; + return threadForDescriptor(tenantId, workbenchId, threadId, { + parentMessageId: null, + parentThreadId: null, + runRef: null, + title: null, + createdAt: new Date(0), + }); + case "reply": + return threadForDescriptor(tenantId, workbenchId, threadId, { + parentMessageId: descriptor.parentMessageId, + parentThreadId: rootThreadId(workbenchId), + runRef: null, + title: null, + createdAt: new Date(0), + }); + case "sub": + return threadForDescriptor(tenantId, workbenchId, threadId, { + parentMessageId: descriptor.anchorMessageId, + parentThreadId: descriptor.parentThreadId, + runRef: null, + title: null, + createdAt: new Date(0), + }); + case "delivery": { + // The title lives on the frames, not the descriptor: find the + // frame carrying this delivery ref in the reader's own copies. + // Ancestry-only callers omit the reader and get the structure + // with a null title — dispatch needs the run ref, never the + // display title. + if (principalId === undefined) { + return threadForDescriptor(tenantId, workbenchId, threadId, { + parentMessageId: null, + parentThreadId: null, + runRef: descriptor.runRef, + title: null, + createdAt: new Date(0), + }); + } + const headers = await deps.listWorkbenchFrameHeaders( + tenantId, + principalId, + workbenchId, + ); + const carrying = headers.find( + (header) => header.deliveryRef?.id === descriptor.runRef, + ); + return threadForDescriptor(tenantId, workbenchId, threadId, { + parentMessageId: null, + parentThreadId: null, + runRef: descriptor.runRef, + title: carrying?.deliveryRef?.label ?? null, + createdAt: + carrying !== undefined ? new Date(carrying.date) : new Date(0), + }); + } + } + }, + + async listThreads(tenantId, workbenchId, principalId) { + const headers = await deps.listWorkbenchFrameHeaders( + tenantId, + principalId, + workbenchId, + ); + const byThread = new Map(); + const derived = deriveThreadIds(headers, workbenchId); + for (const header of headers) { + const threadId = derived.get(header.msgId); + if (threadId === undefined) continue; + const group = byThread.get(threadId); + if (group === undefined) byThread.set(threadId, [header]); + else group.push(header); + } + const threads: WorkbenchThread[] = [ + await this.ensureRootThread(tenantId, workbenchId), + ]; + for (const [threadId, group] of byThread) { + if (threadId === rootThreadId(workbenchId)) continue; + const descriptor = parseThreadDescriptor(threadId); + if (descriptor === undefined) continue; + const [first] = group; + const createdAt = + first !== undefined ? new Date(first.date) : new Date(0); + if (descriptor.kind === "reply") { + threads.push( + threadForDescriptor(tenantId, workbenchId, threadId, { + parentMessageId: descriptor.parentMessageId, + parentThreadId: rootThreadId(workbenchId), + runRef: null, + title: null, + createdAt, + }), + ); + } else if (descriptor.kind === "sub") { + threads.push( + threadForDescriptor(tenantId, workbenchId, threadId, { + parentMessageId: descriptor.anchorMessageId, + parentThreadId: descriptor.parentThreadId, + runRef: null, + title: null, + createdAt, + }), + ); + } else if (descriptor.kind === "delivery") { + const carrying = group.find( + (header) => header.deliveryRef?.id === descriptor.runRef, + ); + threads.push( + threadForDescriptor(tenantId, workbenchId, threadId, { + parentMessageId: null, + parentThreadId: null, + runRef: descriptor.runRef, + title: carrying?.deliveryRef?.label ?? null, + createdAt, + }), + ); + } + } + return threads; + }, + + async listMessageIds(tenantId, workbenchId, threadId, principalId) { + const headers = await deps.listWorkbenchFrameHeaders( + tenantId, + principalId, + workbenchId, + ); + const derived = deriveThreadIds(headers, workbenchId); + return headers + .filter((header) => derived.get(header.msgId) === threadId) + .map((header) => header.msgId); + }, + + async listThreadAssignments(tenantId, workbenchId, principalId) { + const headers = await deps.listWorkbenchFrameHeaders( + tenantId, + principalId, + workbenchId, + ); + return deriveThreadIds(headers, workbenchId); + }, + + async threadIdForMessage(tenantId, workbenchId, messageId) { + const found = await headersFor(tenantId, [messageId]); + const header = found.get(messageId); + if (header === undefined || header.workbenchId !== workbenchId) { + return undefined; + } + if ( + header.threadRef !== undefined || + header.deliveryRef !== undefined || + parentMsgIdOf(header) === undefined + ) { + return deriveThreadId(header, new Map([[messageId, header]]), workbenchId); + } + // Walk at most two ancestors up — the same bound the store's + // container check uses, and exact for every send-time shape. + const byMsgId = new Map([[messageId, header]]); + let frontier = [header]; + for (let round = 0; round < 2; round += 1) { + const wanted = frontier + .map((entry) => parentMsgIdOf(entry)) + .filter( + (parent): parent is string => + parent !== undefined && !byMsgId.has(parent), + ); + if (wanted.length === 0) break; + const more = await headersFor(tenantId, wanted); + let grew = false; + for (const [id, next] of more) { + if (!byMsgId.has(id)) { + byMsgId.set(id, next); + grew = true; + } + } + frontier = [...more.values()]; + if (!grew) break; + } + return deriveThreadId(header, byMsgId, workbenchId); + }, + }; +} diff --git a/packages/chat/src/threads.ts b/packages/chat/src/threads.ts index 35911f5300..a5f9b1fc37 100644 --- a/packages/chat/src/threads.ts +++ b/packages/chat/src/threads.ts @@ -121,30 +121,43 @@ export interface ThreadStore { * creates a sibling sub-thread under that sub-thread's parent rather * than a third level (CL-5948). Never throws for depth. */ forkThread(input: ForkThreadInput): Promise; + /** + * Reads take the workbench the thread is addressed in — descriptors + * parse without mail, so ancestry-only callers pass no reader. The + * reader's `principalId` is needed only where the answer lives on the + * reader's own copies: delivery titles resolve off the frame carrying + * the delivery ref, and readings without it report a null title. + */ getThread( tenantId: string, + workbenchId: string, threadId: string, + principalId?: string, ): Promise; + /** Every thread with a frame in the reader's own copies, root first. */ listThreads( tenantId: string, workbenchId: string, + principalId: string, ): Promise; - assignMessage(input: AssignMessageInput): Promise; + /** The msg_ ids reading in a thread, off the reader's own copies. */ listMessageIds( tenantId: string, + workbenchId: string, threadId: string, + principalId: string, ): Promise; /** - * Every membership row this workbench has, as `messageId -> threadId`. - * A message with no row is absent rather than defaulted to the root - * thread: the "root feed by default" contract belongs to whoever - * reads a thread's messages (see the threads route in `./routes.ts`), - * so this stays a faithful report of what was actually written. + * Every message's thread in the reader's own copies, as `messageId -> + * threadId` — derived per frame, never defaulted: a root-feed frame + * reports the root descriptor explicitly. */ listThreadAssignments( tenantId: string, workbenchId: string, + principalId: string, ): Promise>; + /** The thread one message reads in, resolved tenant-wide. */ threadIdForMessage( tenantId: string, workbenchId: string, @@ -214,7 +227,7 @@ export async function mailAncestryOf( // two ancestors; the bound is the cap, never a guess about cycles. for (let depth = 0; depth < 2; depth++) { if (current === null) break; - const thread = await store.getThread(tenantId, current); + const thread = await store.getThread(tenantId, workbenchId, current); if (thread === undefined || thread.parentMessageId === null) break; ancestors.unshift(thread.parentMessageId); current = From d7c86540f35fd73ea53b17fa1b5641d15c2b1207 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 15 Sep 2026 17:27:55 -0700 Subject: [PATCH 04/12] CL-7594: WIP mailbox-first sends through postMailboxMessage --- packages/chat/src/chat-orchestrator.ts | 30 +- packages/chat/src/mail-headers.ts | 4 +- packages/chat/src/mailbox-fanout.ts | 27 +- packages/chat/src/room-messages-native.ts | 7 +- packages/chat/src/room-messages.ts | 23 +- packages/chat/src/threads-native.ts | 56 ++- packages/chat/src/threads.ts | 23 - packages/chat/src/turn-context.test.ts | 35 +- packages/chat/src/turn-context.ts | 28 +- packages/chat/src/turn-queue.ts | 2 + packages/chat/src/workbench-service.ts | 550 +++++++++++----------- 11 files changed, 412 insertions(+), 373 deletions(-) diff --git a/packages/chat/src/chat-orchestrator.ts b/packages/chat/src/chat-orchestrator.ts index 9a50f55e89..0db53a83d5 100644 --- a/packages/chat/src/chat-orchestrator.ts +++ b/packages/chat/src/chat-orchestrator.ts @@ -192,10 +192,7 @@ export type ChatOrchestratorDeps = { * threads keeps every post on the root feed exactly as before this * landed. */ - threads?: Pick< - ThreadStore, - "ensureRootThread" | "threadIdForMessage" | "assignMessage" - >; + threads?: Pick; /** * Durable dispatch-mail -> source-message correlation (CL-6314): what * lets a reply find the message that woke its turn, whether a human @@ -902,15 +899,6 @@ async function postReply( ...(outcome.status === "failed" ? { error: outcome.error } : {}), }); } - if (threadId !== undefined && deps.threads !== undefined) { - await deps.threads.assignMessage({ - tenantId: resolved.tenantId, - workbenchId, - threadId, - messageId: posted.id, - }); - } - // The delegation hop: when the host's reply @mentions other agent // teammates, they must receive it exactly as they would a human's // @mention — otherwise a handoff only reaches the human side of @@ -1067,14 +1055,6 @@ async function postApproveBlock( runId: localPartOf(resolved.roomAddress), ...(threadId !== undefined ? { threadId } : {}), }); - if (threadId !== undefined && deps.threads !== undefined) { - await deps.threads.assignMessage({ - tenantId: resolved.tenantId, - workbenchId, - threadId, - messageId: posted.id, - }); - } } } @@ -1138,14 +1118,6 @@ async function postFinalizedTurnArtifacts( runId: localPartOf(resolved.roomAddress), ...(threadId !== undefined ? { threadId } : {}), }); - if (threadId !== undefined && deps.threads !== undefined) { - await deps.threads.assignMessage({ - tenantId: resolved.tenantId, - workbenchId, - threadId, - messageId: posted.id, - }); - } } catch (error) { await deps.claims.release(claim); throw error; diff --git a/packages/chat/src/mail-headers.ts b/packages/chat/src/mail-headers.ts index c79ed59a86..71288d59f6 100644 --- a/packages/chat/src/mail-headers.ts +++ b/packages/chat/src/mail-headers.ts @@ -36,8 +36,8 @@ export function rowIdFromMailMessageId(header: string): string { /** * The threading headers for a row being dispatched as mail. `ancestors` - * is the row's parent chain, root first (see `mailAncestryOf` in - * `./threads.ts`); `References` is exactly that chain and `In-Reply-To` + * is the row's parent chain, root first (see `threadAncestryMessageIds` + * in `./threads-native.ts`); `References` is exactly that chain and `In-Reply-To` * is its tail, per RFC 5322. A root-feed row has neither. */ export function mailThreadHeaders(input: { diff --git a/packages/chat/src/mailbox-fanout.ts b/packages/chat/src/mailbox-fanout.ts index 88e9bca268..efd1e61b59 100644 --- a/packages/chat/src/mailbox-fanout.ts +++ b/packages/chat/src/mailbox-fanout.ts @@ -177,12 +177,25 @@ export type MailboxFanoutDeps = { export type WriteChatMailboxFanoutInput = { readonly tenantId: string; readonly workbenchId: string; + /** Local part of the sender's `From:` — an agent local part marks every + * copy inbound, since an agent's own inbox is its run's live mail queue, + * never this package's `@corbits/mailbox` mount. */ readonly senderAddress: string; - readonly senderPrincipalId: string; + /** The sender's principal when a human sends. Absent for agent and system + * sends, whose copies are all inbound. A human sender's own copy is the + * one outbound row; every other copy is inbound. */ + readonly senderPrincipalId?: string; readonly participants: readonly ParticipantRecord[]; /** This row's own RFC 5322 `Message-ID` (`mailMessageIdFor`), already * minted against the row's OWNING tenant's domain. */ readonly messageId: string; + /** + * Extra refs stored on every copy beside the workbench ref: the + * `chat-thread` descriptor id a reply belongs to, the `chat-delivery` + * descriptor id a delivery turn's mail carries. Root sends carry only the + * workbench ref. + */ + readonly extraRefs?: readonly MailboxRef[]; readonly inReplyTo?: string; /** The full ancestry chain, oldest first — see `MailboxBatchItem`'s own * doc comment. */ @@ -225,7 +238,12 @@ export async function writeChatMailboxFanout( const domain = await deps.resolveTenantDomain(input.tenantId); const candidateIds = new Set(humanPrincipalIds(input.participants)); - candidateIds.add(input.senderPrincipalId); + if (input.senderPrincipalId !== undefined) { + // A caller-supplied principal can be a system identity the participant + // list does not carry — the sender's own copy rides on this candidacy + // exactly like a legacy copied sender. + candidateIds.add(input.senderPrincipalId); + } const candidateList = [...candidateIds]; const known = await deps.resolveKnownPrincipalIds( @@ -233,7 +251,10 @@ export async function writeChatMailboxFanout( candidateList, ); - const refs: MailboxRef[] = [{ kind: "workbench", id: input.workbenchId }]; + const refs: MailboxRef[] = [ + { kind: "workbench", id: input.workbenchId }, + ...(input.extraRefs ?? []), + ]; const batch: MailboxBatchItem[] = []; for (const principalId of candidateList) { diff --git a/packages/chat/src/room-messages-native.ts b/packages/chat/src/room-messages-native.ts index 29d4591f9a..35676a5a75 100644 --- a/packages/chat/src/room-messages-native.ts +++ b/packages/chat/src/room-messages-native.ts @@ -146,7 +146,6 @@ export interface NativeTimelineDeps { readonly parts: MessagePartsStore; } - function textFallbackParts(text: string): Part[] { return [{ kind: "text", text }]; } @@ -260,9 +259,9 @@ export async function mapNativeFrames( /** The delivery ref that promotes a frame out of the thread its headers * would otherwise put it in — see `./threads-native.ts`. */ -export function deliveryRefOf(frame: Pick): - | { readonly id: string; readonly label?: string } - | undefined { +export function deliveryRefOf( + frame: Pick, +): { readonly id: string; readonly label?: string } | undefined { return frame.refs.find((ref) => ref.kind === "chat-delivery"); } diff --git a/packages/chat/src/room-messages.ts b/packages/chat/src/room-messages.ts index c4faf8df48..5f5ac004dd 100644 --- a/packages/chat/src/room-messages.ts +++ b/packages/chat/src/room-messages.ts @@ -62,6 +62,12 @@ export interface ListRoomMessagesInput { readonly tenantId: string; readonly workbenchId: string; readonly cursor?: string; + /** + * The reader, for stores that read its own mailbox copies (CL-7594): + * the native timeline pages the reader's frames. Legacy and in-memory + * stores ignore it. + */ + readonly principalId?: string; } export interface ListedRoomMessages { @@ -84,6 +90,11 @@ export interface RoomActivitySummary { export interface ListRoomActivityInput { readonly tenantId: string; + /** + * The reader, for stores that count its own mailbox copies (CL-7594). + * Legacy and in-memory stores ignore it. + */ + readonly principalId?: string; readonly workbenches: readonly { readonly workbenchId: string; /** The caller's own read cursor; absent means everything is unread. */ @@ -104,6 +115,8 @@ export interface RoomMessageStore { readonly tenantId: string; readonly workbenchId: string; readonly messageId: string; + /** The reader, when the caller has one — see `ListRoomMessagesInput`. */ + readonly principalId?: string; }): Promise; /** * Records the `Message-ID` a row went out as (CL-7104). The header is @@ -161,7 +174,7 @@ let mintsThisMillisecond = 0; * it never render in the wrong order. The random tail keeps ids * unguessable and separates two hubs minting in the same millisecond. */ -function newMessageId(): string { +export function newMessageId(): string { const now = Date.now(); if (now === lastMintedAt) { mintsThisMillisecond += 1; @@ -249,7 +262,7 @@ function needsPreviewLookback(parts: readonly Part[]): boolean { return isFailurePreviewParts(parts) || previewOf(parts).length === 0; } -function summaryOf( +export function summaryOf( newest: RoomMessage, unreadCount: number, newestFirstForPreview: readonly RoomMessage[] = [newest], @@ -271,7 +284,7 @@ function summaryOf( * `ChatMessageEventData` is the wire contract this shape is asserted * against before it ever reaches `publish`. */ -function consumerFacingParts(parts: readonly Part[]): Part[] { +export function consumerFacingParts(parts: readonly Part[]): Part[] { return parts.map((part) => { if (part.kind !== "text") return part; const text = consumerFacingInferenceText(part.text); @@ -368,7 +381,9 @@ function decodeCursor( return { createdAt, id: cursor.slice(separator + 1) }; } -function pageOf(newestFirst: readonly RoomMessage[]): ListedRoomMessages { +export function pageOf( + newestFirst: readonly RoomMessage[], +): ListedRoomMessages { const items = newestFirst.slice(0, PAGE_SIZE); const last = items[items.length - 1]; return newestFirst.length > PAGE_SIZE && last !== undefined diff --git a/packages/chat/src/threads-native.ts b/packages/chat/src/threads-native.ts index e4dd9563ed..f6f0d4a9fc 100644 --- a/packages/chat/src/threads-native.ts +++ b/packages/chat/src/threads-native.ts @@ -77,8 +77,13 @@ export function deliveryThreadId(runRef: string): string { /** Parses a descriptor back into its anchor. Undefined for anything that * was never a descriptor — notably the legacy `thr_` rows, which * read as unknown rather than guessing a thread. */ -export function parseThreadDescriptor(threadId: string): ThreadDescriptor | undefined { - if (threadId.startsWith(ROOT_PREFIX) && threadId.length > ROOT_PREFIX.length) { +export function parseThreadDescriptor( + threadId: string, +): ThreadDescriptor | undefined { + if ( + threadId.startsWith(ROOT_PREFIX) && + threadId.length > ROOT_PREFIX.length + ) { return { kind: "root", workbenchId: threadId.slice(ROOT_PREFIX.length) }; } if ( @@ -103,7 +108,11 @@ export function parseThreadDescriptor(threadId: string): ThreadDescriptor | unde const parentThreadId = rest.slice(0, anchorAt); const anchorMessageId = rest.slice(anchorAt + 1); const parent = parseThreadDescriptor(parentThreadId); - if (parent === undefined || parent.kind === "root" || parent.kind === "sub") { + if ( + parent === undefined || + parent.kind === "root" || + parent.kind === "sub" + ) { return undefined; } return { kind: "sub", parentThreadId, anchorMessageId }; @@ -202,9 +211,9 @@ export interface DescriptorThreadDeps { /** The parent msg_ a frame answers, when the parent is a chat frame at * all: anything not shaped `msg_*` (foreign mail, bracket ids) threads * under nothing. */ -export function parentMsgIdOf(frame: Pick): - | string - | undefined { +export function parentMsgIdOf( + frame: Pick, +): string | undefined { const parent = frame.inReplyTo; if (parent === undefined || parent === "") return undefined; const local = localPartOf( @@ -331,7 +340,10 @@ export function createDescriptorThreadStore( tenantId: string, workbenchId: string, messageId: string, - ): Promise<{ readonly threadId: string; readonly descriptor: ThreadDescriptor }> { + ): Promise<{ + readonly threadId: string; + readonly descriptor: ThreadDescriptor; + }> { const root: ThreadDescriptor = { kind: "root", workbenchId }; const found = await headersFor(tenantId, [messageId]); const header = found.get(messageId); @@ -377,7 +389,10 @@ export function createDescriptorThreadStore( function containerThread( tenantId: string, workbenchId: string, - container: { readonly threadId: string; readonly descriptor: ThreadDescriptor }, + container: { + readonly threadId: string; + readonly descriptor: ThreadDescriptor; + }, ): WorkbenchThread { switch (container.descriptor.kind) { case "root": @@ -417,13 +432,18 @@ export function createDescriptorThreadStore( return { async ensureRootThread(tenantId, workbenchId) { - return threadForDescriptor(tenantId, workbenchId, rootThreadId(workbenchId), { - parentMessageId: null, - parentThreadId: null, - runRef: null, - title: null, - createdAt: new Date(0), - }); + return threadForDescriptor( + tenantId, + workbenchId, + rootThreadId(workbenchId), + { + parentMessageId: null, + parentThreadId: null, + runRef: null, + title: null, + createdAt: new Date(0), + }, + ); }, async createDeliveryThread(input: CreateDeliveryThreadInput) { @@ -683,7 +703,11 @@ export function createDescriptorThreadStore( header.deliveryRef !== undefined || parentMsgIdOf(header) === undefined ) { - return deriveThreadId(header, new Map([[messageId, header]]), workbenchId); + return deriveThreadId( + header, + new Map([[messageId, header]]), + workbenchId, + ); } // Walk at most two ancestors up — the same bound the store's // container check uses, and exact for every send-time shape. diff --git a/packages/chat/src/threads.ts b/packages/chat/src/threads.ts index a5f9b1fc37..80689778c2 100644 --- a/packages/chat/src/threads.ts +++ b/packages/chat/src/threads.ts @@ -376,17 +376,6 @@ export function createInMemoryThreadStore(): ThreadStore { .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()); }, - async assignMessage(input) { - messageToThread.set( - messageKey(input.tenantId, input.workbenchId, input.messageId), - input.threadId, - ); - const list = threadMessages.get(input.threadId) ?? []; - if (!list.includes(input.messageId)) { - threadMessages.set(input.threadId, [...list, input.messageId]); - } - }, - async listMessageIds(tenantId, threadId) { const t = threads.get(threadId); if (t === undefined || t.tenantId !== tenantId) return []; @@ -705,18 +694,6 @@ export function createDrizzleThreadStore< return rows.map(mapThreadRow); }, - async assignMessage(input) { - await db - .insert(workbenchThreadMessages) - .values({ - tenantId: input.tenantId, - workbenchId: input.workbenchId, - threadId: input.threadId, - messageId: input.messageId, - }) - .onConflictDoNothing(); - }, - async listMessageIds(tenantId, threadId) { const rows = await db .select({ messageId: workbenchThreadMessages.messageId }) diff --git a/packages/chat/src/turn-context.test.ts b/packages/chat/src/turn-context.test.ts index 8cb1eb2414..24698678af 100644 --- a/packages/chat/src/turn-context.test.ts +++ b/packages/chat/src/turn-context.test.ts @@ -9,7 +9,9 @@ const PARTICIPANTS = [ { address: "ins_echo1@acme.example", handle: "echo" }, ] as const; -async function seed(texts: readonly { from: string; text: string }[]) { +async function seed( + texts: readonly { from: string; text: string; threadId?: string }[], +) { const roomMessages = createInMemoryRoomMessageStore(); const ids: string[] = []; for (const entry of texts) { @@ -19,6 +21,7 @@ async function seed(texts: readonly { from: string; text: string }[]) { workbenchId: WORKBENCH, sender: { name: null, address: entry.from }, parts: [{ kind: "text", text: entry.text }], + ...(entry.threadId !== undefined ? { threadId: entry.threadId } : {}), }); ids.push(posted.id); } @@ -90,15 +93,22 @@ describe("assembleTurnContext", () => { }); test("a thread-scoped turn sees its own thread, never the whole room", async () => { - const { roomMessages, ids } = await seed([ - { from: "prn_alice@acme.example", text: "root feed chatter" }, - { from: "prn_alice@acme.example", text: "in the thread" }, - { from: "ins_echo1@acme.example", text: "also in the thread" }, - ]); - const threadOf = new Map([ - [ids[0] ?? "", "thr_root"], - [ids[1] ?? "", "thr_reply"], - [ids[2] ?? "", "thr_reply"], + const { roomMessages } = await seed([ + { + from: "prn_alice@acme.example", + text: "root feed chatter", + threadId: "thr_root", + }, + { + from: "prn_alice@acme.example", + text: "in the thread", + threadId: "thr_reply", + }, + { + from: "ins_echo1@acme.example", + text: "also in the thread", + threadId: "thr_reply", + }, ]); const context = await assembleTurnContext({ @@ -108,10 +118,7 @@ describe("assembleTurnContext", () => { excludeMessageId: "msg_none", participants: PARTICIPANTS, contextWindow: 10, - thread: { - threadId: "thr_reply", - threadIdOf: (messageId) => threadOf.get(messageId) ?? "thr_root", - }, + thread: { threadId: "thr_reply" }, }); expect(context).toContain("in the thread"); diff --git a/packages/chat/src/turn-context.ts b/packages/chat/src/turn-context.ts index b64cb89080..470414274d 100644 --- a/packages/chat/src/turn-context.ts +++ b/packages/chat/src/turn-context.ts @@ -26,14 +26,14 @@ import { const contextLog = getLogger(["chat", "turn-context"]); /** - * Restricts a turn's context to one thread. Thread membership lives in - * its own store (`./threads.ts`), not on the message row, so the caller - * injects the resolver rather than this module reaching for a second - * store. + * Restricts a turn's context to one thread. Threads are descriptors, not + * rows (`./threads-native.ts`): every timeline row already carries the + * descriptor id it was sent into, so the scope is just that id and the + * context walk compares it against each row's own — no membership table, + * no resolver, no second store. */ export interface TurnContextThreadScope { readonly threadId: string; - threadIdOf(messageId: string): string; } /** @@ -81,6 +81,11 @@ export interface AssembleTurnContextInput { readonly roomMessages: Pick; readonly tenantId: string; readonly workbenchId: string; + /** + * The turn's agent, whose mailbox copy the context reads — a native + * timeline pages one principal's frames. Absent, reads run systematic. + */ + readonly principalId?: string; /** The message this turn is answering; never re-rendered as context. */ readonly excludeMessageId: string; readonly participants: readonly ParticipantRecord[]; @@ -114,18 +119,27 @@ export async function assembleTurnContext( const inScope = (message: RoomMessage): boolean => message.id !== input.excludeMessageId && (input.thread === undefined || - input.thread.threadIdOf(message.id) === input.thread.threadId); + message.threadId === input.thread.threadId); const newestFirst: RoomMessage[] = []; let cursor: string | undefined; do { const page = await input.roomMessages.listMessages( cursor === undefined - ? { tenantId: input.tenantId, workbenchId: input.workbenchId } + ? { + tenantId: input.tenantId, + workbenchId: input.workbenchId, + ...(input.principalId !== undefined + ? { principalId: input.principalId } + : {}), + } : { tenantId: input.tenantId, workbenchId: input.workbenchId, cursor, + ...(input.principalId !== undefined + ? { principalId: input.principalId } + : {}), }, ); newestFirst.push(...page.items.filter(inScope)); diff --git a/packages/chat/src/turn-queue.ts b/packages/chat/src/turn-queue.ts index c4d5ce439f..e6e6bea4f7 100644 --- a/packages/chat/src/turn-queue.ts +++ b/packages/chat/src/turn-queue.ts @@ -42,6 +42,8 @@ export type TurnQueuedEvent = typeof TurnQueuedEvent.infer; export type QueuedTurn = { readonly messageId: string; + /** The descriptor id the message was sent into; absent sends headerless. */ + readonly threadId?: string; readonly principalId: string; readonly recipients: readonly string[]; readonly parts: readonly PartType[]; diff --git a/packages/chat/src/workbench-service.ts b/packages/chat/src/workbench-service.ts index 43e3547a2d..2d405968b2 100644 --- a/packages/chat/src/workbench-service.ts +++ b/packages/chat/src/workbench-service.ts @@ -26,7 +26,6 @@ import { type TurnContextThreadScope, } from "./turn-context"; import type { AgentTurnStore } from "./agent-turns"; -import type { ThreadStore } from "./threads"; import { TurnCancelledError, type TurnCancelRegistry, @@ -56,21 +55,27 @@ import type { InvitableDefinition, } from "./platform-port"; import { - postRoomMessage, - insertRoomMessageRow, + consumerFacingParts, + newMessageId, publishRoomMessageEvent, type PublishedMailHeaders, + type RoomMessage, + type RoomMessageSender, type RoomMessageStore, } from "./room-messages"; import { mailMessageIdFor, mailThreadHeaders } from "./mail-headers"; -import { mailAncestryOf } from "./threads"; +import { + parseThreadDescriptor, + rootThreadId, + threadAncestryMessageIds, +} from "./threads-native"; +import type { MessagePartsStore } from "./message-parts"; import { writeChatMailboxFanout, mailboxBodyOf, mailboxSubjectOf, type MailboxFanoutDeps, } from "./mailbox-fanout"; -import type { TurnMailCorrelationStore } from "./turn-mail-correlation"; import type { WorkbenchSubscriberRegistry } from "./workbench-events"; import type { QueuedTurn, WorkbenchTurnQueue } from "./turn-queue"; import { CHAT_TURN_TIMEOUT_MS } from "./turn-claims"; @@ -192,7 +197,8 @@ export type MintAgentDmDeps = { | "mutateWorkbenchParticipants" >; readonly platform: LaunchAndJoinAgentDeps["platform"]; - readonly roomMessages: LaunchAndJoinAgentDeps["roomMessages"]; + readonly mailbox: LaunchAndJoinAgentDeps["mailbox"]; + readonly parts: LaunchAndJoinAgentDeps["parts"]; readonly publish: LaunchAndJoinAgentDeps["publish"]; }; @@ -420,7 +426,8 @@ export async function mintAgentDm( { store: deps.store, platform: deps.platform, - roomMessages: deps.roomMessages, + mailbox: deps.mailbox, + parts: deps.parts, publish: deps.publish, }, { @@ -494,7 +501,8 @@ async function reopenAgentDm( { store: deps.store, platform: deps.platform, - roomMessages: deps.roomMessages, + mailbox: deps.mailbox, + parts: deps.parts, publish: deps.publish, }, { @@ -518,8 +526,15 @@ async function reopenAgentDm( export type LaunchAndJoinAgentDeps = { readonly store: Pick; readonly platform: WorkbenchLauncher; - readonly roomMessages: RoomMessageStore; + /** + * The mailbox-first fanout for the timeline's `workbench.agent-joined` + * event: the join line lands in each human participant's inbox, no + * `workbench_messages` row. The hub always injects a real one. + */ + readonly mailbox: MailboxFanoutDeps; readonly publish: (workbenchId: string, event: ChatWorkbenchEvent) => void; + /** The `message_parts` sidecar the join event's rich parts land in. */ + readonly parts: MessagePartsStore; }; export type LaunchAndJoinAgentInput = { @@ -745,7 +760,7 @@ export async function launchAndJoinAgent( // truth, and this send may be the host's first traffic — the wake it // triggers deploys the host, which must never put deploy time back // on the caller's path. A delivery failure is logged, never thrown. - const joinEventDelivered = postRoomMessage(deps, { + const joinEventDelivered = postMailboxMessage(deps, { tenantId: input.tenantId, workbenchId: input.workbenchId, sender: { name: null, address: launched.address }, @@ -778,7 +793,8 @@ export async function launchAndJoinAgent( } export type PostCannedGreetingDeps = { - readonly roomMessages: RoomMessageStore; + readonly mailbox: MailboxFanoutDeps; + readonly parts: MessagePartsStore; readonly publish: WorkbenchSubscriberRegistry["publish"]; }; @@ -885,7 +901,7 @@ export async function postCannedGreeting( input: PostCannedGreetingInput, ): Promise { try { - await postRoomMessage(deps, { + await postMailboxMessage(deps, { tenantId: input.tenantId, workbenchId: input.workbenchId, sender: { name: null, address: input.agentAddress }, @@ -904,7 +920,8 @@ export async function postCannedGreeting( export type JoinHumanParticipantDeps = { readonly store: Pick; - readonly roomMessages: RoomMessageStore; + readonly mailbox: MailboxFanoutDeps; + readonly parts: MessagePartsStore; readonly publish: (workbenchId: string, event: ChatWorkbenchEvent) => void; readonly tenancy: Pick; }; @@ -988,7 +1005,7 @@ export async function joinHumanParticipant( // Not awaited, for the same reason `launchAndJoinAgent`'s own join // event isn't: the participant record above is the durable source of // truth, and this send can carry the host's deploy. - const joinEventDelivered = postRoomMessage(deps, { + const joinEventDelivered = postMailboxMessage(deps, { tenantId: input.tenantId, workbenchId: input.workbenchId, sender: { name: null, address: input.principalId }, @@ -1024,7 +1041,8 @@ export async function joinHumanParticipant( export type RemoveWorkbenchParticipantDeps = { readonly store: Pick; - readonly roomMessages: RoomMessageStore; + readonly mailbox: MailboxFanoutDeps; + readonly parts: MessagePartsStore; readonly publish: (workbenchId: string, event: ChatWorkbenchEvent) => void; /** * Releases an invited agent's launched instance the way the idle-sleep @@ -1099,7 +1117,7 @@ export async function removeWorkbenchParticipant( removedBy: input.principalId, }, }; - await postRoomMessage(deps, { + await postMailboxMessage(deps, { tenantId: input.tenantId, workbenchId: input.workbenchId, sender: { name: null, address: input.principalId }, @@ -1151,7 +1169,8 @@ export type StartWorkflowCommandDeps = { "getWorkbenchSettings" | "mutateWorkbenchParticipants" >; readonly platform: WorkbenchLauncher & Pick; - readonly roomMessages: RoomMessageStore; + readonly mailbox: LaunchAndJoinAgentDeps["mailbox"]; + readonly parts: LaunchAndJoinAgentDeps["parts"]; readonly publish: (workbenchId: string, event: ChatWorkbenchEvent) => void; }; @@ -1221,7 +1240,8 @@ export async function startWorkflowCommand( { store: deps.store, platform: deps.platform, - roomMessages: deps.roomMessages, + mailbox: deps.mailbox, + parts: deps.parts, publish: deps.publish, }, { @@ -1249,7 +1269,6 @@ export async function startWorkflowCommand( export type SendWorkbenchMessageDeps = { readonly store: Pick; - readonly roomMessages: RoomMessageStore; readonly publish: WorkbenchSubscriberRegistry["publish"]; /** Dispatch only: reaching an agent's own mailbox to ask it for a * turn. Nothing on the human write path touches it. */ @@ -1276,41 +1295,33 @@ export type SendWorkbenchMessageDeps = { */ readonly turnCancellation: TurnCancelRegistry; /** - * Durable dispatch-mail -> source-message correlation (CL-6314), what - * `dispatchTurn` records after its send resolves so the reply path can - * land the agent's answer in its source message's thread. Optional so - * unit suites that only exercise routing stay free of the table; a - * composition that wants threaded replies (the hub) injects a real - * store. Absent, dispatches still send — their replies just post - * unthreaded. + * Timeline reads for routing and context: the hub injects the native + * store (`./room-messages-native.ts`), so every read here pages the + * reader's own mailbox copies plus the `message_parts` sidecar. Writes + * are gone — sends are mailbox-first (`postMailboxMessage`), so the + * store's `insertMessage`/`stampMailMessageId`/`deleteMessage` throw. */ - readonly turnMailCorrelation?: TurnMailCorrelationStore; + readonly roomMessages: RoomMessageStore; /** - * The turn projection (CL-6329). `dispatchTurn` opens a row before it - * touches the execution plane, so an in-flight turn is visible from - * its first moment and the child run id its reply will carry is - * already allocated. Optional so unit suites that only exercise - * routing stay free of the table; a composition that wants traceable - * replies (the hub) injects a real store. + * The turn projection (`./agent-turns.ts`): `dispatchTurn` opens a row + * before it touches the execution plane so an in-flight turn is visible + * from its first moment, and `cancelWorkbenchTurn` settles running rows + * directly. The table survives the cutover — it keys on the run and the + * surviving msg_ ids, never on a dropped tracking row. Optional so unit + * suites that only exercise routing stay free of the table; absent, + * dispatches still send and cancels only reach the call stack. */ readonly agentTurns?: AgentTurnStore; /** - * Narrows a turn's context to its own thread. Absent, a turn is asked - * with the whole room. See `./turn-context.ts`. - */ - readonly threads?: Pick< - ThreadStore, - "listThreadAssignments" | "getThread" | "threadIdForMessage" - >; - /** - * The mail domain and fan-out target for CL-7450's mailbox copy: - * writing a sent human message into every human participant's - * `@corbits/mailbox` inbox, addressed by this row's own RFC 5322 - * Message-ID (`./mail-headers.ts`). Optional so unit suites that only - * exercise routing stay free of the mailbox tables; the hub always - * injects a real one (`platform-adapter.ts`'s composition). + * The mail domain and fan-out target for the mailbox-first send: every + * sent message lands in each human participant's `@corbits/mailbox` + * inbox, addressed by its own RFC 5322 Message-ID (`./mail-headers.ts`). + * Required — the mailbox batch IS the send's durable write. The hub + * always injects a real one (`platform-adapter.ts`'s composition). */ - readonly mailbox?: MailboxFanoutDeps; + readonly mailbox: MailboxFanoutDeps; + /** The `message_parts` sidecar every send records its rich parts into. */ + readonly parts: MessagePartsStore; /** * The turn-level deadline (CL-6644): `dispatchTurnBatch` wraps every * recipient's `dispatchTurn` call in this single wall-clock budget, @@ -1412,11 +1423,12 @@ export type SendWorkbenchMessageInput = { */ readonly inReplyToMessageId?: string; /** - * Thread membership stamped onto the published `chat.message` so - * stream subscribers see the same scope the POST response returns - * (CL-6660). Assignment into `workbench_thread_messages` still happens - * in the route after the insert — this field only fills the row and - * the SSE payload. + * The descriptor id this send belongs to (`./threads-native.ts`) — a + * reply's `thr_reply_`, a sub-thread's `thr_sub_…`, a delivery + * turn's `thr_delivery_`, absent for the root feed. Stamped onto + * the row and the SSE payload so stream subscribers see the same scope + * the POST response returns (CL-6660), and carried into the turn queue + * so the dispatch's threading headers derive from it structurally. */ readonly threadId?: string; /** @@ -1483,76 +1495,59 @@ async function replyTargetAgent( } /** - * Posts a message into a workbench and routes it to every agent its - * mentions, reply target, and host-default resolve to. The message - * itself is one row plus one publish — no mail, no wake, no sidecar hop - * — so a workbench with every agent process stopped still takes - * messages and still renders them. + * Posts a message, mailbox-first (CL-7594): the message IS its mailbox + * fan-out — one batch into every human participant's `@corbits/mailbox` + * inbox, addressed by its own RFC 5322 Message-ID — with the rich parts + * in the `message_parts` sidecar and the `chat.message` publish last, + * before any agent is dispatched: fan-out (one batch) -> sidecar -> + * publish -> dispatch. A fan-out failure fails the send before anything + * is VISIBLE: no phantom bubble on the sender's own timeline (no + * `chat.message` publish yet), and no duplicate row on a client retry — + * there is no row to delete, because the mailbox batch is the first + * durable write. Unlike agent dispatch (`routeMessage`, fire-and-forget + * so a slept agent's wake never blocks the sender's own bubble), a + * mailbox write failure is never swallowed into an + * apparently-successful send: it surfaces as `MailboxFanoutFailedError` + * (`./mailbox-fanout.ts`), which already carries the one `refId` + * `writeChatMailboxFanout` reported under — the route layer quotes that + * ref rather than reporting again. * - * Routing never branches on the workbench's `kind` (a chat and a - * workbench are routed identically): an `@mention` always reaches its - * agent; a plain reply to an agent's message reaches that agent too, - * even unmentioned; and a message naming no agent at all (no mention, - * no agent reply target) defaults to the workbench's host — its first - * agent participant — so a single-agent workbench still auto-responds - * and a multi-agent one routes through its host instead of going - * silent. - */ -/** - * Posts a message, then — CL-7450 — writes it into every human - * participant's mailbox BEFORE the row is published or any agent is - * dispatched: store row -> stamp Message-ID -> fan-out (one batch) -> - * publish -> dispatch. A fan-out failure must fail the send before - * anything is VISIBLE: no phantom bubble on the sender's own timeline - * (no `chat.message` publish yet), and no duplicate row on a client - * retry. The just-inserted row is therefore deleted on a fan-out failure - * and the failure is rethrown to the caller — unlike agent dispatch - * (`routeMessage`, fire-and-forget so a slept agent's wake never blocks - * the sender's own bubble), a mailbox write failure is never swallowed - * into an apparently-successful send. + * The Message-ID is minted with the message's OWNING tenant's domain + * (`input.tenantId`, always `ownerTenantId` at the route layer — see + * `routes.ts`'s `resolveWorkbenchAccess`), never the acting caller's own + * tenant: a shared-workbench (projected-tenant) sender's + * `input.senderAddress` carries their OWN tenant's domain, which would + * otherwise stamp a message living in the owner tenant with a Message-ID + * nobody else's mail agrees is addressed under. * - * This is not a transactional guarantee: the row and the mailbox batch - * are two separate writes (`insertRoomMessageRow` commits before - * `mailboxFanOutForSend` ever runs), so a concurrent `GET` of the - * timeline between the two CAN read the row before this function decides - * whether to delete it again. The window is real, not merely believed - * closed — it is bounded to the time between those two writes, not open - * indefinitely, and nothing durable is built on top of what that window - * exposes (no reply has been dispatched, no mailbox row written) before - * the delete either lands or the row survives for good. A fan-out - * failure surfaces as `MailboxFanoutFailedError` (`./mailbox-fanout.ts`), - * which already carries the one `refId` `writeChatMailboxFanout` reported - * under — the route layer quotes that ref rather than reporting again. + * Threading rides the descriptor id, never a tracking row: a reply + * stamps the `chat-thread` descriptor ref its ancestry derives from (and + * answers it over mail with `In-Reply-To`/`References` off that same + * ancestry), a delivery turn's mail stamps the `chat-delivery` + * descriptor ref, and a root-feed send carries only the workbench ref. An + * unknown (legacy) thread id sends headerless — a root-feed send, never + * a guess. */ export async function sendWorkbenchMessage( deps: SendWorkbenchMessageDeps, input: SendWorkbenchMessageInput, ): Promise { - const posted = await insertRoomMessageRow(deps, { - tenantId: input.tenantId, - workbenchId: input.workbenchId, - sender: { name: null, address: input.senderAddress }, - senderPrincipalId: input.principalId, - parts: input.messageParts, - ...(input.threadId !== undefined ? { threadId: input.threadId } : {}), - }); - - const mailboxDeps = deps.mailbox; - let mail: PublishedMailHeaders | undefined; - if (mailboxDeps !== undefined) { - try { - mail = await mailboxFanOutForSend(deps, mailboxDeps, input, posted.id); - } catch (err) { - await deps.roomMessages.deleteMessage({ - tenantId: input.tenantId, - workbenchId: input.workbenchId, - messageId: posted.id, - }); - throw err; - } - } - - publishRoomMessageEvent(deps, posted, mail); + const posted = await postMailboxMessage( + { + store: deps.store, + mailbox: deps.mailbox, + parts: deps.parts, + publish: deps.publish, + }, + { + tenantId: input.tenantId, + workbenchId: input.workbenchId, + sender: { name: null, address: input.senderAddress }, + senderPrincipalId: input.principalId, + parts: input.messageParts, + ...(input.threadId !== undefined ? { threadId: input.threadId } : {}), + }, + ); return { id: posted.id, @@ -1561,86 +1556,133 @@ export async function sendWorkbenchMessage( }; } +export type PostMailboxMessageDeps = { + readonly store: Pick; + readonly mailbox: MailboxFanoutDeps; + readonly parts: MessagePartsStore; + readonly publish: WorkbenchSubscriberRegistry["publish"]; +}; + +export type PostMailboxMessageInput = { + readonly tenantId: string; + readonly workbenchId: string; + readonly sender: RoomMessageSender; + /** The sender's principal when a human sends; absent for agent and + * system sends, whose copies are all inbound. */ + readonly senderPrincipalId?: string; + readonly parts: readonly PartType[]; + /** The agent run this message came out of; absent for a human's. */ + readonly runId?: string; + /** + * The descriptor id this send belongs to. A reply or sub-thread send + * stamps the `chat-thread` ref; a delivery turn's send stamps the + * `chat-delivery` ref; absent (or the root descriptor) sends to the + * root feed with only the workbench ref. + */ + readonly threadId?: string; + /** Skip the settings load when the caller already holds them. */ + readonly participants?: readonly ParticipantRecord[]; +}; + +export type PostMailboxMessageResult = PublishedMailHeaders & { + readonly id: string; + readonly createdAt: string; +}; + /** - * CL-7450: the human write path's own copy — every human participant's - * `@corbits/mailbox` inbox gets this row, addressed by its own RFC 5322 - * Message-ID, before this call returns. Unlike agent dispatch - * (`routeMessage`, fire-and-forget so a slept agent's wake never blocks - * the sender's own bubble), a mailbox write failure must reach the - * caller: `writeChatMailboxFanout` throws on anything but a genuinely - * unknown participant, and this function does not catch it (its own - * caller, `sendWorkbenchMessage`, does — to delete the just-inserted row). - * - * The Message-ID (and any `In-Reply-To` it carries) is minted with the - * row's OWNING tenant's domain (`input.tenantId`, always `ownerTenantId` - * at the route layer — see `routes.ts`'s `resolveWorkbenchAccess`), never - * the acting caller's own tenant: a shared-workbench (projected-tenant) - * sender's `input.senderAddress` carries their OWN tenant's domain, which - * would otherwise stamp a row living in the owner tenant with a - * Message-ID nobody else's mail agrees is addressed under. - * - * Returns the stamped mail identity — the row's own Message-ID plus the - * threading headers when the row answers a thread — so the send's own - * `chat.message` publish names the same thread the mail went out as. + * The one way a message enters a workbench (CL-7594): the message IS its + * mailbox fan-out — one batch into every human participant's + * `@corbits/mailbox` inbox — with the rich parts in the `message_parts` + * sidecar and the `chat.message` publish last. Every send below, human or + * agent or system, funnels through here; the only differences are the + * sender identity and the refs the send carries. */ -async function mailboxFanOutForSend( - deps: SendWorkbenchMessageDeps, - mailboxDeps: MailboxFanoutDeps, - input: SendWorkbenchMessageInput, - messageId: string, -): Promise { - const domain = await mailboxDeps.resolveTenantDomain(input.tenantId); +export async function postMailboxMessage( + deps: PostMailboxMessageDeps, + input: PostMailboxMessageInput, +): Promise { + const messageId = newMessageId(); + const createdAt = new Date().toISOString(); + const parts = consumerFacingParts(input.parts); + const domain = await deps.mailbox.resolveTenantDomain(input.tenantId); const mailMessageId = mailMessageIdFor(messageId, domain); - await deps.roomMessages.stampMailMessageId({ - tenantId: input.tenantId, - workbenchId: input.workbenchId, - messageId, - mailMessageId, - }); - - const settingsRow = await deps.store.getWorkbenchSettings( - input.tenantId, - input.workbenchId, + const ancestors = threadAncestryMessageIds( + input.threadId ?? null, + (ancestor) => mailMessageIdFor(ancestor, domain), ); - const participants = - settingsRow !== undefined ? participantsOf(settingsRow.settings) : []; + const inReplyTo = + ancestors.length > 0 ? ancestors[ancestors.length - 1] : undefined; + const references = ancestors.length > 0 ? ancestors : undefined; - const ancestors = - deps.threads !== undefined - ? await mailAncestryOf( - deps.threads, + const settingsRow = + input.participants !== undefined + ? undefined + : await deps.store.getWorkbenchSettings( input.tenantId, input.workbenchId, - input.threadId ?? null, - ) - : []; - const inReplyTo = - ancestors.length > 0 - ? mailMessageIdFor(ancestors[ancestors.length - 1] as string, domain) + ); + const participants = + input.participants ?? + (settingsRow !== undefined ? participantsOf(settingsRow.settings) : []); + + const descriptor = + input.threadId !== undefined + ? parseThreadDescriptor(input.threadId) : undefined; - const references = ancestors.map((ancestor) => - mailMessageIdFor(ancestor, domain), - ); + const threadRef = + descriptor !== undefined && + (descriptor.kind === "reply" || descriptor.kind === "sub") + ? [{ kind: "chat-thread" as const, id: input.threadId as string }] + : []; + const deliveryRef = + descriptor !== undefined && descriptor.kind === "delivery" + ? [{ kind: "chat-delivery" as const, id: input.threadId as string }] + : []; - const body = mailboxBodyOf(input.messageParts); - await writeChatMailboxFanout(mailboxDeps, { + const body = mailboxBodyOf(parts); + await writeChatMailboxFanout(deps.mailbox, { tenantId: input.tenantId, workbenchId: input.workbenchId, - senderAddress: input.senderAddress, - senderPrincipalId: input.principalId, + senderAddress: input.sender.address, + ...(input.senderPrincipalId !== undefined + ? { senderPrincipalId: input.senderPrincipalId } + : {}), participants, messageId: mailMessageId, subject: mailboxSubjectOf(body), body, + ...([...threadRef, ...deliveryRef].length > 0 + ? { extraRefs: [...threadRef, ...deliveryRef] } + : {}), ...(inReplyTo !== undefined ? { inReplyTo } : {}), - ...(references.length > 0 ? { references } : {}), + ...(references !== undefined ? { references } : {}), + }); + await deps.parts.recordMessageParts({ + tenantId: input.tenantId, + mailMessageId, + workbenchId: input.workbenchId, + parts, }); - return { + const posted: RoomMessage = { + id: messageId, + workbenchId: input.workbenchId, + createdAt, + sender: input.sender, + senderPrincipalId: input.senderPrincipalId ?? null, + runId: input.runId ?? null, + threadId: input.threadId ?? rootThreadId(input.workbenchId), + mailMessageId, + parts, + }; + const mail: PublishedMailHeaders = { messageId: mailMessageId, ...(inReplyTo !== undefined ? { inReplyTo } : {}), - ...(references.length > 0 ? { references } : {}), + ...(references !== undefined ? { references } : {}), }; + publishRoomMessageEvent({ publish: deps.publish }, posted, mail); + + return { id: messageId, createdAt, ...mail }; } /** @@ -1677,30 +1719,18 @@ async function routeMessage( /** * The thread a turn's context is confined to (CL-6329): a message inside * a sub-thread is answered with that sub-thread, never the whole room. - * Returns nothing at all when the workbench has no thread store or the - * triggering message carries no membership row — the room itself is the - * scope then, which is exactly `assembleTurnContext`'s no-thread case. - * - * Membership is read once, in bulk, so the resolver `assembleTurnContext` - * calls per message stays synchronous rather than fanning a query out - * per timeline row. + * The scope is the triggering send's own descriptor id — the id + * `postMailboxMessage` stamped on its row — defaulting to the root feed + * for a send with no thread, exactly the membership the old thread table + * used to record. Native rows carry their own descriptor, so + * `assembleTurnContext` compares it directly; no store read happens here. */ -async function turnThreadScope( - deps: Pick, - input: Pick, - messageId: string, -): Promise<{ thread?: TurnContextThreadScope }> { - if (deps.threads === undefined) return {}; - const assignments = await deps.threads.listThreadAssignments( - input.tenantId, - input.workbenchId, - ); - const threadId = assignments.get(messageId); - if (threadId === undefined) return {}; +function turnThreadScope( + input: Pick, +): { thread: TurnContextThreadScope } { return { thread: { - threadId, - threadIdOf: (id) => assignments.get(id) ?? "", + threadId: input.threadId ?? rootThreadId(input.workbenchId), }, }; } @@ -1770,6 +1800,10 @@ async function routeToRecipients( roomMessages: deps.roomMessages, tenantId: input.tenantId, workbenchId: input.workbenchId, + // The context reads the sender's own copies: every send lands + // one copy per human participant, so the sender's mailbox holds + // the whole timeline including its own just-sent message. + principalId: input.principalId, excludeMessageId: messageId, participants, contextWindow: resolveContextWindow( @@ -1779,7 +1813,7 @@ async function routeToRecipients( {}, ), ).value, - ...(await turnThreadScope(deps, input, messageId)), + ...turnThreadScope(input), }) : undefined; const turnParts = @@ -1791,6 +1825,7 @@ async function routeToRecipients( input.workbenchId, { messageId, + ...(input.threadId !== undefined ? { threadId: input.threadId } : {}), principalId: input.principalId, recipients, parts: turnParts, @@ -1815,15 +1850,14 @@ async function dispatchTurnBatch( deps: Pick< SendWorkbenchMessageDeps, | "platform" - | "roomMessages" - | "publish" - | "turnDispatchTimeoutMs" - | "waitUntilFreeTimeoutMs" | "agentTurns" | "turnCancellation" - | "turnMailCorrelation" + | "turnDispatchTimeoutMs" + | "waitUntilFreeTimeoutMs" | "mailbox" - | "threads" + | "store" + | "parts" + | "publish" >, tenantId: string, workbenchId: string, @@ -1928,6 +1962,9 @@ async function dispatchTurnBatch( agentAddress, parts, requestMessageIds: messageIds, + ...(last.threadId !== undefined + ? { threadId: last.threadId } + : {}), }, signal, ), @@ -1992,6 +2029,12 @@ export type DispatchTurnInput = { readonly parts: PartType[]; /** The room messages this turn answers, in arrival order. */ readonly requestMessageIds: readonly string[]; + /** + * The descriptor id the answered messages were sent into. The dispatch + * frame's threading headers derive from it structurally — never from a + * membership lookup. Absent dispatches headerless. + */ + readonly threadId?: string; }; /** @@ -2039,13 +2082,7 @@ export type DispatchTurnInput = { export async function dispatchTurn( deps: Pick< SendWorkbenchMessageDeps, - | "platform" - | "agentTurns" - | "roomMessages" - | "publish" - | "turnMailCorrelation" - | "mailbox" - | "threads" + "platform" | "agentTurns" | "mailbox" | "store" | "parts" | "publish" >, input: DispatchTurnInput, signal?: AbortSignal, @@ -2101,43 +2138,26 @@ export async function dispatchTurn( signal?.addEventListener("abort", closeAsTimedOut, { once: true }); try { - // RFC 5322 threading (CL-7450): the dispatched frame carries the same - // identity the row it answers already carries on the timeline — its - // Message-ID is derived, never minted separately (`mailMessageIdFor`), - // so it always equals what `mailboxFanOutForSend` already stamped for - // that row — and names the row's own parent chain in - // `In-Reply-To`/`References`. Degrades to unthreaded (as before) when - // this composition has no mailbox domain to derive it from. + // RFC 5322 threading, derived structurally: the answered messages' + // descriptor id already encodes the ancestry (`threads-native.ts`), + // so `In-Reply-To`/`References` frame that chain for the tenant's + // domain — no membership lookup, no separate ancestry walk. Degrades + // to unthreaded (as before) when the turn answers nothing at all. const sourceMessageId = input.requestMessageIds[input.requestMessageIds.length - 1]; - const mailboxDeps = deps.mailbox; const threadHeaders = - mailboxDeps !== undefined && sourceMessageId !== undefined + sourceMessageId !== undefined ? await (async () => { - const domain = await mailboxDeps.resolveTenantDomain( + const domain = await deps.mailbox.resolveTenantDomain( input.tenantId, ); - const threadId = - deps.threads !== undefined - ? ((await deps.threads.threadIdForMessage( - input.tenantId, - input.workbenchId, - sourceMessageId, - )) ?? null) - : null; - const ancestors = - deps.threads !== undefined - ? await mailAncestryOf( - deps.threads, - input.tenantId, - input.workbenchId, - threadId, - ) - : []; return mailThreadHeaders({ rowId: sourceMessageId, domain, - ancestors, + ancestors: threadAncestryMessageIds( + input.threadId ?? null, + (rowId) => rowId, + ), }); })() : undefined; @@ -2152,34 +2172,11 @@ export async function dispatchTurn( }), fromWorkbenchId: input.workbenchId, }); - // The reply path threads under the turn that produced it (CL-6314), - // and this mail is what opens that turn's bracket — so record which - // message it answers while both halves are in hand. The latest - // message, matching how a batch already attributes its principal: - // the conversation is where its newest message is. A record that - // fails is reported, never thrown: the turn was dispatched, and - // threading degrades to unthreaded rather than failing it. - if ( - sourceMessageId !== undefined && - deps.turnMailCorrelation !== undefined - ) { - try { - await deps.turnMailCorrelation.recordTurnMail({ - tenantId: input.tenantId, - mailId: sourceMessageId, - workbenchId: input.workbenchId, - sourceMessageId, - }); - } catch (err) { - reportError(err, { - operation: "chat.dispatchTurn.recordTurnMail", - tenantId: input.tenantId, - roomId: input.workbenchId, - agentId: input.agentAddress, - extra: { mailId: sourceMessageId, sourceMessageId }, - }); - } - } + // The reply path threads under the turn structurally (CL-6314's + // bracket is the dispatch frame's own `In-Reply-To`/`References`, + // derived above): the reply answers that frame over mail, and the + // descriptor derivation maps it into the source message's thread + // with no correlation row to record. } catch (err) { if (turn !== undefined) { await deps.agentTurns?.finishTurn({ @@ -2244,7 +2241,10 @@ function isCredentialDispatchFailure(cause: unknown): boolean { * and the error is already logged by the caller. */ async function postUndeliveredNotice( - deps: Pick, + deps: Pick< + SendWorkbenchMessageDeps, + "store" | "mailbox" | "parts" | "publish" + >, input: { readonly tenantId: string; readonly workbenchId: string; @@ -2263,7 +2263,7 @@ async function postUndeliveredNotice( : isCredentialDispatchFailure(input.cause) ? CREDENTIAL_UNDELIVERED_NOTICE : RETRYABLE_UNDELIVERED_NOTICE; - await postRoomMessage(deps, { + await postMailboxMessage(deps, { tenantId: input.tenantId, workbenchId: input.workbenchId, sender: { name: null, address: input.agentAddress }, @@ -2303,7 +2303,10 @@ const CANCELLED_NOTICE = "This turn was cancelled."; * there is nowhere left to say so. */ async function postCancelledNotice( - deps: Pick, + deps: Pick< + SendWorkbenchMessageDeps, + "store" | "mailbox" | "parts" | "publish" + >, input: { readonly tenantId: string; readonly workbenchId: string; @@ -2311,7 +2314,7 @@ async function postCancelledNotice( }, ): Promise { try { - await postRoomMessage(deps, { + await postMailboxMessage(deps, { tenantId: input.tenantId, workbenchId: input.workbenchId, sender: { name: null, address: input.agentAddress }, @@ -2381,7 +2384,12 @@ export type CancelWorkbenchTurnResult = { export async function cancelWorkbenchTurn( deps: Pick< SendWorkbenchMessageDeps, - "agentTurns" | "turnCancellation" | "roomMessages" | "publish" + | "agentTurns" + | "turnCancellation" + | "store" + | "mailbox" + | "parts" + | "publish" >, input: { readonly tenantId: string; readonly workbenchId: string }, ): Promise { From d0000b3537752ea4163966d857e48d5104d28492 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 15 Sep 2026 17:28:42 -0700 Subject: [PATCH 05/12] CL-7594: prettier pass over sidecar and native-timeline files --- .../chat/src/room-messages-native.test.ts | 8 ++--- packages/chat/src/schema.ts | 5 +-- packages/chat/src/threads-native.test.ts | 31 ++++++++++++------- .../chat/test/message-parts.drizzle.test.ts | 10 +++--- 4 files changed, 28 insertions(+), 26 deletions(-) diff --git a/packages/chat/src/room-messages-native.test.ts b/packages/chat/src/room-messages-native.test.ts index a003e2f4f7..a4daea56e8 100644 --- a/packages/chat/src/room-messages-native.test.ts +++ b/packages/chat/src/room-messages-native.test.ts @@ -44,9 +44,7 @@ function depsFor(frames: readonly NativeTimelineFrame[]): NativeTimelineDeps { frames.find((entry) => entry.messageId === mailMessageId), findFrames: async (_tenantId, messageIds) => frames.filter((entry) => - messageIds.includes( - entry.messageId.replace(/^<(.+?)@.+>$/, "$1"), - ), + messageIds.includes(entry.messageId.replace(/^<(.+?)@.+>$/, "$1")), ), countFramesSince: async () => frames.length, resolveRunIds: async () => new Map(), @@ -100,7 +98,9 @@ describe("createNativeRoomMessageStore", () => { workbenchId: BENCH, principalId: READER, }); - expect(page.items[0]?.parts).toEqual([{ kind: "text", text: "plain body" }]); + expect(page.items[0]?.parts).toEqual([ + { kind: "text", text: "plain body" }, + ]); expect(page.items[1]?.parts).toEqual([]); }); diff --git a/packages/chat/src/schema.ts b/packages/chat/src/schema.ts index 304e2a5189..4c4e4ca569 100644 --- a/packages/chat/src/schema.ts +++ b/packages/chat/src/schema.ts @@ -566,10 +566,7 @@ export const messageParts = chatSchema.table( primaryKey({ columns: [table.tenantId, table.mailMessageId], }), - index("message_parts_workbench_idx").on( - table.tenantId, - table.workbenchId, - ), + index("message_parts_workbench_idx").on(table.tenantId, table.workbenchId), ], ); diff --git a/packages/chat/src/threads-native.test.ts b/packages/chat/src/threads-native.test.ts index 32f486bae8..661436bc90 100644 --- a/packages/chat/src/threads-native.test.ts +++ b/packages/chat/src/threads-native.test.ts @@ -115,7 +115,10 @@ describe("ancestryOfDescriptor", () => { it("frames ancestry as Message-IDs for dispatches", () => { expect( - threadAncestryMessageIds(subThreadId(replyThreadId("msg_a"), "msg_b"), (rowId) => `<${rowId}@mail.test>`), + threadAncestryMessageIds( + subThreadId(replyThreadId("msg_a"), "msg_b"), + (rowId) => `<${rowId}@mail.test>`, + ), ).toEqual(["", ""]); expect(threadAncestryMessageIds(null, (rowId) => rowId)).toEqual([]); }); @@ -182,9 +185,9 @@ describe("deriveThreadId", () => { rootThreadId(BENCH), ); const bracket = header("msg_g", { inReplyTo: "" }); - expect( - deriveThreadId(bracket, new Map([["msg_g", bracket]]), BENCH), - ).toBe(rootThreadId(BENCH)); + expect(deriveThreadId(bracket, new Map([["msg_g", bracket]]), BENCH)).toBe( + rootThreadId(BENCH), + ); }); it("derives a whole scan consistently", () => { @@ -237,9 +240,7 @@ describe("createDescriptorThreadStore", () => { workbenchId: BENCH, parentMessageId: "msg_c", }); - expect(forked.id).toBe( - subThreadId(replyThreadId("msg_a"), "msg_c"), - ); + expect(forked.id).toBe(subThreadId(replyThreadId("msg_a"), "msg_c")); }); it("resolves delivery titles off the reader's copies", async () => { @@ -266,18 +267,24 @@ describe("createDescriptorThreadStore", () => { const store = createDescriptorThreadStore(depsFor([a, b, delivered])); const threads = await store.listThreads(TENANT, BENCH, "u_1"); expect(threads.map((entry) => entry.id).sort()).toEqual( - [rootThreadId(BENCH), replyThreadId("msg_a"), deliveryThreadId("run_7")].sort(), + [ + rootThreadId(BENCH), + replyThreadId("msg_a"), + deliveryThreadId("run_7"), + ].sort(), ); const assignments = await store.listThreadAssignments(TENANT, BENCH, "u_1"); expect(assignments.get("msg_a")).toBe(rootThreadId(BENCH)); expect(assignments.get("msg_b")).toBe(replyThreadId("msg_a")); - expect(await store.listMessageIds(TENANT, BENCH, replyThreadId("msg_a"), "u_1")).toEqual([ - "msg_b", - ]); + expect( + await store.listMessageIds(TENANT, BENCH, replyThreadId("msg_a"), "u_1"), + ).toEqual(["msg_b"]); expect(await store.threadIdForMessage(TENANT, BENCH, "msg_b")).toBe( replyThreadId("msg_a"), ); - expect(await store.threadIdForMessage(TENANT, BENCH, "msg_missing")).toBeUndefined(); + expect( + await store.threadIdForMessage(TENANT, BENCH, "msg_missing"), + ).toBeUndefined(); }); it("creates delivery descriptors purely", async () => { diff --git a/packages/chat/test/message-parts.drizzle.test.ts b/packages/chat/test/message-parts.drizzle.test.ts index 7a0673a51b..d44777785c 100644 --- a/packages/chat/test/message-parts.drizzle.test.ts +++ b/packages/chat/test/message-parts.drizzle.test.ts @@ -46,9 +46,9 @@ describe("createInMemoryMessagePartsStore", () => { workbenchId: "run_1", parts: [textPart()], }); - expect( - await store.readMessageParts("ten_1", ""), - ).toEqual([TEXT_PART]); + expect(await store.readMessageParts("ten_1", "")).toEqual([ + TEXT_PART, + ]); }); test("a frame with no row reads null", async () => { @@ -142,9 +142,7 @@ describeIfDb("createDrizzleMessagePartsStore", () => { TEXT_PART, BLOCK_PART, ]); - expect( - await store.readMessageParts("ten_1", ""), - ).toBeNull(); + expect(await store.readMessageParts("ten_1", "")).toBeNull(); const rows = await store.listMessagePartsForFrames("ten_1", [ "", ]); From 0af60b42b49e3f668e5b2650452e51458bce80d8 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 15 Sep 2026 17:29:48 -0700 Subject: [PATCH 06/12] CL-7594: drop posted bindings left unused by assignMessage removal --- packages/chat/src/chat-orchestrator.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/chat/src/chat-orchestrator.ts b/packages/chat/src/chat-orchestrator.ts index 0db53a83d5..9de4ebe8e5 100644 --- a/packages/chat/src/chat-orchestrator.ts +++ b/packages/chat/src/chat-orchestrator.ts @@ -1047,7 +1047,7 @@ async function postApproveBlock( mailId, turn, ); - const posted = await postRoomMessage(deps, { + await postRoomMessage(deps, { tenantId: resolved.tenantId, workbenchId, sender: { name: null, address: resolved.roomAddress }, @@ -1110,7 +1110,7 @@ async function postFinalizedTurnArtifacts( workbenchId, turnId, ); - const posted = await postRoomMessage(deps, { + await postRoomMessage(deps, { tenantId: resolved.tenantId, workbenchId, sender: { name: null, address: resolved.roomAddress }, From 9f490c6978924704a18da880076f989051c6dabe Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 15 Sep 2026 17:33:08 -0700 Subject: [PATCH 07/12] fix(chat): widen MintAgentDmDeps store, exactOptional fixes in native timeline --- packages/chat/src/room-messages-native.test.ts | 9 +++++++-- packages/chat/src/room-messages-native.ts | 4 +++- packages/chat/src/workbench-service.ts | 17 ++++++++++++++--- 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/packages/chat/src/room-messages-native.test.ts b/packages/chat/src/room-messages-native.test.ts index a4daea56e8..c7c8ed4c0b 100644 --- a/packages/chat/src/room-messages-native.test.ts +++ b/packages/chat/src/room-messages-native.test.ts @@ -178,7 +178,7 @@ describe("createNativeRoomMessageStore", () => { const activity = await store.listActivity({ tenantId: TENANT, principalId: READER, - workbenches: [{ workbenchId: BENCH, sinceCreatedAt: undefined }], + workbenches: [{ workbenchId: BENCH }], }); expect(activity[BENCH]?.unreadCount).toBe(1); expect(activity[BENCH]?.lastActivityAt).toBe("2026-09-01T00:00:00.000Z"); @@ -193,6 +193,7 @@ describe("createNativeRoomMessageStore", () => { store.insertMessage({ tenantId: TENANT, workbenchId: BENCH, + id: "msg_new", sender: { name: null, address: "u@mail.test" }, parts: [], }), @@ -206,7 +207,11 @@ describe("createNativeRoomMessageStore", () => { }), ).rejects.toThrow(); await expect( - store.deleteMessage({ tenantId: TENANT, messageId: "msg_a" }), + store.deleteMessage({ + tenantId: TENANT, + workbenchId: BENCH, + messageId: "msg_a", + }), ).rejects.toThrow(); }); }); diff --git a/packages/chat/src/room-messages-native.ts b/packages/chat/src/room-messages-native.ts index 35676a5a75..b0163e7d40 100644 --- a/packages/chat/src/room-messages-native.ts +++ b/packages/chat/src/room-messages-native.ts @@ -441,5 +441,7 @@ function pageOfWithCursor( export function parentOfFrame( frame: Pick, ): string | undefined { - return parentMailMessageId({ inReplyTo: frame.inReplyTo }); + return parentMailMessageId( + frame.inReplyTo === undefined ? {} : { inReplyTo: frame.inReplyTo }, + ); } diff --git a/packages/chat/src/workbench-service.ts b/packages/chat/src/workbench-service.ts index 2d405968b2..a58c1ef194 100644 --- a/packages/chat/src/workbench-service.ts +++ b/packages/chat/src/workbench-service.ts @@ -194,6 +194,7 @@ export type MintAgentDmDeps = { | "deleteWorkbenchSettings" | "updateWorkbenchSettings" | "listWorkbenchSettings" + | "getWorkbenchSettings" | "mutateWorkbenchParticipants" >; readonly platform: LaunchAndJoinAgentDeps["platform"]; @@ -524,7 +525,10 @@ async function reopenAgentDm( } export type LaunchAndJoinAgentDeps = { - readonly store: Pick; + readonly store: Pick< + ChatStore, + "mutateWorkbenchParticipants" | "getWorkbenchSettings" + >; readonly platform: WorkbenchLauncher; /** * The mailbox-first fanout for the timeline's `workbench.agent-joined` @@ -793,6 +797,7 @@ export async function launchAndJoinAgent( } export type PostCannedGreetingDeps = { + readonly store: Pick; readonly mailbox: MailboxFanoutDeps; readonly parts: MessagePartsStore; readonly publish: WorkbenchSubscriberRegistry["publish"]; @@ -919,7 +924,10 @@ export async function postCannedGreeting( } export type JoinHumanParticipantDeps = { - readonly store: Pick; + readonly store: Pick< + ChatStore, + "mutateWorkbenchParticipants" | "getWorkbenchSettings" + >; readonly mailbox: MailboxFanoutDeps; readonly parts: MessagePartsStore; readonly publish: (workbenchId: string, event: ChatWorkbenchEvent) => void; @@ -1040,7 +1048,10 @@ export async function joinHumanParticipant( } export type RemoveWorkbenchParticipantDeps = { - readonly store: Pick; + readonly store: Pick< + ChatStore, + "mutateWorkbenchParticipants" | "getWorkbenchSettings" + >; readonly mailbox: MailboxFanoutDeps; readonly parts: MessagePartsStore; readonly publish: (workbenchId: string, event: ChatWorkbenchEvent) => void; From 39ea47ee43fb2c6d36f65a4fa9e9abf8b451f8c8 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 15 Sep 2026 17:37:31 -0700 Subject: [PATCH 08/12] CL-7594: WIP routes mailbox-first migration (preserve lane progress) --- packages/chat/src/routes.ts | 361 ++++++++++++++++-------------------- 1 file changed, 162 insertions(+), 199 deletions(-) diff --git a/packages/chat/src/routes.ts b/packages/chat/src/routes.ts index 58cf3f2bb9..982d450992 100644 --- a/packages/chat/src/routes.ts +++ b/packages/chat/src/routes.ts @@ -66,7 +66,8 @@ import { } from "./workbench-settings"; import { AGENT_DM_DEFINITION_ID_KEY } from "./agent-dm-mode"; import { listWorkbenchLiveState } from "./workbench-reply-activity"; -import { postRoomMessage, type RoomMessageStore } from "./room-messages"; +import { postMailboxMessage } from "./workbench-service"; +import type { RoomMessageStore } from "./room-messages"; import { WorkbenchOnboardingStep } from "./blocks"; import type { ConnectGithubBlockData } from "./blocks"; import { @@ -100,8 +101,8 @@ import { createTurnCancelRegistry, type TurnCancelRegistry, } from "./turn-cancellation"; -import type { TurnMailCorrelationStore } from "./turn-mail-correlation"; import type { ChatPlatform } from "./platform-port"; +import type { MessagePartsStore } from "./message-parts"; import type { ChatStore } from "./store"; import { dispatchAtCommand, @@ -209,29 +210,23 @@ export type CreateChatRoutesDeps = { */ onMessageFanout?: (fanoutDelivered: Promise) => void; /** - * Thread identity store (root / reply / delivery). When omitted, - * thread list routes return empty and delivery-thread creation is - * unavailable — composition that wants threads (hub) injects a - * real store. Optional so unit tests that only exercise workbench - * CRUD stay free of thread tables. + * Thread identity (root / reply / delivery) over mailbox scans — the + * hub injects the descriptor store (`./threads-native.ts`), so every + * thread read here derives off the reader's own copies. Required: the + * thread routes serve every scope from it, and sends resolve their + * target thread through it before publish (CL-6660). */ - threads?: ThreadStore; + threads: ThreadStore; /** - * CL-7450's mailbox fan-out: writes a sent human message into every + * CL-7594's mailbox fan-out: every send writes its batch into every * human participant's `@corbits/mailbox` inbox — see * `SendWorkbenchMessageDeps`'s field of the same name in - * `./workbench-service.ts`. Omitted, a sent message reaches only the - * room's own timeline, the pre-CL-7450 behavior. + * `./workbench-service.ts`. Required — the mailbox batch IS the send's + * durable write. */ - mailbox?: MailboxFanoutDeps; - /** - * Durable dispatch-mail -> source-message correlation (CL-6314) — - * threaded through to every `sendWorkbenchMessage` call this router - * makes, so the reply path can land an agent's answer in its source - * message's thread. Omitted, dispatches still send; their replies - * just post unthreaded. - */ - turnMailCorrelation?: TurnMailCorrelationStore; + mailbox: MailboxFanoutDeps; + /** The `message_parts` sidecar every send records its rich parts into. */ + parts: MessagePartsStore; /** * The turn projection (CL-6329) — one row per agent turn, which is * what makes a reply traceable back to the child run that produced @@ -1224,7 +1219,8 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { { store: deps.store, platform: deps.platform, - roomMessages: deps.roomMessages, + mailbox: deps.mailbox, + parts: deps.parts, publish, }, { @@ -1289,7 +1285,12 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { // joined-then-hello; neither ever rejects. await joinEventDelivered; await postCannedGreeting( - { roomMessages: deps.roomMessages, publish }, + { + store: deps.store, + mailbox: deps.mailbox, + parts: deps.parts, + publish, + }, { tenantId: tenant.id, workbenchId, @@ -1348,7 +1349,8 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { const joined = await joinHumanParticipant( { store: deps.store, - roomMessages: deps.roomMessages, + mailbox: deps.mailbox, + parts: deps.parts, publish, tenancy: deps.tenancy, }, @@ -1568,23 +1570,24 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { // when it was never assigned at all — `workbench_thread_messages` states // that default ("root feed by default"), and `POST /messages` is the only // caller that records membership, so every agent-originated message - // arrives with none. Both thread-aware read routes resolve it the same - // way, from one assignments read rather than a request per thread. + // Both thread-aware read routes resolve it the same way, from one + // assignments read rather than a request per thread. Derived off the + // reader's own mailbox copies — anything without a resolvable thread + // reads in the root feed, the way unassigned rows always defaulted + // there. async function resolveThreadMembership( tenantId: string, workbenchId: string, - ): Promise< - | { - readonly rootThreadId: string; - readonly threadIdOf: (id: string) => string; - } - | undefined - > { - if (deps.threads === undefined) return undefined; + principalId: string, + ): Promise<{ + readonly rootThreadId: string; + readonly threadIdOf: (id: string) => string; + }> { const root = await deps.threads.ensureRootThread(tenantId, workbenchId); const assignments = await deps.threads.listThreadAssignments( tenantId, workbenchId, + principalId, ); return { rootThreadId: root.id, @@ -1597,6 +1600,7 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { deps.requireGrant(idResource("workflow-run", "id"), "read"), async (c) => { const tenant = c.get("tenant"); + const principal = c.get("principal"); const workbenchId = c.req.param("id"); if (!(await workbenchInTenant(deps.store, tenant.id, workbenchId))) { return c.json( @@ -1607,14 +1611,16 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { 404, ); } - if (deps.threads === undefined) { - return c.json({ rootThreadId: "", items: [] as const }); - } - const membership = await resolveThreadMembership(tenant.id, workbenchId); - if (membership === undefined) { - return c.json({ rootThreadId: "", items: [] as const }); - } - const items = await deps.threads.listThreads(tenant.id, workbenchId); + const membership = await resolveThreadMembership( + tenant.id, + workbenchId, + principal.id, + ); + const items = await deps.threads.listThreads( + tenant.id, + workbenchId, + principal.id, + ); // Reply activity for every thread from the one mailbox read the // per-thread feed would otherwise repeat once per thread (CL-6313): @@ -1679,15 +1685,6 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { 404, ); } - if (deps.threads === undefined) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "threads not available", - }), - 404, - ); - } const body = type({ parentMessageId: "string", "title?": "string", @@ -1743,16 +1740,12 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { 404, ); } - if (deps.threads === undefined) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "threads not available", - }), - 404, - ); - } - const thread = await deps.threads.getThread(tenant.id, threadId); + const thread = await deps.threads.getThread( + tenant.id, + workbenchId, + threadId, + principal.id, + ); if (thread === undefined || thread.workbenchId !== workbenchId) { return c.json( makeErrorEnvelope({ @@ -1762,25 +1755,21 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { 404, ); } - // A message's thread is the one it was assigned to, or the root - // thread when it was never assigned at all — `workbench_thread_messages` - // states that default ("root feed by default"), and this is the - // one place that resolves it. `POST /messages` is the only caller - // that records membership, so every agent-originated message - // (`chat-orchestrator`'s reply/approve-block/artifact posters, - // `workbench-service`'s join and leave notices) arrives with none: - // listing a feed by membership rows alone would silently hide all - // of them, a fresh chat's very first agent reply included. - const membership = await resolveThreadMembership(tenant.id, workbenchId); - if (membership === undefined) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "threads not available", - }), - 404, - ); - } + // A message's thread is derived off the reader's own copies — a + // reply's `chat-thread` ref, a delivery's `chat-delivery` ref, or + // the `In-Reply-To` walk — defaulting to the root feed, the way + // unassigned rows always defaulted there. `POST /messages` is the + // only caller that stamps membership, so every agent-originated + // message (`chat-orchestrator`'s reply/approve-block/artifact + // posters, `workbench-service`'s join and leave notices) derives + // its thread the same way: listing a feed by anything narrower + // would silently hide all of them, a fresh chat's very first + // agent reply included. + const membership = await resolveThreadMembership( + tenant.id, + workbenchId, + principal.id, + ); const listed = await deps.roomMessages.listMessages({ tenantId: tenant.id, workbenchId, @@ -1829,15 +1818,6 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { 404, ); } - if (deps.threads === undefined) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "threads not available", - }), - 404, - ); - } const body = type({ runRef: "string", "title?": "string", @@ -1909,11 +1889,10 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { // query serve the root feed, every open thread, and reply counts // (CL-6313) — this route already reads the whole mailbox, so the // membership resolve costs one extra read, not one per thread. - // Absent (not a fabricated id) on a host that mounts no thread - // store, matching `GET /threads`' own `rootThreadId: ""` there. const membership = await resolveThreadMembership( access.ownerTenantId, workbenchId, + principal.id, ); const items = await Promise.all( @@ -1933,9 +1912,10 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { : message.sender, parts: message.parts, }; - return membership === undefined - ? base - : { ...base, threadId: membership.threadIdOf(message.id) }; + return { + ...base, + threadId: membership.threadIdOf(message.id), + }; }), ); @@ -2131,7 +2111,8 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { { store: deps.store, platform: deps.platform, - roomMessages: deps.roomMessages, + mailbox: deps.mailbox, + parts: deps.parts, publish, }, { @@ -2198,7 +2179,8 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { const joined = await joinHumanParticipant( { store: deps.store, - roomMessages: deps.roomMessages, + mailbox: deps.mailbox, + parts: deps.parts, publish, tenancy: deps.tenancy, }, @@ -2248,8 +2230,13 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { const commandResult = commandDecision.command; const resultText = textForCommandResult(commandResult); if (resultText !== undefined) { - await postRoomMessage( - { roomMessages: deps.roomMessages, publish }, + await postMailboxMessage( + { + store: deps.store, + mailbox: deps.mailbox, + parts: deps.parts, + publish, + }, { tenantId: ownerTenantId, workbenchId, @@ -2263,53 +2250,52 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { } // Resolve the target thread *before* publish so the `chat.message` - // SSE payload carries `threadId` (CL-6660). Assignment still runs - // after the insert — membership needs the new message id — but - // subscribers must not see a root-feed echo of a reply send. - let targetThreadId: string | undefined; - if (deps.threads !== undefined) { - const root = await deps.threads.ensureRootThread( + // SSE payload carries `threadId` (CL-6660) — the send stamps the + // descriptor as its `chat-thread` ref, so subscribers never see a + // root-feed echo of a reply send. + const root = await deps.threads.ensureRootThread( + ownerTenantId, + workbenchId, + ); + let targetThreadId: string = root.id; + if (parsed.threadId !== undefined) { + const existing = await deps.threads.getThread( ownerTenantId, workbenchId, + parsed.threadId, + principal.id, ); - targetThreadId = root.id; - if (parsed.threadId !== undefined) { - const existing = await deps.threads.getThread( - ownerTenantId, - parsed.threadId, + if (existing === undefined || existing.workbenchId !== workbenchId) { + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "thread not found", + }), + 404, ); - if (existing === undefined || existing.workbenchId !== workbenchId) { + } + targetThreadId = existing.id; + } else if (parsed.inReplyToMessageId !== undefined) { + let reply; + try { + reply = await deps.threads.openReplyThread({ + tenantId: ownerTenantId, + workbenchId, + parentMessageId: parsed.inReplyToMessageId, + }); + } catch (cause) { + if (cause instanceof ThreadDepthCapError) { return c.json( makeErrorEnvelope({ - code: "not_found", - userMessage: "thread not found", + code: "conflict", + userMessage: cause.message, }), - 404, + 409, ); } - targetThreadId = existing.id; - } else if (parsed.inReplyToMessageId !== undefined) { - let reply; - try { - reply = await deps.threads.openReplyThread({ - tenantId: ownerTenantId, - workbenchId, - parentMessageId: parsed.inReplyToMessageId, - }); - } catch (cause) { - if (cause instanceof ThreadDepthCapError) { - return c.json( - makeErrorEnvelope({ - code: "conflict", - userMessage: cause.message, - }), - 409, - ); - } - throw cause; - } - targetThreadId = reply.id; + throw cause; } + targetThreadId = reply.id; } // No unreachable-agent branch: the message is a room write, and @@ -2332,20 +2318,17 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { publish, turnQueue, turnCancellation, + mailbox: deps.mailbox, + parts: deps.parts, ...(deps.agentTurns !== undefined ? { agentTurns: deps.agentTurns } : {}), - ...(deps.threads !== undefined ? { threads: deps.threads } : {}), - ...(deps.turnMailCorrelation !== undefined - ? { turnMailCorrelation: deps.turnMailCorrelation } - : {}), ...(deps.turnDispatchTimeoutMs !== undefined ? { turnDispatchTimeoutMs: deps.turnDispatchTimeoutMs } : {}), ...(deps.waitUntilFreeTimeoutMs !== undefined ? { waitUntilFreeTimeoutMs: deps.waitUntilFreeTimeoutMs } : {}), - ...(deps.mailbox !== undefined ? { mailbox: deps.mailbox } : {}), }, { tenantId: ownerTenantId, @@ -2356,9 +2339,7 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { ...(parsed.inReplyToMessageId !== undefined ? { inReplyToMessageId: parsed.inReplyToMessageId } : {}), - ...(targetThreadId !== undefined - ? { threadId: targetThreadId } - : {}), + threadId: targetThreadId, ...(commandDecision !== undefined && "routeToParticipant" in commandDecision ? { forcedRecipientAddress: commandDecision.routeToParticipant } @@ -2391,30 +2372,14 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { }); } - if (deps.threads !== undefined && targetThreadId !== undefined) { - await deps.threads.assignMessage({ - tenantId: ownerTenantId, - workbenchId, - threadId: targetThreadId, - messageId: sent.id, - }); - return c.json( - { - id: sent.id, - createdAt: sent.createdAt, - threadId: targetThreadId, - ...(parsed.clientId !== undefined - ? { clientId: parsed.clientId } - : {}), - }, - 201, - ); - } - + // The thread rides the send itself — `threadId` stamps the + // `chat-thread` ref — so there is no membership row to record + // after it. The response echoes the thread the send read in. return c.json( { id: sent.id, createdAt: sent.createdAt, + threadId: targetThreadId, ...(parsed.clientId !== undefined ? { clientId: parsed.clientId } : {}), @@ -2513,8 +2478,13 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { // question branch below ever gets a chance to dispatch a turn, so an // agent reading the timeline for its turn always finds this event // already there (CL-7192). - await postRoomMessage( - { roomMessages: deps.roomMessages, publish }, + await postMailboxMessage( + { + store: deps.store, + mailbox: deps.mailbox, + parts: deps.parts, + publish, + }, { tenantId: ownerTenantId, workbenchId, @@ -2552,14 +2522,11 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { // card's own thread — which is also what gives its dispatch // the card's `Message-ID` in `In-Reply-To` (CL-7104), the one // thing that ties an answer to the question it answers. - const answerThread = - deps.threads === undefined - ? undefined - : await deps.threads.openReplyThread({ - tenantId: ownerTenantId, - workbenchId, - parentMessageId: messageId, - }); + const answerThread = await deps.threads.openReplyThread({ + tenantId: ownerTenantId, + workbenchId, + parentMessageId: messageId, + }); try { const answer = await sendWorkbenchMessage( { @@ -2569,24 +2536,17 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { publish, turnQueue, turnCancellation, + mailbox: deps.mailbox, + parts: deps.parts, ...(deps.agentTurns !== undefined ? { agentTurns: deps.agentTurns } : {}), - ...(deps.threads !== undefined - ? { threads: deps.threads } - : {}), - ...(deps.turnMailCorrelation !== undefined - ? { turnMailCorrelation: deps.turnMailCorrelation } - : {}), ...(deps.turnDispatchTimeoutMs !== undefined ? { turnDispatchTimeoutMs: deps.turnDispatchTimeoutMs } : {}), ...(deps.waitUntilFreeTimeoutMs !== undefined ? { waitUntilFreeTimeoutMs: deps.waitUntilFreeTimeoutMs } : {}), - ...(deps.mailbox !== undefined - ? { mailbox: deps.mailbox } - : {}), }, { tenantId: ownerTenantId, @@ -2600,20 +2560,10 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { // ties an answer to the question it answers. There is no // correlation id on the wire. inReplyToMessageId: messageId, - ...(answerThread !== undefined - ? { threadId: answerThread.id } - : {}), + threadId: answerThread.id, }, ); deps.onMessageFanout?.(answer.fanoutDelivered); - if (deps.threads !== undefined && answerThread !== undefined) { - await deps.threads.assignMessage({ - tenantId: ownerTenantId, - workbenchId, - threadId: answerThread.id, - messageId: answer.id, - }); - } } catch (err) { // `MailboxFanoutFailedError` already reported itself under its // own `refId` — quoting that instead of calling `reportError` @@ -3213,7 +3163,8 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { { store: deps.store, platform: deps.platform, - roomMessages: deps.roomMessages, + mailbox: deps.mailbox, + parts: deps.parts, publish, }, { @@ -3305,8 +3256,13 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { steps: step.steps, state: "disconnected", }; - const posted = await postRoomMessage( - { roomMessages: deps.roomMessages, publish }, + const posted = await postMailboxMessage( + { + store: deps.store, + mailbox: deps.mailbox, + parts: deps.parts, + publish, + }, { tenantId: tenant.id, workbenchId, @@ -3397,7 +3353,8 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { await removeWorkbenchParticipant( { store: deps.store, - roomMessages: deps.roomMessages, + mailbox: deps.mailbox, + parts: deps.parts, publish, releaseAgentInstance: deps.releaseAgentInstance, }, @@ -4024,8 +3981,13 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { principal.id, ); for (const event of events) { - await postRoomMessage( - { roomMessages: deps.roomMessages, publish }, + await postMailboxMessage( + { + store: deps.store, + mailbox: deps.mailbox, + parts: deps.parts, + publish, + }, { tenantId: tenant.id, workbenchId, @@ -4340,7 +4302,8 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { const result = await cancelWorkbenchTurn( { turnCancellation, - roomMessages: deps.roomMessages, + mailbox: deps.mailbox, + parts: deps.parts, publish, ...(deps.agentTurns !== undefined ? { agentTurns: deps.agentTurns } From 2c5f03f2c96a84852504db4cd19ec99b004a1094 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 15 Sep 2026 17:40:44 -0700 Subject: [PATCH 09/12] fix(chat): mailbox-first deps for workflow-participant routes, cancel-turn store, connect-pending --- packages/chat/src/connect-pending.ts | 24 +++++++++++++++---- packages/chat/src/routes.ts | 1 + .../chat/src/workflow-participant-routes.ts | 19 ++++++++------- 3 files changed, 31 insertions(+), 13 deletions(-) diff --git a/packages/chat/src/connect-pending.ts b/packages/chat/src/connect-pending.ts index e6c9b2bee8..ce6080d1ef 100644 --- a/packages/chat/src/connect-pending.ts +++ b/packages/chat/src/connect-pending.ts @@ -99,12 +99,20 @@ function bareConnectorId(connectorId: string): string { export type SettleConnectedServiceDeps = Pick< SendWorkbenchMessageDeps, - "platform" | "agentTurns" | "roomMessages" | "publish" + "platform" | "agentTurns" | "mailbox" | "parts" | "publish" > & { readonly store: Pick< ChatStore, - "listWorkbenchSettings" | "updateWorkbenchSettings" + | "listWorkbenchSettings" + | "updateWorkbenchSettings" + | "getWorkbenchSettings" + | "getBenchSettings" >; + /** + * Timeline reads for the answered-messages scan: the hub injects the + * native store, so the scan pages the connecting person's own copies. + */ + readonly roomMessages: Pick; }; export type SettleConnectedServiceInput = { @@ -134,7 +142,11 @@ function arrivalOrder(left: RoomMessage, right: RoomMessage): number { async function existingRequestMessageIds( roomMessages: Pick, - input: { readonly tenantId: string; readonly workbenchId: string }, + input: { + readonly tenantId: string; + readonly workbenchId: string; + readonly principalId: string; + }, ): Promise { const listed = await roomMessages.listMessages(input); const lastUser = listed.items.find( @@ -231,7 +243,11 @@ export async function settleConnectedService( const requestMessageIds = await existingRequestMessageIds( deps.roomMessages, - { tenantId: input.tenantId, workbenchId: row.workbenchId }, + { + tenantId: input.tenantId, + workbenchId: row.workbenchId, + principalId: input.principalId, + }, ); await dispatchTurn(deps, { tenantId: input.tenantId, diff --git a/packages/chat/src/routes.ts b/packages/chat/src/routes.ts index 982d450992..794afbb0db 100644 --- a/packages/chat/src/routes.ts +++ b/packages/chat/src/routes.ts @@ -4301,6 +4301,7 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { } const result = await cancelWorkbenchTurn( { + store: deps.store, turnCancellation, mailbox: deps.mailbox, parts: deps.parts, diff --git a/packages/chat/src/workflow-participant-routes.ts b/packages/chat/src/workflow-participant-routes.ts index a28e311ba9..0450be7261 100644 --- a/packages/chat/src/workflow-participant-routes.ts +++ b/packages/chat/src/workflow-participant-routes.ts @@ -154,6 +154,10 @@ export type CreateWorkflowParticipantRoutesDeps = { SendWorkbenchMessageDeps["store"]; readonly platform: LaunchAndJoinAgentDeps["platform"] & SendWorkbenchMessageDeps["platform"]; + readonly mailbox: SendWorkbenchMessageDeps["mailbox"]; + readonly parts: SendWorkbenchMessageDeps["parts"]; + /** Timeline reads for routing and context — see + * `SendWorkbenchMessageDeps`'s field of the same name. */ readonly roomMessages: SendWorkbenchMessageDeps["roomMessages"]; readonly publish: LaunchAndJoinAgentDeps["publish"]; /** The same one-in-flight-turn-per-workbench queue `createChatRoutes` @@ -165,10 +169,6 @@ export type CreateWorkflowParticipantRoutesDeps = { * (CL-7201) — shared, never a second instance, so a cancel request * reaches a controller registered from either entry point. */ readonly turnCancellation: SendWorkbenchMessageDeps["turnCancellation"]; - /** The same dispatch-mail correlation `createChatRoutes` is given - * (CL-6314) — shared, never a second instance, so a workflow-child - * message's dispatch records the same way a person's own send does. */ - readonly turnMailCorrelation?: SendWorkbenchMessageDeps["turnMailCorrelation"]; readonly authenticator: WorkflowRunAuthenticator; readonly tenancy: Pick< WorkbenchTenancyStore, @@ -248,7 +248,8 @@ export function createWorkflowParticipantRoutes( { store: deps.store, platform: deps.platform, - roomMessages: deps.roomMessages, + mailbox: deps.mailbox, + parts: deps.parts, publish: deps.publish, }, { @@ -382,7 +383,8 @@ export function createWorkflowParticipantRoutes( tenancy: deps.tenancy, store: deps.store, platform: deps.platform, - roomMessages: deps.roomMessages, + mailbox: deps.mailbox, + parts: deps.parts, publish: deps.publish, }, { @@ -532,12 +534,11 @@ export function createWorkflowParticipantRoutes( store: deps.store, platform: deps.platform, roomMessages: deps.roomMessages, + mailbox: deps.mailbox, + parts: deps.parts, publish: deps.publish, turnQueue: deps.turnQueue, turnCancellation: deps.turnCancellation, - ...(deps.turnMailCorrelation !== undefined - ? { turnMailCorrelation: deps.turnMailCorrelation } - : {}), }, { tenantId: scope.tenantId, From 6d13c515cd528f732bd4caa8bbda70949c85a783 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 15 Sep 2026 17:48:32 -0700 Subject: [PATCH 10/12] CL-7594: migrate mail-threading + sse header tests to descriptor threads --- packages/chat/test/mail-threading.test.ts | 103 ++++-------------- .../chat/test/sse-message-headers.test.ts | 21 +--- 2 files changed, 25 insertions(+), 99 deletions(-) diff --git a/packages/chat/test/mail-threading.test.ts b/packages/chat/test/mail-threading.test.ts index 355dd52a17..ef557a4052 100644 --- a/packages/chat/test/mail-threading.test.ts +++ b/packages/chat/test/mail-threading.test.ts @@ -8,9 +8,7 @@ import { describe, expect, test } from "bun:test"; import { dispatchTurn } from "../src/workbench-service"; import type { MailContent } from "../src/codec"; import { createInMemoryRoomMessageStore } from "../src/room-messages"; -import { createInMemoryThreadStore } from "../src/threads"; -import { createInMemoryTurnMailCorrelationStore } from "../src/turn-mail-correlation"; -import { mailIdFromBracketMessageId } from "../src/turn-mail-correlation"; +import { replyThreadId, subThreadId } from "../src/threads-native"; const TENANT = "ten_1"; const WORKBENCH = "ins_workbench1"; @@ -19,8 +17,6 @@ const DOMAIN = "acme.example"; function harness() { const roomMessages = createInMemoryRoomMessageStore(); - const threads = createInMemoryThreadStore(); - const turnMailCorrelation = createInMemoryTurnMailCorrelationStore(); const sent: MailContent[] = []; const deps = { platform: { @@ -33,8 +29,6 @@ function harness() { }, }, roomMessages, - threads, - turnMailCorrelation, publish: () => undefined, mailbox: { writer: { @@ -52,7 +46,7 @@ function harness() { resolveTenantDomain: async () => DOMAIN, }, }; - return { deps, roomMessages, threads, turnMailCorrelation, sent }; + return { deps, roomMessages, sent }; } async function postRow( @@ -80,6 +74,7 @@ async function postRow( async function dispatch( h: ReturnType, requestMessageIds: readonly string[], + threadId?: string, ) { await dispatchTurn(h.deps as never, { tenantId: TENANT, @@ -88,6 +83,7 @@ async function dispatch( agentAddress: AGENT, parts: [{ kind: "text", text: "over to you" }], requestMessageIds, + ...(threadId !== undefined ? { threadId } : {}), }); } @@ -124,32 +120,12 @@ describe("dispatch mail threading", () => { test("a dispatch from inside a sub-thread carries the full References chain, oldest first", async () => { const h = harness(); const anchor = await postRow(h.roomMessages, "the original"); - const thread = await h.threads.openReplyThread({ - tenantId: TENANT, - workbenchId: WORKBENCH, - parentMessageId: anchor.id, - }); - const inThread = await postRow(h.roomMessages, "a reply", thread.id); - await h.threads.assignMessage({ - tenantId: TENANT, - workbenchId: WORKBENCH, - threadId: thread.id, - messageId: inThread.id, - }); - const sub = await h.threads.forkThread({ - tenantId: TENANT, - workbenchId: WORKBENCH, - parentMessageId: inThread.id, - }); - const inSub = await postRow(h.roomMessages, "@ins_echo1 look", sub.id); - await h.threads.assignMessage({ - tenantId: TENANT, - workbenchId: WORKBENCH, - threadId: sub.id, - messageId: inSub.id, - }); + const replyTid = replyThreadId(anchor.id); + const inThread = await postRow(h.roomMessages, "a reply", replyTid); + const subTid = subThreadId(replyTid, inThread.id); + const inSub = await postRow(h.roomMessages, "@ins_echo1 look", subTid); - await dispatch(h, [inSub.id]); + await dispatch(h, [inSub.id], subTid); expect(h.sent[0]?.messageId).toBe(`<${inSub.id}@acme.example>`); expect(h.sent[0]?.references).toEqual([ @@ -163,74 +139,35 @@ describe("dispatch mail threading", () => { test("two turns pending for one address stay told apart by their Message-IDs, not by the address", async () => { const h = harness(); const first = await postRow(h.roomMessages, "question one"); - const firstThread = await h.threads.openReplyThread({ - tenantId: TENANT, - workbenchId: WORKBENCH, - parentMessageId: first.id, - }); + const firstTid = replyThreadId(first.id); const inFirst = await postRow( h.roomMessages, "@ins_echo1 one", - firstThread.id, + firstTid, ); - await h.threads.assignMessage({ - tenantId: TENANT, - workbenchId: WORKBENCH, - threadId: firstThread.id, - messageId: inFirst.id, - }); const second = await postRow(h.roomMessages, "question two"); - const secondThread = await h.threads.openReplyThread({ - tenantId: TENANT, - workbenchId: WORKBENCH, - parentMessageId: second.id, - }); + const secondTid = replyThreadId(second.id); const inSecond = await postRow( h.roomMessages, "@ins_echo1 two", - secondThread.id, + secondTid, ); - await h.threads.assignMessage({ - tenantId: TENANT, - workbenchId: WORKBENCH, - threadId: secondThread.id, - messageId: inSecond.id, - }); // Both turns are in flight against the same agent address. - await dispatch(h, [inFirst.id]); - await dispatch(h, [inSecond.id]); + await dispatch(h, [inFirst.id], firstTid); + await dispatch(h, [inSecond.id], secondTid); expect(h.sent.map((mail) => mail.messageId)).toEqual([ `<${inFirst.id}@acme.example>`, `<${inSecond.id}@acme.example>`, ]); - // A reply arriving inside the SECOND dispatch's bracket resolves to - // the second turn's source, not the newest-per-address guess: the - // bracket's Message-ID is what names it. - const secondBracket = h.sent[1]?.messageId ?? ""; - expect( - await h.turnMailCorrelation.findTurnMailSource({ - tenantId: TENANT, - mailId: mailIdFromBracketMessageId(secondBracket), - }), - ).toEqual({ - tenantId: TENANT, - workbenchId: WORKBENCH, - sourceMessageId: inSecond.id, - }); - - // And the first turn's bracket still names the first source — one - // address, two pending turns, no ambiguity. - const firstBracket = h.sent[0]?.messageId ?? ""; - expect( - await h.turnMailCorrelation.findTurnMailSource({ - tenantId: TENANT, - mailId: mailIdFromBracketMessageId(firstBracket), - }), - ).toMatchObject({ sourceMessageId: inFirst.id }); + // Each dispatch's bracket names its own source thread, not the + // newest-per-address guess: the thread descriptor is what tells them + // apart. + expect(h.sent[0]?.inReplyTo).toBe(`<${first.id}@acme.example>`); + expect(h.sent[1]?.inReplyTo).toBe(`<${second.id}@acme.example>`); }); test("a dispatch answering nothing threads under nothing rather than guessing a parent", async () => { diff --git a/packages/chat/test/sse-message-headers.test.ts b/packages/chat/test/sse-message-headers.test.ts index a5fb02d077..1ceb892747 100644 --- a/packages/chat/test/sse-message-headers.test.ts +++ b/packages/chat/test/sse-message-headers.test.ts @@ -11,10 +11,11 @@ import { createInMemoryRoomMessageStore } from "../src/room-messages"; import { postRoomMessage } from "../src/room-messages"; import { ChatMessageEventData } from "../src/stream-events"; import { createInMemoryChatStore } from "../src/store"; -import { createInMemoryThreadStore } from "../src/threads"; +import { createInMemoryMessagePartsStore } from "../src/message-parts"; import { createInMemoryTurnClaimStore } from "../src/turn-claims"; import { createWorkbenchTurnQueue } from "../src/turn-queue"; import { createTurnCancelRegistry } from "../src/turn-cancellation"; +import { replyThreadId } from "../src/threads-native"; import type { MailboxWriter } from "../src/mailbox-fanout"; import type { ParticipantRecord } from "../src/participants"; @@ -40,7 +41,6 @@ function participantsOf( function harness() { const store = createInMemoryChatStore(); const roomMessages = createInMemoryRoomMessageStore(); - const threads = createInMemoryThreadStore(); const claims = createInMemoryTurnClaimStore({ ttlMs: 60_000 }); const turnQueue = createWorkbenchTurnQueue({ claims, @@ -59,7 +59,6 @@ function harness() { const deps = { store, roomMessages, - threads, publish: (_workbenchId: string, event: { type: string; data: unknown }) => { events.push(event); }, @@ -70,6 +69,7 @@ function harness() { }, turnQueue, turnCancellation: createTurnCancelRegistry(), + parts: createInMemoryMessagePartsStore(), mailbox: { writer, resolveKnownPrincipalIds: async ( @@ -79,7 +79,7 @@ function harness() { resolveTenantDomain: async (_tenantId: string) => DOMAIN, }, }; - return { deps, store, roomMessages, threads, events }; + return { deps, store, roomMessages, events }; } async function messageEventOf( @@ -154,26 +154,15 @@ describe("chat.message SSE event mail headers (CL-7448)", () => { }); await root.fanoutDelivered; - const thread = await h.threads.openReplyThread({ - tenantId: TENANT_ID, - workbenchId: WORKBENCH_ID, - parentMessageId: root.id, - }); const reply = await sendWorkbenchMessage(h.deps, { tenantId: TENANT_ID, principalId: SENDER, senderAddress: `${SENDER}@${DOMAIN}`, workbenchId: WORKBENCH_ID, messageParts: [{ kind: "text", text: "a reply" }], - threadId: thread.id, + threadId: replyThreadId(root.id), }); await reply.fanoutDelivered; - await h.threads.assignMessage({ - tenantId: TENANT_ID, - workbenchId: WORKBENCH_ID, - threadId: thread.id, - messageId: reply.id, - }); const parsed = ChatMessageEventData.assert( await messageEventOf(h, reply.id), From 4ef0e254d9965802ef9ad59cc3c15f7f6f529e0f Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 15 Sep 2026 17:50:20 -0700 Subject: [PATCH 11/12] CL-7594: dispatch-turn-abort asserts the mailbox-first cancelled notice --- .../chat/test/dispatch-turn-abort.test.ts | 48 +++++++++++++------ 1 file changed, 33 insertions(+), 15 deletions(-) diff --git a/packages/chat/test/dispatch-turn-abort.test.ts b/packages/chat/test/dispatch-turn-abort.test.ts index 07b8171bc2..8d28375871 100644 --- a/packages/chat/test/dispatch-turn-abort.test.ts +++ b/packages/chat/test/dispatch-turn-abort.test.ts @@ -1,4 +1,4 @@ -// CL-7201 (Critique finding): `dispatchTurn` must never call `sendMail` +// CL-7201: `dispatchTurn` must refuse to start a mail send at all // for a turn whose signal was ALREADY aborted before the call — as // opposed to CL-7230's disclosed ceiling (a signal that aborts WHILE // `sendMail` is already in flight, which genuinely cannot be stopped). @@ -9,21 +9,34 @@ import { describe, expect, test } from "bun:test"; import { createInMemoryAgentTurnStore } from "../src/agent-turns"; -import { createInMemoryRoomMessageStore } from "../src/room-messages"; +import { ChatMessageEventData } from "../src/stream-events"; import { TurnCancelledError } from "../src/turn-cancellation"; import { dispatchTurn } from "../src/workbench-service"; -import { fakePlatform, TENANT } from "./test-support"; +import { buildDeps, fakePlatform, TENANT } from "./test-support"; describe("dispatchTurn with an already-aborted signal (CL-7201)", () => { test("never calls sendMail, and settles the row cancelled", async () => { const platform = fakePlatform(); const agentTurns = createInMemoryAgentTurnStore(); - const roomMessages = createInMemoryRoomMessageStore(); + const deps = buildDeps({ platform, agentTurns }); + const events: { type: string; data: unknown }[] = []; const controller = new AbortController(); controller.abort(new TurnCancelledError()); await dispatchTurn( - { platform, agentTurns, roomMessages, publish: () => undefined }, + { + platform: deps.platform, + agentTurns, + store: deps.store, + mailbox: deps.mailbox, + parts: deps.parts, + publish: ( + _workbenchId: string, + event: { type: string; data: unknown }, + ) => { + events.push(event); + }, + }, { tenantId: TENANT.id, workbenchId: "wb_1", @@ -34,6 +47,9 @@ describe("dispatchTurn with an already-aborted signal (CL-7201)", () => { }, controller.signal, ); + // The abort-close runs on a fire-and-forget `.then` off `finishTurn`, + // so flush before asserting on the notice it posts. + await Bun.sleep(5); expect(platform.sentMail).toHaveLength(0); @@ -44,15 +60,17 @@ describe("dispatchTurn with an already-aborted signal (CL-7201)", () => { expect(turns).toHaveLength(1); expect(turns[0]?.status).toBe("cancelled"); - const messages = await roomMessages.listMessages({ - tenantId: TENANT.id, - workbenchId: "wb_1", - }); - const notice = messages.items.find((message) => - message.parts.some( - (part) => part.kind === "text" && part.turnCancelled === true, - ), - ); - expect(notice).toBeDefined(); + // The cancelled notice goes out mailbox-first (fanout + parts sidecar) + // like every other timeline write now, so it surfaces as a published + // `chat.message` rather than a sidecar row. + const notices = events + .filter((event) => event.type === "chat.message") + .map((event) => ChatMessageEventData.assert(event.data)) + .filter((data) => + data.parts.some( + (part) => part.kind === "text" && part.turnCancelled === true, + ), + ); + expect(notices).toHaveLength(1); }); }); From 1b9ced0ed2d1220ee0e1c1e40593e6c75d3c06cb Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 15 Sep 2026 17:50:40 -0700 Subject: [PATCH 12/12] CL-7594: prettier pass on migrated mail-threading test --- packages/chat/test/mail-threading.test.ts | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/packages/chat/test/mail-threading.test.ts b/packages/chat/test/mail-threading.test.ts index ef557a4052..d146a56318 100644 --- a/packages/chat/test/mail-threading.test.ts +++ b/packages/chat/test/mail-threading.test.ts @@ -140,19 +140,11 @@ describe("dispatch mail threading", () => { const h = harness(); const first = await postRow(h.roomMessages, "question one"); const firstTid = replyThreadId(first.id); - const inFirst = await postRow( - h.roomMessages, - "@ins_echo1 one", - firstTid, - ); + const inFirst = await postRow(h.roomMessages, "@ins_echo1 one", firstTid); const second = await postRow(h.roomMessages, "question two"); const secondTid = replyThreadId(second.id); - const inSecond = await postRow( - h.roomMessages, - "@ins_echo1 two", - secondTid, - ); + const inSecond = await postRow(h.roomMessages, "@ins_echo1 two", secondTid); // Both turns are in flight against the same agent address. await dispatch(h, [inFirst.id], firstTid);