From 913540921a1fd5f6a3e12a4e6a92dd60357eb47a Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 18 Sep 2026 16:51:12 -0700 Subject: [PATCH] fix(web): a new chat starts its own thread instead of appending to Myra's last one (CL-8616) --- apps/web/src/chat/threads-api.test.ts | 1 + apps/web/src/chat/threads-api.ts | 88 +++++++++++++++++-------- apps/web/src/pages/chat-thread-page.tsx | 4 +- docs/chat-mail-threading.md | 13 ++-- 4 files changed, 73 insertions(+), 33 deletions(-) diff --git a/apps/web/src/chat/threads-api.test.ts b/apps/web/src/chat/threads-api.test.ts index 88b13e2f7..79fd9d06d 100644 --- a/apps/web/src/chat/threads-api.test.ts +++ b/apps/web/src/chat/threads-api.test.ts @@ -38,6 +38,7 @@ function meTurn(subject: string, body: string) { return { id: "Sent:1", messageId: "m1", + parentId: undefined, address: "run_alice@example.com", author: "me" as const, subject, diff --git a/apps/web/src/chat/threads-api.ts b/apps/web/src/chat/threads-api.ts index 9634e2351..cac706867 100644 --- a/apps/web/src/chat/threads-api.ts +++ b/apps/web/src/chat/threads-api.ts @@ -1,6 +1,6 @@ -// Chats are mail threads (see docs/chat-mail-threading.md). Keyed by the -// agent's definition asset id, not a run id, since a redeploy retires the -// run id and would 409 a keyed-on-run chat. +// Chats are mail threads (see docs/chat-mail-threading.md). A chat is keyed +// by its thread root turn's id (the person's opening send), so two chats to +// the same agent stay separate instead of merging into one conversation. import { type } from "arktype"; import { WorkflowDeploymentResponse } from "@intx/types"; @@ -64,7 +64,8 @@ export type ChatMessage = { }; export type ChatSummary = { - /** Route id: the agent's run id. */ + /** Route id: the thread's root turn id (`folder:uid` of its opening + * send) — unique per conversation, unlike the agent it's addressed to. */ readonly id: string; readonly title: string; readonly agentName: string; @@ -332,6 +333,9 @@ function participantAddress( type MailTurn = { readonly id: string; readonly messageId: string; + /** In-Reply-To, or the newest References entry when that header is + * missing — what ties a reply back to the turn before it. */ + readonly parentId: string | undefined; readonly address: string; readonly author: "me" | "agent"; readonly subject: string; @@ -352,6 +356,7 @@ async function readFolder(tenantId: string, folder: "INBOX" | "Sent"): Promise { return [...inbox, ...sent].sort((a, b) => Date.parse(a.at) - Date.parse(b.at)); } +/** Each turn's own id, mapped to the id of its thread's opening turn — + * found by walking In-Reply-To/References back to a turn with no known + * parent. A cycle (shouldn't happen) just stops at the point it's seen. */ +function threadRootIds(turns: readonly MailTurn[]): Map { + const byMessageId = new Map(turns.map((turn) => [turn.messageId, turn])); + const roots = new Map(); + for (const turn of turns) { + let current = turn; + const seen = new Set([turn.messageId]); + for (;;) { + const parent = current.parentId === undefined ? undefined : byMessageId.get(current.parentId); + if (parent === undefined || seen.has(parent.messageId)) break; + seen.add(parent.messageId); + current = parent; + } + roots.set(turn.id, current.id); + } + return roots; +} + // --------------------------------------------------------------------- // Sends // --------------------------------------------------------------------- @@ -382,7 +407,7 @@ async function sendToAgent( address: string, body: string, inReplyTo: string | undefined, -): Promise { +): Promise<{ readonly messageId: string; readonly uid: number }> { let response: Response; try { response = await fetch(`${mailboxPath(tenantId)}/send`, { @@ -408,11 +433,14 @@ async function sendToAgent( if (parsed instanceof type.errors) { throw new ChatApiError(`Unexpected send response: ${parsed.summary}`); } + return parsed; } -/** Starts a chat with one agent. Returns the chat id to route to — the - * agent's definition asset id. Callers should keep the composer disabled - * until `liveAddress` is set; this still guards against a stale click. */ +/** Starts a brand-new chat with one agent: no `inReplyTo`, so the send opens + * its own thread rather than landing on whatever this agent last answered. + * Returns the chat id to route to — the opening send's own `Sent:`, the + * thread's root turn id. Callers should keep the composer disabled until + * `liveAddress` is set; this still guards against a stale click. */ export async function startChat( tenantId: string, agent: ChatAgent, @@ -421,8 +449,8 @@ export async function startChat( if (agent.liveAddress === null) { throw new ChatApiError(`${agent.name} is starting…`); } - await sendToAgent(tenantId, agent.liveAddress, content, undefined); - return agent.id; + const sent = await sendToAgent(tenantId, agent.liveAddress, content, undefined); + return `Sent:${String(sent.uid)}`; } /** A reply is the same send, threaded onto the chat's newest message, sent @@ -469,25 +497,27 @@ function agentByAddress(agents: readonly ChatAgent[]): Map { return index; } -/** Every chat the person has: one per agent they have exchanged mail with, - * grouped by the agent's asset id so a redeploy's new run still lands in - * the same chat. */ +/** Every chat the person has: one per mail thread with an agent, grouped by + * the thread's root turn id, not the agent — an agent's address set spans + * every run it has ever had, so two separate threads to the same agent stay + * two separate chats instead of merging into one. */ export async function listChats(tenantId: string): Promise { const [agents, turns] = await Promise.all([listChatAgents(tenantId), readTurns(tenantId)]); const addressToAgent = agentByAddress(agents); - const byAgent = new Map(); + const rootIds = threadRootIds(turns); + const byThread = new Map(); for (const turn of turns) { - const agent = addressToAgent.get(turn.address); - if (agent === undefined) continue; - byAgent.set(agent.id, [...(byAgent.get(agent.id) ?? []), turn]); + if (!addressToAgent.has(turn.address)) continue; + const rootId = rootIds.get(turn.id)!; + byThread.set(rootId, [...(byThread.get(rootId) ?? []), turn]); } - return [...byAgent.entries()] - .map(([agentId, rows]) => { - const resolvedName = agents.find((agent) => agent.id === agentId)?.name; - const agentName = resolvedName ?? agentId; + return [...byThread.entries()] + .map(([threadId, rows]) => { const newest = rows[rows.length - 1]!; + const resolvedName = addressToAgent.get(newest.address)?.name; + const agentName = resolvedName ?? "Unknown agent"; return { - id: agentId, + id: threadId, title: chatTitle(rows, resolvedName), agentName, preview: newest.body.slice(0, 80), @@ -528,16 +558,20 @@ export function isChatReplyReady( } } -/** One chat's full transcript: every mail turn addressed to any run this - * agent has ever had, oldest first. */ +/** One chat's full transcript: every turn in the thread rooted at `chatId`, + * oldest first. */ export async function readChat(tenantId: string, chatId: string): Promise { const [agents, turns] = await Promise.all([listChatAgents(tenantId), readTurns(tenantId)]); - const agent = agents.find((candidate) => candidate.id === chatId); + const rootIds = threadRootIds(turns); + const rows = turns.filter((turn) => rootIds.get(turn.id) === chatId); + if (rows.length === 0) { + throw new ChatApiError("That chat could not be found.", 404); + } + const addressToAgent = agentByAddress(agents); + const agent = addressToAgent.get(rows[0]!.address); if (agent === undefined) { throw new ChatApiError("That agent could not be found.", 404); } - const addresses = new Set(agent.addresses); - const rows = turns.filter((turn) => addresses.has(turn.address)); return { id: chatId, title: chatTitle(rows, agent.name), diff --git a/apps/web/src/pages/chat-thread-page.tsx b/apps/web/src/pages/chat-thread-page.tsx index eacb4a5c9..9d2311dab 100644 --- a/apps/web/src/pages/chat-thread-page.tsx +++ b/apps/web/src/pages/chat-thread-page.tsx @@ -248,7 +248,9 @@ function ChatTranscript({
diff --git a/docs/chat-mail-threading.md b/docs/chat-mail-threading.md index 4293ecd86..9c04b9e82 100644 --- a/docs/chat-mail-threading.md +++ b/docs/chat-mail-threading.md @@ -5,11 +5,14 @@ mail: a chat has exactly one agent, and both sides are durable mail — the person sends from their own mailbox (keeping a Sent copy), and an agent's reply lands in the same mailbox's INBOX. No chat-specific hub route exists. -A chat is keyed by its agent's definition asset id, not a run id: every hub -restart releases the old run and redeploys under a new one, so keying on a -run id would 409 the moment it turns terminal. An agent's address set spans -every run it has ever had, which keeps history intact across a redeploy; -sends resolve the current live run's address at send time. +A chat is keyed by its thread's root turn id (`Sent:` of the person's +opening send), found by walking In-Reply-To/References back from every mail +turn — not the agent's definition asset id, so two separate chats with the +same agent stay separate instead of merging into one conversation. Sends +resolve the agent's current live run's address at send time; an agent's +address set spans every run it has ever had (a hub restart retires the old +run and redeploys under a new one), which keeps a thread's history readable +across a redeploy even though the address on later turns changes. A workbench is a child tenant, and its conversation is that tenant's own mailbox — reads are the same stock mailbox routes as a chat, scoped to the