From b53d4fd496a4d6f0ffe9edbddd292de00a54c763 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 18 Sep 2026 08:26:40 -0700 Subject: [PATCH 1/2] test(web): add round-trip test for room roster append/strip --- apps/web/src/chat/room-roster.test.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 apps/web/src/chat/room-roster.test.ts diff --git a/apps/web/src/chat/room-roster.test.ts b/apps/web/src/chat/room-roster.test.ts new file mode 100644 index 000000000..1f7483d22 --- /dev/null +++ b/apps/web/src/chat/room-roster.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "bun:test"; + +import { appendRoster, stripRoster } from "./room-roster"; + +describe("appendRoster / stripRoster", () => { + test("round-trips a message through append and strip", () => { + const body = "Please pass this to the scribe."; + const withRoster = appendRoster(body, [ + { name: "Scribe", address: "run_abc@room.example" }, + { name: "Myra", address: "run_def@room.example" }, + ]); + expect(withRoster).toBe( + "Please pass this to the scribe.\n\nParticipants:\nScribe \nMyra ", + ); + expect(stripRoster(withRoster)).toBe(body); + }); + + test("is a no-op with no entries", () => { + expect(appendRoster("hello", [])).toBe("hello"); + expect(stripRoster("hello")).toBe("hello"); + }); +}); From 3bb5904b980171920471b577568fe2279f28ab21 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 18 Sep 2026 08:26:46 -0700 Subject: [PATCH 2/2] feat(web): room mail carries a participant roster so agents can hand off The hub only delivers mail to run addresses, which only the client knows (a room's Participants panel already reads them off the deployment/run listing). Append them as a trailing Participants block on every room send, and strip that block back off before showing a person their own sent message. --- apps/web/src/chat/room-roster.ts | 36 ++++++++++++++++++++++ apps/web/src/chat/threads-api.ts | 17 +++++++--- apps/web/src/pages/workbench-room-page.tsx | 7 ++++- 3 files changed, 55 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/chat/room-roster.ts diff --git a/apps/web/src/chat/room-roster.ts b/apps/web/src/chat/room-roster.ts new file mode 100644 index 000000000..be54ec302 --- /dev/null +++ b/apps/web/src/chat/room-roster.ts @@ -0,0 +1,36 @@ +// A room mail send has no way to hand off between agents: the hub only +// delivers to run addresses, which only the client knows (a room's +// Participants panel already reads them off the deployment/run listing). +// Appending them as a trailing block on every room send gives every agent +// in the room everyone else's address, so one agent can mail another +// directly. `stripRoster` is the inverse, used only to keep the block out +// of what a person sees echoed back as their own sent message. + +const ROSTER_HEADING = "Participants:"; + +export type RosterEntry = { + readonly name: string; + readonly address: string; +}; + +/** The trailing block marker, including the blank-line separator that + * `stripRoster` looks for. */ +function rosterBlock(entries: readonly RosterEntry[]): string { + return [ROSTER_HEADING, ...entries.map((entry) => `${entry.name} <${entry.address}>`)].join("\n"); +} + +/** Appends a `Participants:` block listing every entry's name and address, + * separated from the message by a blank line. A no-op with no entries. */ +export function appendRoster(body: string, entries: readonly RosterEntry[]): string { + if (entries.length === 0) return body; + return `${body}\n\n${rosterBlock(entries)}`; +} + +/** Removes a trailing `Participants:` block appended by `appendRoster`, so + * a person's own sent message never shows it echoed back. A body with no + * such block is returned unchanged. */ +export function stripRoster(body: string): string { + const marker = `\n\n${ROSTER_HEADING}\n`; + const index = body.lastIndexOf(marker); + return index === -1 ? body : body.slice(0, index); +} diff --git a/apps/web/src/chat/threads-api.ts b/apps/web/src/chat/threads-api.ts index 0adf0d17d..f18240dff 100644 --- a/apps/web/src/chat/threads-api.ts +++ b/apps/web/src/chat/threads-api.ts @@ -19,6 +19,7 @@ import { reportError } from "@corbits/error-sink"; import { agentSlugFromSourceAssetName } from "../agent-deploy"; import { listTopLevelRuns } from "../agents-api"; import { MYRA_SOURCE_CONFIG } from "../myra-source"; +import { appendRoster } from "./room-roster"; export class ChatApiError extends Error { constructor( @@ -763,7 +764,10 @@ export function ancestorChain( /** The one send seam for a room: a single mailbox send addressed to every * agent in it. The hub triggers each addressed run and keeps the Sent * copy, so the person's own turn comes back out of the mailbox like any - * other. */ + * other. The body carries a trailing roster of every agent's name and + * run address (the same rows the Participants panel reads), so an agent + * can hand a task to another agent in the room — the hub only delivers to + * a run address, which only the client otherwise knows. */ export async function sendToRoom(input: { readonly roomTenantId: string; readonly agents: readonly RoomParticipant[]; @@ -771,10 +775,15 @@ export async function sendToRoom(input: { /** The turn this reply threads onto — a sub-thread's parent. */ readonly inReplyTo?: string; }): Promise { - const to = input.agents.map((agent) => agent.address).filter((address) => address.includes("@")); - if (to.length === 0) { + const live = input.agents.filter((agent) => agent.address.includes("@")); + if (live.length === 0) { throw new ChatApiError("No agent is in this workbench yet, so there is nobody to send to."); } + const to = live.map((agent) => agent.address); + const body = appendRoster( + input.content, + live.map((agent) => ({ name: agent.name, address: agent.address })), + ); let response: Response; try { response = await fetch(`${mailboxPath(input.roomTenantId)}/send`, { @@ -783,7 +792,7 @@ export async function sendToRoom(input: { body: JSON.stringify({ to, subject: input.content.slice(0, 60), - body: input.content, + body, ...(input.inReplyTo !== undefined ? { inReplyTo: input.inReplyTo } : {}), }), }); diff --git a/apps/web/src/pages/workbench-room-page.tsx b/apps/web/src/pages/workbench-room-page.tsx index 20cc5891c..6a4648da9 100644 --- a/apps/web/src/pages/workbench-room-page.tsx +++ b/apps/web/src/pages/workbench-room-page.tsx @@ -24,6 +24,7 @@ import { Composer } from "@/chat/composer"; import { Markdown } from "@/chat/markdown"; import { MessageAttachments } from "@/chat/message-attachments"; import { resolveMessagePackage } from "@/chat/deployable-package"; +import { stripRoster } from "@/chat/room-roster"; import { ancestorChain, listRoomParticipants, @@ -64,7 +65,11 @@ function RoomMessageRow({ const displayName = resolveParticipantName(message, participants); const matched = participants.find((participant) => participant.address === message.address); const kind = message.author !== "me" && matched?.kind === "agent" ? "agent" : "person"; - const { pkg, renderedBody } = resolveMessagePackage(message.attachments, message.body); + // 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 + // see echoed back at them. + const body = message.author === "me" ? stripRoster(message.body) : message.body; + const { pkg, renderedBody } = resolveMessagePackage(message.attachments, body); return (