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
22 changes: 22 additions & 0 deletions apps/web/src/chat/room-roster.test.ts
Original file line number Diff line number Diff line change
@@ -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 <run_abc@room.example>\nMyra <run_def@room.example>",
);
expect(stripRoster(withRoster)).toBe(body);
});

test("is a no-op with no entries", () => {
expect(appendRoster("hello", [])).toBe("hello");
expect(stripRoster("hello")).toBe("hello");
});
});
36 changes: 36 additions & 0 deletions apps/web/src/chat/room-roster.ts
Original file line number Diff line number Diff line change
@@ -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);
}
17 changes: 13 additions & 4 deletions apps/web/src/chat/threads-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -763,18 +764,26 @@ 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[];
readonly content: string;
/** The turn this reply threads onto — a sub-thread's parent. */
readonly inReplyTo?: string;
}): Promise<void> {
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`, {
Expand All @@ -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 } : {}),
}),
});
Expand Down
7 changes: 6 additions & 1 deletion apps/web/src/pages/workbench-room-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 (
<div className="chat-thread-message" data-author={message.author}>
<span className="shell-ch-avatar">
Expand Down
Loading