Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 59 additions & 5 deletions apps/web/src/chat/threads-api.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand All @@ -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 `<refId>@<domain>`, 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");
Expand Down
27 changes: 22 additions & 5 deletions apps/web/src/chat/threads-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<readonly RoomParticipant[]> {
* 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 `<refId>@<tenantDomain>`, 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<readonly RoomParticipant[]> {
const [page, chatAgents] = await Promise.all([
getJson(`/api/tenants/${encodeURIComponent(tenantId)}/principals?limit=100`, PrincipalPage),
listChatAgents(tenantId),
Expand All @@ -638,7 +645,7 @@ export async function listRoomParticipants(tenantId: string): Promise<readonly R
id: principal.id,
kind: "person",
name: principal.displayName,
address: principal.email ?? principal.refId,
address: `${principal.refId}@${tenantDomain}`,
}));
const agents = chatAgents.map((agent): RoomParticipant => ({
id: agent.id,
Expand Down Expand Up @@ -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(
Expand All @@ -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
);
}
Expand All @@ -695,7 +710,9 @@ export function resolveAvatarName(
message: Pick<RoomMessage, "author" | "authorName" | "address">,
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);
}

Expand Down
10 changes: 8 additions & 2 deletions apps/web/src/pages/workbench-room-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
readRoom,
resolveAvatarName,
resolveParticipantName,
sameAddress,
sendToRoom,
subscribeToInbox,
type RoomMessage,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) =>
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/workbench-create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ export async function createWorkbench(input: CreateWorkbenchInput): Promise<stri

if (input.openingMessage !== undefined && input.openingMessage !== "") {
try {
const participants = await listRoomParticipants(tenantId);
const participants = await listRoomParticipants(tenantId, domain);
const agents = participants.filter((participant) => 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
Expand Down
Loading