diff --git a/cli/src/__tests__/chat-cli-guidance.test.ts b/cli/src/__tests__/chat-cli-guidance.test.ts new file mode 100644 index 00000000000..2abceaee0d5 --- /dev/null +++ b/cli/src/__tests__/chat-cli-guidance.test.ts @@ -0,0 +1,80 @@ +import { readFileSync } from "node:fs"; +import { Command } from "commander"; +import { describe, expect, it } from "vitest"; +import { registerClientAuthCommands } from "../commands/client/auth.js"; +import { registerCompanyCommands } from "../commands/client/company.js"; +import { registerContextCommands } from "../commands/client/context.js"; +import { registerIssueCommands } from "../commands/client/issue.js"; +import { registerRunCommands } from "../commands/client/run.js"; + +// Chat agents copy these commands into their answers verbatim, so every +// command and flag the chat guidance names must exist in this CLI. +const guidance = readFileSync(new URL("../../../server/src/services/agent-conversations.ts", import.meta.url), "utf8"); + +function cliProgram() { + const program = new Command(); + registerContextCommands(program); + registerIssueCommands(program); + registerCompanyCommands(program); + registerRunCommands(program.command("run")); + registerClientAuthCommands(program.command("auth")); + return program; +} + +/** Resolves the command at the start of a guidance sentence; returns its path and the unknown part, if any. */ +function resolve(program: Command, text: string): { path: string; error?: string } { + const tokens = (text.match(/<[^<>]+>|\S+/g) ?? []).map(token => token.replace(/[.,;:]$/, "")); + const path: string[] = []; + const result = (error?: string) => ({ path: path.join(" "), error }); + let command = program, index = 0; + while (index < tokens.length) { + const next = command.commands.find(child => child.name() === tokens[index]); + if (!next) break; + command = next; path.push(next.name()); index += 1; + } + if (command === program) return result(`unknown command: ${tokens[0]}`); + if (command.commands.length > 0) return result(`unknown subcommand ${tokens[index]} of ${command.name()}`); + let argumentsLeft = command.registeredArguments.length; + const given = new Set(); + while (index < tokens.length) { + const token = tokens[index]!; + if (token.startsWith("--")) { + const option = command.options.find(row => row.long === token); + if (!option) return result(`unknown flag ${token} for ${command.name()}`); + given.add(token); + if (option.required || option.optional) { + const value = tokens[index + 1]; + if (option.required && (!value || value.startsWith("-"))) return result(`missing value for ${token}`); + index += value && !value.startsWith("-") ? 2 : 1; + } else index += 1; + } else if (token.startsWith("<") && argumentsLeft > 0) { + argumentsLeft -= 1; index += 1; + } else break; + } + if (argumentsLeft > 0) return result(`missing arguments for ${command.name()}`); + const missing = command.options.find(option => option.mandatory && !given.has(option.long!)); + return result(missing ? `missing required ${missing.long} for ${command.name()}` : undefined); +} + +describe("chat CLI guidance", () => { + const program = cliProgram(); + const commands = guidance.split("npx paperclipai ").slice(1).map(rest => resolve(program, rest.slice(0, 400))); + + it("names the commands a person runs for tasks, documents, runs and context", () => { + const paths = commands.map(row => row.path); + for (const command of ["issue create", "issue document:get", "run get", "run cancel", "context set", "context show", "company current", "auth whoami"]) + expect(paths).toContain(command); + }); + + it("names only commands, arguments and flags this CLI supports", () => { + for (const command of commands) expect(command.error, command.path).toBeUndefined(); + }); + + it("detects an invented command or flag", () => { + expect(resolve(program, "run cancel --force now").error).toMatch(/unknown flag --force/); + expect(resolve(program, "context select ").error).toMatch(/unknown subcommand select/); + expect(resolve(program, "issue document:get and more").error).toMatch(/missing arguments/); + expect(resolve(program, "context set --profile --use").error).toMatch(/missing value for --profile/); + expect(resolve(program, "issue create --project-id creates").error).toMatch(/missing required --company-id/); + }); +}); diff --git a/server/src/__tests__/agent-conversations.test.ts b/server/src/__tests__/agent-conversations.test.ts index 10200bdcaaf..3e790748852 100644 --- a/server/src/__tests__/agent-conversations.test.ts +++ b/server/src/__tests__/agent-conversations.test.ts @@ -1005,6 +1005,17 @@ describe("chat prompt policy", () => { expect(prompt).toContain("GET /api/teams/catalog"); }); + it("tells chat to answer terminal requests with the person's own CLI commands", () => { + expect(AGENT_CHAT_DIRECTIVE).toContain("they mean their own terminal: give them commands using the Paperclip CLI, npx paperclipai"); + expect(AGENT_CHAT_DIRECTIVE).toContain("instead of doing the work yourself, saving a file in your workspace or writing curl"); + expect(AGENT_CHAT_DIRECTIVE).toContain("Fill in values you can read here, such as this company's id"); + expect(AGENT_CHAT_DIRECTIVE).toContain("Write the Paperclip address as "); + expect(AGENT_CHAT_DIRECTIVE).toContain("npx paperclipai run cancel stops a queued or running one (board users only)"); + expect(AGENT_CHAT_DIRECTIVE).toContain("a command whose output is piped cannot open the sign-in prompt"); + const prompt = buildPaperclipTaskMarkdown({ issue: { id: "chat", identifier: "RUN-1", title: "Chat", workMode: "ask", conversationAgentId: "agent" } }); + expect(prompt).toContain("npx paperclipai issue document:get --json | jq -r .body > "); + }); + it("tells chat where a person can hire when the agent cannot", () => { expect(AGENT_CHAT_DIRECTIVE).toContain("send the person to the hire form at //agents/new"); expect(AGENT_CHAT_DIRECTIVE).toContain("to your own /permissions page, where they can turn on hiring for you"); diff --git a/server/src/services/agent-conversations.ts b/server/src/services/agent-conversations.ts index 18856d832be..9bbf648df33 100644 --- a/server/src/services/agent-conversations.ts +++ b/server/src/services/agent-conversations.ts @@ -125,6 +125,8 @@ Some developer labs, including the task-chat interaction lab at //dev/ta Ready-made teams are installable packages in Paperclip's teams catalog, not agents already in this company. When the person asks whether a ready-made team exists, or refers to one, list the catalog with GET /api/teams/catalog and choose by its descriptions and roles, since teams are not named after every kind of project. When you recommend a team, name its roles (agentSlugs), every required skill (requiredSkills, by its ref) and every configuration input (envInputs), and say that a catalog listing does not mean the team's accounts are configured. The catalog is the only source of ready-made teams: do not look for others in package registries, repositories or the web. A team chosen earlier in this chat may be recorded in its task context (get_task_context). POST /api/companies/{companyId}/teams/catalog/{catalogId}/preview shows what an install would add and creates no resources. Installing needs a board user or an agent with permission to create agents. The web app has no teams catalog page, so do not link one: name the team and its id, and when you cannot install, tell the person that a board user can run paperclipai teams install --company-id . After an install, link the installed agents and projects at their own pages. +When the person asks for a terminal or CLI command, or to do something from the terminal, they mean their own terminal: give them commands using the Paperclip CLI, npx paperclipai, instead of doing the work yourself, saving a file in your workspace or writing curl. Put them in one fenced shell block, one complete command per line in the order to run, with no shell conditionals or alternative versions; say in words when a later command depends on an earlier result. The CLI acts as whoever that terminal is signed in as, normally the person's own Board identity, not you, so a control your credential is denied can still be theirs: say which commands need a board user. When the person may not have signed that terminal in, say in a sentence to run npx paperclipai auth login --api-base first, because a command whose output is piped cannot open the sign-in prompt; a PAPERCLIP_API_KEY set in that terminal, such as an agent key, is used instead of their sign-in, and npx paperclipai auth whoami shows who the commands act as. Fill in values you can read here, such as this company's id and task, agent, project and run ids, instead of placeholders. Write the Paperclip address as , the address the person opens Paperclip at; do not look it up. Supported commands: npx paperclipai issue create --company-id --title --project-id <projectId> --assignee-agent-id <agentId> creates a task. npx paperclipai issue document:get <task id or identifier> <key> --json | jq -r .body > <file name> saves a task document, such as plan, as a file. npx paperclipai run get <runId> reads a run, and npx paperclipai run cancel <runId> stops a queued or running one (board users only). npx paperclipai context set --profile <name> --api-base <your Paperclip URL> --company-id <companyId> --use saves and selects a named profile without changing the person's other profiles, and npx paperclipai context show, npx paperclipai company current and npx paperclipai auth whoami confirm the selected company and identity before any write. Do not invent other flags. + This conversation and your credentials belong to one company. A task identifier whose prefix differs from this conversation's prefix belongs to another company or to none, and you cannot read it here, even when the person can open it themselves: a lookup that is not found does not mean the task does not exist. Do not search for it further, guess its content or copy anything from it. Say that this chat can only work in this company, and that to use that task the person can switch to its company and open a chat there. Link the task at /<its prefix>/issues/<identifier>, taking the prefix from the identifier the person gave; this is the one link that does not use this conversation's prefix. Tasks and projects in this company can be private. A private task is hidden from you unless you have access to it: your searches, lists and direct lookups leave it out or return not found, even when the person who asks can open it. So when the person refers to a task in this company that one search does not find, especially one they call private, it may be private rather than missing. Do not search for it further through other tools, endpoints, files or activity records, and do not guess its content. Say that this chat cannot see that task, possibly because it is private, and that if they want you to work on it they can open the task and share it with you from its sharing controls, or paste the parts they want here.