From d41a42ae548c6a5a437c85f51baef5e0a619870f Mon Sep 17 00:00:00 2001 From: Gandy2025 Date: Fri, 24 Jul 2026 15:10:43 +0800 Subject: [PATCH 1/2] feat: formalize human participant invites --- .../command-registration-smoke.test.ts | 13 ++++ apps/cli/src/commands/chat/invite.ts | 13 ++-- apps/cli/src/commands/chat/send.ts | 2 +- docs/cli-reference.md | 20 ++++-- .../src/__tests__/agent-briefing.test.ts | 3 + .../src/runtime/templates/agent-briefing.ejs | 2 +- packages/client/src/sdk.ts | 9 ++- .../src/__tests__/agent-participants.test.ts | 65 +++++++++++++++++++ .../src/__tests__/chat-service-edge.test.ts | 2 +- .../src/__tests__/participant-invite.test.ts | 2 +- packages/server/src/services/chat.ts | 2 +- .../server/src/services/participant-invite.ts | 4 +- packages/shared/src/schemas/chat.ts | 8 ++- 13 files changed, 119 insertions(+), 26 deletions(-) diff --git a/apps/cli/src/__tests__/command-registration-smoke.test.ts b/apps/cli/src/__tests__/command-registration-smoke.test.ts index e0243516b..c389a92c4 100644 --- a/apps/cli/src/__tests__/command-registration-smoke.test.ts +++ b/apps/cli/src/__tests__/command-registration-smoke.test.ts @@ -191,6 +191,19 @@ describe("CLI command registration", () => { expect(help).toContain("--pattern "); }); + it("documents chat invite as a silent human-or-agent participant change", () => { + const root = new Command(); + registerChatCommands(root); + + const invite = command(command(root, "chat"), "invite"); + const help = invite.helpInformation(); + + expect(invite.registeredArguments.map((argument) => argument.name())).toEqual(["participantName"]); + expect(help).toContain("human or agent"); + expect(help).toMatch(/does\s+not\s+send a message or wake the participant/); + expect(help).toMatch(/chat send\s+/); + }); + it("exposes read-only help for strict task-scoped Read activation", () => { const root = new Command(); registerTreeCommands(root); diff --git a/apps/cli/src/commands/chat/invite.ts b/apps/cli/src/commands/chat/invite.ts index 4543954ab..c0932f66d 100644 --- a/apps/cli/src/commands/chat/invite.ts +++ b/apps/cli/src/commands/chat/invite.ts @@ -4,12 +4,14 @@ import { createSdk, handleSdkError } from "../_shared/local-agent.js"; export function registerChatInviteCommand(chat: Command): void { chat - .command("invite ") + .command("invite ") .description( - "Invite an agent into the caller's current chat (the chat identified by FIRST_TREE_CHAT_ID). Use this for same-task handoffs before `chat send ` when the recipient is not yet a member.", + "Add an eligible active same-organization human or agent to the caller's current chat (identified by " + + "FIRST_TREE_CHAT_ID). This changes membership only: it does not send a message or wake the participant. " + + "Follow with `chat send ` when attention is required.", ) .option("--agent ", "Agent name on the First Tree server (default: first configured on this client)") - .action(async (agentName: string, options: { agent?: string }) => { + .action(async (participantName: string, options: { agent?: string }) => { try { const chatId = process.env.FIRST_TREE_CHAT_ID; if (!chatId) { @@ -20,7 +22,10 @@ export function registerChatInviteCommand(chat: Command): void { ); } const sdk = createSdk(options.agent); - const participants = await sdk.addChatParticipant(chatId, { agentName }); + // `agentName` is the retained wire-field name. The server resolves it + // against every active participant mirror in the chat's organization, + // including human members. + const participants = await sdk.addChatParticipant(chatId, { agentName: participantName }); success(participants); } catch (error) { handleSdkError(error); diff --git a/apps/cli/src/commands/chat/send.ts b/apps/cli/src/commands/chat/send.ts index c5d0b8c64..fb593b6f0 100644 --- a/apps/cli/src/commands/chat/send.ts +++ b/apps/cli/src/commands/chat/send.ts @@ -21,7 +21,7 @@ export function registerChatSendCommand(chat: Command): void { .command("send [name] [message]") .description( "Send a message into the caller's current chat (FIRST_TREE_CHAT_ID). is any participant — agent or " + - "human; the recipient is @mentioned and woken (must already be a participant — `chat invite` an agent " + + "human; the recipient is @mentioned and woken (must already be a participant — `chat invite` them " + "first). A plain send to a human is informational only — a free reply or report they can read and move " + "on from; any question your next step depends on goes through `chat ask` (a send never carries a " + "blocking question). Report progress with `chat update --description`. A message must name a recipient " + diff --git a/docs/cli-reference.md b/docs/cli-reference.md index fbb44c79a..b015578b1 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -345,7 +345,7 @@ first-tree chat │ --multi-select # allow picking more than one option (requires --options) │ # always a fresh top-level question — no threading, and no resolve flag │ # (the human answers in the web UI; an agent can only ASK) -├── invite # add to FIRST_TREE_CHAT_ID before same-task send +├── invite # silently add an eligible active human or agent ├── list ├── history ├── update # update topic and/or description (each independently) @@ -462,10 +462,12 @@ first-tree chat ask alice --message-file ask-body.md # question is simply left open (the human works open questions oldest-first), and # re-asking opens a NEW, independent question. -# Pull a non-member into the current chat first, then send normally. Use this -# for same-task stage / role handoffs. -first-tree chat invite code-agent -first-tree chat send code-agent "now we can talk" +# Add an eligible active same-organization human or agent to the current chat, +# subject to existing visibility and ownership rules, then send +# when they should be woken. The invite itself changes membership only: it does +# not write a message or wake the participant. +first-tree chat invite alice +first-tree chat send alice "now we can talk" # Browse first-tree chat list @@ -505,14 +507,18 @@ first-tree chat open code-agent `chat send` / `chat invite` operate on the chat identified by `FIRST_TREE_CHAT_ID`, which the runtime injects into the agent's session environment. The recipient must be a participant of that chat; if not, -`invite` first. +`invite` first. `chat invite` accepts an eligible active same-organization +human or non-human agent, subject to existing visibility and ownership rules, +and adds them silently; use an addressed `chat send` afterward when attention +is required. `chat create` is different: it creates a new task chat and writes the first message in one command. Use it to split genuinely new work into a fresh chat. Use `chat send` for replies/status in the current chat, and `chat invite` when you want to add a non-member to the current chat before sending there. A same-task handoff, such as architect to developer or developer to reviewer, -stays in the current chat; invite the next agent and send the handoff there. +stays in the current chat; invite the next participant and send the handoff +there. Ordinary task creation is intentionally not idempotent. There is no operation id, and the CLI does not automatically retry it. If an ordinary create reports diff --git a/packages/client/src/__tests__/agent-briefing.test.ts b/packages/client/src/__tests__/agent-briefing.test.ts index 28e464234..2a9d72d54 100644 --- a/packages/client/src/__tests__/agent-briefing.test.ts +++ b/packages/client/src/__tests__/agent-briefing.test.ts @@ -401,6 +401,9 @@ describe("buildAgentBriefing — Working in First Tree hard rules", () => { expect(briefing).toContain("-F -"); expect(briefing).toContain("--description -"); expect(briefing).toContain("never `JSON.stringify`"); + expect(briefing).toContain("first-tree chat invite "); + expect(briefing).toContain("eligible active same-organization human or agent"); + expect(briefing).toContain("adds them silently"); }); it("interpolates the working directory and worktree paths", () => { diff --git a/packages/client/src/runtime/templates/agent-briefing.ejs b/packages/client/src/runtime/templates/agent-briefing.ejs index f9fc981b0..659e0a265 100644 --- a/packages/client/src/runtime/templates/agent-briefing.ejs +++ b/packages/client/src/runtime/templates/agent-briefing.ejs @@ -160,7 +160,7 @@ reply transport for a human-directed turn. | Human asks / reports something and no answer is needed from them | `<%- bin %> chat send -f markdown -F ` | Send exactly one self-contained reply before ending the turn. | | Your next step depends on a human decision, approval, or answer | `<%- bin %> chat ask -F ` | Blocking questions never ride inside plain `chat send`; route by dependency, not importance. | | Progress/status during longer work | `<%- bin %> chat update --description -` | Update status instead of streaming repeated plain sends. | -| Make another agent act | `<%- bin %> chat send -F ` | Invite the agent first if needed; keep stage handoffs in this chat. | +| Make another agent act | `<%- bin %> chat send -F ` | Add a missing participant first with `<%- bin %> chat invite `; it accepts an eligible active same-organization human or agent and adds them silently. Then send to wake them; keep stage handoffs in this chat. | | Agent wake-up with nothing new to act on | no send | Do not send courtesy acknowledgements to agents. | Replying to a human is required, not optional. The `no send` case applies diff --git a/packages/client/src/sdk.ts b/packages/client/src/sdk.ts index 1dc85b4e1..c52ef0319 100644 --- a/packages/client/src/sdk.ts +++ b/packages/client/src/sdk.ts @@ -696,11 +696,10 @@ export class FirstTreeHubSDK { } /** - * Add a participant to a chat by uuid or by name. Names resolve within the - * chat's organization. Idempotent: re-adding an existing speaker returns - * the chat's current participant list (the server treats it as a conflict - * the caller can safely ignore — see `chat invite` CLI for the - * UX wrapper that swallows that case). + * Add a human or non-human participant to a chat by uuid or by name. Names + * resolve within the chat's organization. The legacy wire fields remain + * `agentId` / `agentName` because human members use agent mirror rows. + * Re-adding an existing speaker returns a conflict. */ async addChatParticipant( chatId: string, diff --git a/packages/server/src/__tests__/agent-participants.test.ts b/packages/server/src/__tests__/agent-participants.test.ts index 13c3d3681..0d26b8232 100644 --- a/packages/server/src/__tests__/agent-participants.test.ts +++ b/packages/server/src/__tests__/agent-participants.test.ts @@ -1,5 +1,6 @@ import type { FastifyInstance } from "fastify"; import { describe, expect, it } from "vitest"; +import * as memberService from "../services/member.js"; import { createTestAgent, useTestApp } from "./helpers.js"; describe("Agent Participants API", () => { @@ -45,6 +46,70 @@ describe("Agent Participants API", () => { expect(participants.map((p: { agentId: string }) => p.agentId)).toContain(a3.agent.uuid); }); + it("adds an active human by name without writing a message, then allows addressed delivery", async () => { + const app = getApp(); + const { a1, chatId } = await setupChat(app); + const human = await createTestAgent(app, { + type: "human", + name: `part-human-${crypto.randomUUID().slice(0, 6)}`, + }); + if (!human.agent.name) throw new Error("human participant name missing"); + + const addRes = await a1.request("POST", `/api/v1/agent/chats/${chatId}/participants`, { + agentName: human.agent.name, + }); + expect(addRes.statusCode).toBe(201); + expect(addRes.json().map((p: { agentId: string }) => p.agentId)).toContain(human.agent.uuid); + + const userChatRes = await app.inject({ + method: "GET", + url: `/api/v1/chats/${chatId}`, + headers: { authorization: `Bearer ${human.accessToken}` }, + }); + expect(userChatRes.statusCode).toBe(200); + + const historyBeforeSend = await human.request("GET", `/api/v1/agent/chats/${chatId}/messages`); + expect(historyBeforeSend.statusCode).toBe(200); + expect(historyBeforeSend.json().items).toHaveLength(0); + const inboxBeforeSend = await human.request("GET", "/api/v1/agent/inbox"); + expect(inboxBeforeSend.statusCode).toBe(200); + expect(inboxBeforeSend.json()).toHaveLength(0); + + const sendRes = await a1.request("POST", `/api/v1/agent/chats/${chatId}/messages`, { + format: "text", + content: "Human participant delivery", + receiverNames: [human.agent.name], + }); + expect(sendRes.statusCode).toBe(201); + + const inboxRes = await human.request("GET", "/api/v1/agent/inbox"); + expect(inboxRes.statusCode).toBe(200); + expect( + inboxRes + .json() + .some((entry: { message: { content: string } }) => + entry.message.content.includes("Human participant delivery"), + ), + ).toBe(true); + }); + + it("rejects a removed human by name through the agent invite path", async () => { + const app = getApp(); + const { a1, chatId } = await setupChat(app); + const human = await createTestAgent(app, { + type: "human", + name: `part-removed-${crypto.randomUUID().slice(0, 6)}`, + }); + if (!human.agent.name) throw new Error("removed human participant name missing"); + await memberService.deleteMember(app.db, human.memberId, human.organizationId); + + const res = await a1.request("POST", `/api/v1/agent/chats/${chatId}/participants`, { + agentName: human.agent.name, + }); + expect(res.statusCode).toBe(400); + expect(res.json<{ error: string }>().error).toContain("Inactive participant"); + }); + it("rejects request with neither agentId nor agentName", async () => { const app = getApp(); const { a1, chatId } = await setupChat(app); diff --git a/packages/server/src/__tests__/chat-service-edge.test.ts b/packages/server/src/__tests__/chat-service-edge.test.ts index 5138fad03..8a9497aad 100644 --- a/packages/server/src/__tests__/chat-service-edge.test.ts +++ b/packages/server/src/__tests__/chat-service-edge.test.ts @@ -458,7 +458,7 @@ describe("chat service edge coverage", () => { addParticipant(app.db, chat.id, seed.humanAgentUuid, { agentName: newcomer.name ?? "" }), ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ agentId: newcomer.uuid })])); await expect(addParticipant(app.db, chat.id, seed.humanAgentUuid, {} as never)).rejects.toThrow( - 'Agent "(unknown)" not found', + 'Participant "(unknown)" not found', ); }); diff --git a/packages/server/src/__tests__/participant-invite.test.ts b/packages/server/src/__tests__/participant-invite.test.ts index 7495553b0..56a677e5b 100644 --- a/packages/server/src/__tests__/participant-invite.test.ts +++ b/packages/server/src/__tests__/participant-invite.test.ts @@ -83,7 +83,7 @@ describe("inviteParticipantsToChat", () => { targetAgentIds: ["00000000-0000-0000-0000-000000000000"], errorOnAlreadySpeaker: true, }), - ).rejects.toThrow(/Agents not found/); + ).rejects.toThrow(/Participants not found/); }); it("rejects removed human mirrors as explicit invite targets", async () => { diff --git a/packages/server/src/services/chat.ts b/packages/server/src/services/chat.ts index 21087a3c1..74625c97c 100644 --- a/packages/server/src/services/chat.ts +++ b/packages/server/src/services/chat.ts @@ -1141,7 +1141,7 @@ export async function addParticipant(db: Database, chatId: string, requesterId: const [targetAgent] = await db.select({ id: agents.uuid }).from(agents).where(targetSelector).limit(1); if (!targetAgent) { const ref = data.agentId ?? data.agentName ?? "(unknown)"; - throw new NotFoundError(`Agent "${ref}" not found`); + throw new NotFoundError(`Participant "${ref}" not found`); } await inviteParticipantsToChat(db, { diff --git a/packages/server/src/services/participant-invite.ts b/packages/server/src/services/participant-invite.ts index 12314e6a4..23337d789 100644 --- a/packages/server/src/services/participant-invite.ts +++ b/packages/server/src/services/participant-invite.ts @@ -230,7 +230,7 @@ export async function inviteParticipantsToChat(db: Database, args: InvitePartici if (targetRows.length !== distinctTargets.length) { const foundSet = new Set(targetRows.map((r) => r.uuid)); const missing = distinctTargets.filter((id) => !foundSet.has(id)); - throw new BadRequestError(`Agents not found: ${missing.join(", ")}`); + throw new BadRequestError(`Participants not found: ${missing.join(", ")}`); } const crossOrg = targetRows.filter((t) => t.organizationId !== chat.organizationId); if (crossOrg.length > 0) { @@ -286,7 +286,7 @@ export async function inviteParticipantsToChat(db: Database, args: InvitePartici if (errorOnAlreadySpeaker) { const firstDup = distinctTargets.find((id) => existingSpeakerSet.has(id)); if (firstDup !== undefined) { - throw new ConflictError(`Agent "${firstDup}" is already a participant`); + throw new ConflictError(`Participant "${firstDup}" is already a participant`); } } const toWrite = distinctTargets.filter((id) => !existingSpeakerSet.has(id)); diff --git a/packages/shared/src/schemas/chat.ts b/packages/shared/src/schemas/chat.ts index 755f2c2b9..9920b1d43 100644 --- a/packages/shared/src/schemas/chat.ts +++ b/packages/shared/src/schemas/chat.ts @@ -231,9 +231,11 @@ export type UpdateChat = z.infer; * §3.2 / §6. */ /** - * Identify the target by uuid (`agentId`) or by name (`agentName`). Names are - * resolved server-side within the chat's organization. Exactly one field - * must be supplied — both or neither is a 400. + * Identify the human or non-human participant by uuid (`agentId`) or by name + * (`agentName`). The field names are retained for wire compatibility because + * human members use agent mirror rows. Names resolve server-side within the + * chat's organization. Exactly one field must be supplied — both or neither + * is a 400. */ export const addParticipantSchema = z .object({ From 83379f462c3fd4bfd201b856a224fc9aba352c84 Mon Sep 17 00:00:00 2001 From: Gandy2025 Date: Fri, 24 Jul 2026 15:40:10 +0800 Subject: [PATCH 2/2] fix: narrow human invite change to public contract --- packages/client/src/sdk.ts | 9 ++-- .../src/__tests__/agent-participants.test.ts | 51 +------------------ .../src/__tests__/chat-service-edge.test.ts | 2 +- .../src/__tests__/participant-invite.test.ts | 2 +- packages/server/src/services/chat.ts | 2 +- .../server/src/services/participant-invite.ts | 4 +- packages/shared/src/schemas/chat.ts | 8 ++- 7 files changed, 14 insertions(+), 64 deletions(-) diff --git a/packages/client/src/sdk.ts b/packages/client/src/sdk.ts index c52ef0319..1dc85b4e1 100644 --- a/packages/client/src/sdk.ts +++ b/packages/client/src/sdk.ts @@ -696,10 +696,11 @@ export class FirstTreeHubSDK { } /** - * Add a human or non-human participant to a chat by uuid or by name. Names - * resolve within the chat's organization. The legacy wire fields remain - * `agentId` / `agentName` because human members use agent mirror rows. - * Re-adding an existing speaker returns a conflict. + * Add a participant to a chat by uuid or by name. Names resolve within the + * chat's organization. Idempotent: re-adding an existing speaker returns + * the chat's current participant list (the server treats it as a conflict + * the caller can safely ignore — see `chat invite` CLI for the + * UX wrapper that swallows that case). */ async addChatParticipant( chatId: string, diff --git a/packages/server/src/__tests__/agent-participants.test.ts b/packages/server/src/__tests__/agent-participants.test.ts index 0d26b8232..26390f2ec 100644 --- a/packages/server/src/__tests__/agent-participants.test.ts +++ b/packages/server/src/__tests__/agent-participants.test.ts @@ -1,6 +1,5 @@ import type { FastifyInstance } from "fastify"; import { describe, expect, it } from "vitest"; -import * as memberService from "../services/member.js"; import { createTestAgent, useTestApp } from "./helpers.js"; describe("Agent Participants API", () => { @@ -46,7 +45,7 @@ describe("Agent Participants API", () => { expect(participants.map((p: { agentId: string }) => p.agentId)).toContain(a3.agent.uuid); }); - it("adds an active human by name without writing a message, then allows addressed delivery", async () => { + it("adds an active human by name (chat invite CLI path)", async () => { const app = getApp(); const { a1, chatId } = await setupChat(app); const human = await createTestAgent(app, { @@ -60,54 +59,6 @@ describe("Agent Participants API", () => { }); expect(addRes.statusCode).toBe(201); expect(addRes.json().map((p: { agentId: string }) => p.agentId)).toContain(human.agent.uuid); - - const userChatRes = await app.inject({ - method: "GET", - url: `/api/v1/chats/${chatId}`, - headers: { authorization: `Bearer ${human.accessToken}` }, - }); - expect(userChatRes.statusCode).toBe(200); - - const historyBeforeSend = await human.request("GET", `/api/v1/agent/chats/${chatId}/messages`); - expect(historyBeforeSend.statusCode).toBe(200); - expect(historyBeforeSend.json().items).toHaveLength(0); - const inboxBeforeSend = await human.request("GET", "/api/v1/agent/inbox"); - expect(inboxBeforeSend.statusCode).toBe(200); - expect(inboxBeforeSend.json()).toHaveLength(0); - - const sendRes = await a1.request("POST", `/api/v1/agent/chats/${chatId}/messages`, { - format: "text", - content: "Human participant delivery", - receiverNames: [human.agent.name], - }); - expect(sendRes.statusCode).toBe(201); - - const inboxRes = await human.request("GET", "/api/v1/agent/inbox"); - expect(inboxRes.statusCode).toBe(200); - expect( - inboxRes - .json() - .some((entry: { message: { content: string } }) => - entry.message.content.includes("Human participant delivery"), - ), - ).toBe(true); - }); - - it("rejects a removed human by name through the agent invite path", async () => { - const app = getApp(); - const { a1, chatId } = await setupChat(app); - const human = await createTestAgent(app, { - type: "human", - name: `part-removed-${crypto.randomUUID().slice(0, 6)}`, - }); - if (!human.agent.name) throw new Error("removed human participant name missing"); - await memberService.deleteMember(app.db, human.memberId, human.organizationId); - - const res = await a1.request("POST", `/api/v1/agent/chats/${chatId}/participants`, { - agentName: human.agent.name, - }); - expect(res.statusCode).toBe(400); - expect(res.json<{ error: string }>().error).toContain("Inactive participant"); }); it("rejects request with neither agentId nor agentName", async () => { diff --git a/packages/server/src/__tests__/chat-service-edge.test.ts b/packages/server/src/__tests__/chat-service-edge.test.ts index 8a9497aad..5138fad03 100644 --- a/packages/server/src/__tests__/chat-service-edge.test.ts +++ b/packages/server/src/__tests__/chat-service-edge.test.ts @@ -458,7 +458,7 @@ describe("chat service edge coverage", () => { addParticipant(app.db, chat.id, seed.humanAgentUuid, { agentName: newcomer.name ?? "" }), ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ agentId: newcomer.uuid })])); await expect(addParticipant(app.db, chat.id, seed.humanAgentUuid, {} as never)).rejects.toThrow( - 'Participant "(unknown)" not found', + 'Agent "(unknown)" not found', ); }); diff --git a/packages/server/src/__tests__/participant-invite.test.ts b/packages/server/src/__tests__/participant-invite.test.ts index 56a677e5b..7495553b0 100644 --- a/packages/server/src/__tests__/participant-invite.test.ts +++ b/packages/server/src/__tests__/participant-invite.test.ts @@ -83,7 +83,7 @@ describe("inviteParticipantsToChat", () => { targetAgentIds: ["00000000-0000-0000-0000-000000000000"], errorOnAlreadySpeaker: true, }), - ).rejects.toThrow(/Participants not found/); + ).rejects.toThrow(/Agents not found/); }); it("rejects removed human mirrors as explicit invite targets", async () => { diff --git a/packages/server/src/services/chat.ts b/packages/server/src/services/chat.ts index 74625c97c..21087a3c1 100644 --- a/packages/server/src/services/chat.ts +++ b/packages/server/src/services/chat.ts @@ -1141,7 +1141,7 @@ export async function addParticipant(db: Database, chatId: string, requesterId: const [targetAgent] = await db.select({ id: agents.uuid }).from(agents).where(targetSelector).limit(1); if (!targetAgent) { const ref = data.agentId ?? data.agentName ?? "(unknown)"; - throw new NotFoundError(`Participant "${ref}" not found`); + throw new NotFoundError(`Agent "${ref}" not found`); } await inviteParticipantsToChat(db, { diff --git a/packages/server/src/services/participant-invite.ts b/packages/server/src/services/participant-invite.ts index 23337d789..12314e6a4 100644 --- a/packages/server/src/services/participant-invite.ts +++ b/packages/server/src/services/participant-invite.ts @@ -230,7 +230,7 @@ export async function inviteParticipantsToChat(db: Database, args: InvitePartici if (targetRows.length !== distinctTargets.length) { const foundSet = new Set(targetRows.map((r) => r.uuid)); const missing = distinctTargets.filter((id) => !foundSet.has(id)); - throw new BadRequestError(`Participants not found: ${missing.join(", ")}`); + throw new BadRequestError(`Agents not found: ${missing.join(", ")}`); } const crossOrg = targetRows.filter((t) => t.organizationId !== chat.organizationId); if (crossOrg.length > 0) { @@ -286,7 +286,7 @@ export async function inviteParticipantsToChat(db: Database, args: InvitePartici if (errorOnAlreadySpeaker) { const firstDup = distinctTargets.find((id) => existingSpeakerSet.has(id)); if (firstDup !== undefined) { - throw new ConflictError(`Participant "${firstDup}" is already a participant`); + throw new ConflictError(`Agent "${firstDup}" is already a participant`); } } const toWrite = distinctTargets.filter((id) => !existingSpeakerSet.has(id)); diff --git a/packages/shared/src/schemas/chat.ts b/packages/shared/src/schemas/chat.ts index 9920b1d43..755f2c2b9 100644 --- a/packages/shared/src/schemas/chat.ts +++ b/packages/shared/src/schemas/chat.ts @@ -231,11 +231,9 @@ export type UpdateChat = z.infer; * §3.2 / §6. */ /** - * Identify the human or non-human participant by uuid (`agentId`) or by name - * (`agentName`). The field names are retained for wire compatibility because - * human members use agent mirror rows. Names resolve server-side within the - * chat's organization. Exactly one field must be supplied — both or neither - * is a 400. + * Identify the target by uuid (`agentId`) or by name (`agentName`). Names are + * resolved server-side within the chat's organization. Exactly one field + * must be supplied — both or neither is a 400. */ export const addParticipantSchema = z .object({