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/server/src/__tests__/agent-participants.test.ts b/packages/server/src/__tests__/agent-participants.test.ts index 13c3d3681..26390f2ec 100644 --- a/packages/server/src/__tests__/agent-participants.test.ts +++ b/packages/server/src/__tests__/agent-participants.test.ts @@ -45,6 +45,22 @@ describe("Agent Participants API", () => { expect(participants.map((p: { agentId: string }) => p.agentId)).toContain(a3.agent.uuid); }); + 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, { + 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); + }); + it("rejects request with neither agentId nor agentName", async () => { const app = getApp(); const { a1, chatId } = await setupChat(app);