Skip to content
Open
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
13 changes: 13 additions & 0 deletions apps/cli/src/__tests__/command-registration-smoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,19 @@ describe("CLI command registration", () => {
expect(help).toContain("--pattern <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+<participantName>/);
});

it("exposes read-only help for strict task-scoped Read activation", () => {
const root = new Command();
registerTreeCommands(root);
Expand Down
13 changes: 9 additions & 4 deletions apps/cli/src/commands/chat/invite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@ import { createSdk, handleSdkError } from "../_shared/local-agent.js";

export function registerChatInviteCommand(chat: Command): void {
chat
.command("invite <agentName>")
.command("invite <participantName>")
.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 <agentName>` 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 <participantName>` when attention is required.",
)
.option("--agent <name>", "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) {
Expand All @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/src/commands/chat/send.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). <name> 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 " +
Expand Down
20 changes: 13 additions & 7 deletions docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <agentName> # add to FIRST_TREE_CHAT_ID before same-task send
├── invite <participantName> # silently add an eligible active human or agent
├── list
├── history <chatId>
├── update # update topic and/or description (each independently)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions packages/client/src/__tests__/agent-briefing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <participant>");
expect(briefing).toContain("eligible active same-organization human or agent");
expect(briefing).toContain("adds them silently");
});

it("interpolates the working directory and worktree paths", () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/client/src/runtime/templates/agent-briefing.ejs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <human> -f markdown -F <file>` | Send exactly one self-contained reply before ending the turn. |
| Your next step depends on a human decision, approval, or answer | `<%- bin %> chat ask <human> -F <file>` | 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 <agent> -F <file>` | Invite the agent first if needed; keep stage handoffs in this chat. |
| Make another agent act | `<%- bin %> chat send <agent> -F <file>` | Add a missing participant first with `<%- bin %> chat invite <participant>`; 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
Expand Down
16 changes: 16 additions & 0 deletions packages/server/src/__tests__/agent-participants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading