From 85414df9f505dfcc5f45ebba8bbb6ef9e7a741ed Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 18 Sep 2026 11:19:05 -0700 Subject: [PATCH 1/2] test(web): person participant address is the mailbox address (CL-8581) --- apps/web/src/chat/threads-api.test.ts | 64 ++++++++++++++++++++++++--- 1 file changed, 59 insertions(+), 5 deletions(-) diff --git a/apps/web/src/chat/threads-api.test.ts b/apps/web/src/chat/threads-api.test.ts index da57207ab..a16a9c789 100644 --- a/apps/web/src/chat/threads-api.test.ts +++ b/apps/web/src/chat/threads-api.test.ts @@ -1,8 +1,22 @@ -import { describe, expect, test } from "bun:test"; +import { afterEach, describe, expect, test } from "bun:test"; import { agentDeploySourceAssetName } from "../agent-deploy"; import { MYRA_SOURCE_CONFIG } from "../myra-source"; -import { displayAgentName, resolveAvatarName, type RoomParticipant } from "./threads-api"; +import { + displayAgentName, + listRoomParticipants, + resolveAvatarName, + type RoomParticipant, +} from "./threads-api"; + +const realFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = realFetch; +}); + +const json = (body: unknown) => + new Response(JSON.stringify(body), { headers: { "content-type": "application/json" } }); describe("displayAgentName", () => { test("renders Myra's fixed display name for her asset", () => { @@ -19,15 +33,55 @@ describe("displayAgentName", () => { }); }); +describe("listRoomParticipants", () => { + test("a person's address is their refId at the room's own domain, never email or bare refId", async () => { + globalThis.fetch = ((input: RequestInfo | URL) => { + const path = typeof input === "string" ? input : String(input); + if (path.includes("/principals")) { + return Promise.resolve( + json({ + data: [ + { + id: "prin_1", + kind: "user", + refId: "Mk9tHH", + displayName: "Alice", + email: "alice@example.com", + status: "active", + }, + ], + nextCursor: null, + }), + ); + } + if (path.includes("/workflows/deployments")) return Promise.resolve(json([])); + if (path.includes("/assets")) return Promise.resolve(json([])); + if (path.includes("/runs")) return Promise.resolve(json({ data: [], nextCursor: null })); + throw new Error(`unexpected fetch: ${path}`); + }) as typeof fetch; + + const participants = await listRoomParticipants("tnt_1", "room.example"); + expect(participants).toContainEqual({ + id: "prin_1", + kind: "person", + name: "Alice", + address: "Mk9tHH@room.example", + }); + }); +}); + describe("resolveAvatarName", () => { + // A person's roster address is `@`, mixed case as stored; + // the mailbox lowercases local parts on the wire, so a header `from` + // stays mixed case while the envelope `from` comes back lowercase. const participants: readonly RoomParticipant[] = [ - { id: "p1", kind: "person", name: "alice", address: "alice@example.com" }, + { id: "p1", kind: "person", name: "alice", address: "Mk9tHH@example.com" }, { id: "a1", kind: "agent", name: "Myra", address: "myra@example.com" }, ]; - test("uses the person's own real name for their own turn, never 'You'", () => { + test("matches the person's own turn by envelope address, case-insensitively", () => { const name = resolveAvatarName( - { author: "me", authorName: "You", address: "alice@example.com" }, + { author: "me", authorName: "You", address: "mk9thh@example.com" }, participants, ); expect(name).toBe("alice"); From e96185404ca67ca881be59c11cf3818cf038bdc4 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 18 Sep 2026 11:19:07 -0700 Subject: [PATCH 2/2] fix(web): a person's participant address is their mailbox address (CL-8581) --- apps/web/src/chat/threads-api.ts | 27 ++++++++++++++++++---- apps/web/src/pages/workbench-room-page.tsx | 10 ++++++-- apps/web/src/workbench-create.ts | 2 +- 3 files changed, 31 insertions(+), 8 deletions(-) diff --git a/apps/web/src/chat/threads-api.ts b/apps/web/src/chat/threads-api.ts index c2c1f3893..3ae01aced 100644 --- a/apps/web/src/chat/threads-api.ts +++ b/apps/web/src/chat/threads-api.ts @@ -626,8 +626,15 @@ const PrincipalPage = type({ /** Everyone in the room: the child tenant's principals plus its live * deployments' run addresses. A deployment's workflow principal only * appears after its first run, so the run listing is what makes an agent - * addressable from the moment it is deployed into the room. */ -export async function listRoomParticipants(tenantId: string): Promise { + * addressable from the moment it is deployed into the room. `tenantDomain` + * is the room tenant's own domain (already fetched by the caller) — a + * person's routable mailbox address is `@`, exactly + * what the hub builds and the mailbox delivers to; their email and refId + * alone are never routable. */ +export async function listRoomParticipants( + tenantId: string, + tenantDomain: string, +): Promise { const [page, chatAgents] = await Promise.all([ getJson(`/api/tenants/${encodeURIComponent(tenantId)}/principals?limit=100`, PrincipalPage), listChatAgents(tenantId), @@ -638,7 +645,7 @@ export async function listRoomParticipants(tenantId: string): Promise ({ id: agent.id, @@ -674,6 +681,14 @@ function authorName(address: string): string { return local.length > 0 ? local : address; } +/** Case-insensitive whole-address match: the mailbox lowercases local parts + * on the wire, so a sent message's header `from` (mixed case) and envelope + * `from` (lowercase) both name the same participant. Never lowercases + * stored data — comparison only. */ +export function sameAddress(a: string, b: string): boolean { + return a.toLowerCase() === b.toLowerCase(); +} + /** A turn's display name: the matching room participant's name, else the * address local part — never the raw run/email address. */ export function resolveParticipantName( @@ -682,7 +697,7 @@ export function resolveParticipantName( ): string { if (message.author === "me") return message.authorName; return ( - participants.find((participant) => participant.address === message.address)?.name ?? + participants.find((participant) => sameAddress(participant.address, message.address))?.name ?? message.authorName ); } @@ -695,7 +710,9 @@ export function resolveAvatarName( message: Pick, participants: readonly RoomParticipant[], ): string { - const matched = participants.find((participant) => participant.address === message.address); + const matched = participants.find((participant) => + sameAddress(participant.address, message.address), + ); return matched?.name ?? resolveParticipantName(message, participants); } diff --git a/apps/web/src/pages/workbench-room-page.tsx b/apps/web/src/pages/workbench-room-page.tsx index bda6b10a6..27b68d286 100644 --- a/apps/web/src/pages/workbench-room-page.tsx +++ b/apps/web/src/pages/workbench-room-page.tsx @@ -31,6 +31,7 @@ import { readRoom, resolveAvatarName, resolveParticipantName, + sameAddress, sendToRoom, subscribeToInbox, type RoomMessage, @@ -65,7 +66,9 @@ function RoomMessageRow({ // included, never the "You" transcript label — falling back to the // address local part a mail turn otherwise carries. const avatarName = resolveAvatarName(message, participants); - const matched = participants.find((participant) => participant.address === message.address); + const matched = participants.find((participant) => + sameAddress(participant.address, message.address), + ); const kind = message.author !== "me" && matched?.kind === "agent" ? "agent" : "person"; // The person's own send carries a trailing roster block so agents in the // room can hand off to each other; it's never something a person should @@ -242,7 +245,10 @@ function Room({ roomTenantId }: { readonly roomTenantId: string }) { }); const participants = useQuery({ queryKey: roomKeys.participants(roomTenantId), - queryFn: () => listRoomParticipants(roomTenantId), + queryFn: () => listRoomParticipants(roomTenantId, tenant.data?.domain ?? ""), + // The room tenant's domain is read first; a person's mailbox address + // depends on it, so participants wait for it rather than racing it. + enabled: tenant.data !== undefined, // Poll while any agent has no live run yet, so the room notices its own // redeploy finishing without a manual refresh. refetchInterval: (query) => diff --git a/apps/web/src/workbench-create.ts b/apps/web/src/workbench-create.ts index 96152f731..946bca12b 100644 --- a/apps/web/src/workbench-create.ts +++ b/apps/web/src/workbench-create.ts @@ -112,7 +112,7 @@ export async function createWorkbench(input: CreateWorkbenchInput): Promise participant.kind === "agent"); // A deployment's run address exists only once the deploy settles; a // room whose agent has not surfaced yet keeps the opening message