diff --git a/AGENTS.md b/AGENTS.md index f66e1192d..10336212f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,5 +8,5 @@ - Any future extension or update that introduces a user-selectable model, feature toggle, permission, concurrency limit, theme/UI preference, or other package-owned choice must update all of: `extensions/setup/`, `extensions/shared/setup-config.ts`, the no-argument `/openpi-setup` status output, `SETUP.md`, and `README.md` (which holds the canonical defaults table and restates limits in prose). - Any new model-facing tool must be classified for the child-session boundary: parent-only tools go in `CHILD_EXCLUDED_TOOL_NAMES` and read-only child-safe ones in `CHILD_SAFE_PACKAGE_TOOL_NAMES` (`extensions/shared/child-session.ts`). The drift guard in `child-session.test.ts` fails closed on an unclassified tool, including factory-registered ones. - Installation must remain side-effect-safe: no provider/model names hardcoded as defaults, no model calls before explicit configuration, and expensive or privileged behavior must be opt-in. Optional Pi packages require a native reviewed confirmation, fixed package identity, safe configuration before activation, no rewrite of existing preference files, fail-closed persistence, and no automatic reload into the running Session. -- Natural language is interpreted by the active model. The configuration tool should expose only the smallest typed state needed to persist the result; avoid adding a parser or a proliferation of subcommands. +- Natural language is interpreted by the active model during an `/openpi-setup` episode. The typed configuration write tool is episode-scoped (not resident on ordinary turns) and exposes only the smallest typed state needed to persist the result; avoid adding a parser or a proliferation of subcommands. - Subagents are Pi-native: one in-process backend that inherits the parent model and thinking level. Do not add hardcoded model preferences, and do not reintroduce external CLI harnesses without a stated need. diff --git a/README.md b/README.md index ff83c57c3..410e743a5 100644 --- a/README.md +++ b/README.md @@ -436,7 +436,7 @@ pi install npm:pi-intercom | `ask_user`, `human_handoff` | 经复核的用户决策与用户专属操作 | | `plan_ready` | 显式完成计划,不自动开始实施 | | `fd`, `rg` | 文件发现与内容搜索 | -| `configure_my_pi_setup` | 受限配置写入 | +| `configure_my_pi_setup` | `/openpi-setup` 进行中才暴露的受限配置写入 | diff --git a/SETUP.md b/SETUP.md index f41b83721..f408ca480 100644 --- a/SETUP.md +++ b/SETUP.md @@ -32,7 +32,7 @@ Pi will load the extensions, skills, and theme from their directories the next t ## Configure this package -Use the single canonical package-owned command. `/my-pi-setup` remains a compatibility alias. With no arguments, when optional pi-intercom is absent, the interactive TUI first offers a reviewed global installation; declining changes nothing. Acceptance installs the fixed `npm:pi-intercom` source through Pi's package manager. A new private config receives `confirmSend: true` and `inboundTrigger: "replies"`; an existing preference file is never rewritten and must already define both fields. Package download failure writes no config, while uncertain activation retains the safe new config. Setup asks for `/reload` instead of loading a new broker into the running Session. The current model then explains the remaining configurable areas and uses `ask_user`: first run initializes them; later runs explain the saved state and ask whether to keep it, change one area, or review everything. With arguments, it treats the rest as a targeted natural-language request: +Use the single canonical package-owned command. `/my-pi-setup` remains a compatibility alias. With no arguments, when optional pi-intercom is absent, the interactive TUI first offers a reviewed global installation; declining changes nothing. Acceptance installs the fixed `npm:pi-intercom` source through Pi's package manager. A new private config receives `confirmSend: true` and `inboundTrigger: "replies"`; an existing preference file is never rewritten and must already define both fields. Package download failure writes no config, while uncertain activation retains the safe new config. Setup asks for `/reload` instead of loading a new broker into the running Session. The current model then explains the remaining configurable areas and uses `ask_user`: first run initializes them; later runs explain the saved state and ask whether to keep it, change one area, or review everything. With arguments, it treats the rest as a targeted natural-language request. Persist still goes through the typed `configure_my_pi_setup` tool, which is exposed only while that `/openpi-setup` episode is in flight and is hidden again afterward: ```text /openpi-setup diff --git a/extensions/setup/index.test.ts b/extensions/setup/index.test.ts index b007da60e..1f369e8a7 100644 --- a/extensions/setup/index.test.ts +++ b/extensions/setup/index.test.ts @@ -1,40 +1,336 @@ import assert from "node:assert/strict"; import test from "node:test"; -import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import type { + ExtensionAPI, + ExtensionCommandContext, + ExtensionContext, +} from "@earendil-works/pi-coding-agent"; import { SUBAGENT_ROLE_NAMES } from "../shared/subagent-roles.ts"; import setupExtension, { applySubagentRoleModelUpdates, buildInteractiveSetupPrompt, + CONFIGURE_MY_PI_SETUP_TOOL_NAME, shouldOfferPiIntercom, SUBAGENT_ROLE_MODELS_SCHEMA, } from "./index.ts"; -test("registers the canonical setup command, legacy alias, and one constrained tool", () => { - const commands = new Set(); - const tools = new Set(); - let parameterNames: string[] = []; - const api = { - registerCommand: (name: string) => commands.add(name), - registerTool: (tool: { name: string; parameters: unknown }) => { - tools.add(tool.name); - parameterNames = Object.keys( - (tool.parameters as { properties: Record }).properties, - ); +type Handler = ( + event: Record, + ctx: ExtensionContext, +) => unknown; + +function visibilityHarness( + options: { + initialActive?: string[]; + mode?: ExtensionCommandContext["mode"]; + idle?: boolean; + } = {}, +) { + const commands = new Map< + string, + { handler: (args: string, ctx: ExtensionCommandContext) => Promise } + >(); + const tools = new Map(); + const handlers = new Map(); + let activeTools = [ + ...(options.initialActive ?? ["read", "bash", "edit", "write"]), + ]; + const userMessages: Array<{ content: unknown; options: unknown }> = []; + const setActiveCalls: string[][] = []; + let idle = options.idle ?? true; + + const pi = { + events: { emit() {} }, + registerCommand( + name: string, + command: { + handler: (args: string, ctx: ExtensionCommandContext) => Promise; + }, + ) { + commands.set(name, command); + }, + registerTool(tool: { name: string; parameters: unknown }) { + tools.set(tool.name, tool); + // Pi refreshTools() adds newly registered names to the active set. + if (!activeTools.includes(tool.name)) { + activeTools = [...activeTools, tool.name]; + } + }, + on(event: string, handler: Handler) { + handlers.set(event, [...(handlers.get(event) ?? []), handler]); + }, + getActiveTools() { + return [...activeTools]; + }, + setActiveTools(names: string[]) { + setActiveCalls.push([...names]); + activeTools = [...names]; + }, + getAllTools() { + return [...tools.keys()].map((name) => ({ name })); + }, + getThinkingLevel() { + return "off"; + }, + sendUserMessage(content: unknown, sendOptions?: unknown) { + userMessages.push({ content, options: sendOptions }); }, } as unknown as ExtensionAPI; - setupExtension(api); + setupExtension(pi); - assert.deepEqual(commands, new Set(["openpi-setup", "my-pi-setup"])); - assert.deepEqual(tools, new Set(["configure_my_pi_setup"])); - assert.equal(parameterNames.includes("suggestions_enabled"), true); - assert.equal(parameterNames.includes("suggestion_model"), true); + const ctx = { + mode: options.mode ?? "rpc", + hasUI: false, + cwd: "/tmp/setup-visibility-test", + isIdle: () => idle, + model: undefined, + ui: { + confirm: async () => false, + notify() {}, + setWorkingMessage() {}, + }, + } as unknown as ExtensionCommandContext & ExtensionContext; + + return { + tools, + commands, + userMessages, + setActiveCalls, + ctx, + isActive() { + return activeTools.includes(CONFIGURE_MY_PI_SETUP_TOOL_NAME); + }, + activeNames() { + return [...activeTools]; + }, + async emit(event: string, data: Record = {}) { + for (const handler of handlers.get(event) ?? []) { + await handler({ type: event, ...data }, ctx); + } + }, + async runCommand(name: string, args = "") { + const command = commands.get(name); + assert.ok(command, `missing command ${name}`); + await command.handler(args, ctx); + }, + setIdle(value: boolean) { + idle = value; + }, + }; +} + +test("registers the canonical setup command, legacy alias, and one constrained tool", () => { + const h = visibilityHarness(); + assert.deepEqual([...h.commands.keys()].sort(), [ + "my-pi-setup", + "openpi-setup", + ]); + assert.equal(h.tools.has(CONFIGURE_MY_PI_SETUP_TOOL_NAME), true); + const parameters = h.tools.get(CONFIGURE_MY_PI_SETUP_TOOL_NAME)! + .parameters as { + properties: Record; + }; + assert.equal("suggestions_enabled" in parameters.properties, true); + assert.equal("suggestion_model" in parameters.properties, true); assert.equal( - parameterNames.some((name) => name.startsWith("summary")), + Object.keys(parameters.properties).some((name) => + name.startsWith("summary"), + ), false, ); }); +test("session_start hides configure_my_pi_setup after registration refresh", async () => { + const h = visibilityHarness(); + assert.equal(h.isActive(), true, "registerTool refresh activates the tool"); + await h.emit("session_start"); + assert.equal(h.isActive(), false); + assert.equal(h.tools.has(CONFIGURE_MY_PI_SETUP_TOOL_NAME), true); +}); + +test("openpi-setup and my-pi-setup expose the tool then inject the setup message", async () => { + for (const name of ["openpi-setup", "my-pi-setup"] as const) { + const h = visibilityHarness(); + await h.emit("session_start"); + assert.equal(h.isActive(), false); + await h.runCommand(name, "关闭下一步预测"); + assert.equal(h.isActive(), true); + assert.equal(h.userMessages.length, 1); + assert.match(String(h.userMessages[0]?.content), /关闭下一步预测/); + } +}); + +test("successful configure_my_pi_setup hides the tool", async () => { + const h = visibilityHarness(); + await h.emit("session_start"); + await h.runCommand("openpi-setup", "关闭下一步预测"); + await h.emit("agent_start"); + assert.equal(h.isActive(), true); + await h.emit("tool_execution_end", { + toolName: CONFIGURE_MY_PI_SETUP_TOOL_NAME, + isError: false, + }); + assert.equal(h.isActive(), false); +}); + +test("keep-without-apply hides after the setup agent run settles", async () => { + const h = visibilityHarness(); + await h.emit("session_start"); + await h.runCommand("openpi-setup", ""); + await h.emit("agent_start"); + assert.equal(h.isActive(), true); + await h.emit("agent_settled"); + assert.equal(h.isActive(), false); +}); + +test("follow-up while busy does not tear down on the prior turn settle", async () => { + const h = visibilityHarness({ idle: false }); + await h.emit("session_start"); + await h.runCommand("openpi-setup", "切换 Footer 为 powerline"); + assert.deepEqual(h.userMessages[0]?.options, { deliverAs: "followUp" }); + assert.equal(h.isActive(), true); + // Current in-flight turn settles before the setup follow-up runs. + await h.emit("agent_settled"); + assert.equal(h.isActive(), true); + await h.emit("agent_start"); + await h.emit("agent_settled"); + assert.equal(h.isActive(), false); +}); + +test("overlapping openpi-setup follow-up survives the prior apply", async () => { + const h = visibilityHarness(); + await h.emit("session_start"); + await h.runCommand("openpi-setup", "关闭下一步预测"); + await h.emit("agent_start"); + h.setIdle(false); + await h.runCommand("openpi-setup", "开启下一步预测"); + assert.deepEqual(h.userMessages[1]?.options, { deliverAs: "followUp" }); + assert.equal(h.isActive(), true); + await h.emit("tool_execution_end", { + toolName: CONFIGURE_MY_PI_SETUP_TOOL_NAME, + isError: false, + }); + assert.equal(h.isActive(), true); + await h.emit("agent_start"); + await h.emit("agent_settled"); + assert.equal(h.isActive(), false); +}); + +test("a second openpi-setup after hide re-exposes via get+add", async () => { + const h = visibilityHarness({ + initialActive: ["read", "bash", "ask_user", "workflow"], + }); + await h.emit("session_start"); + await h.runCommand("openpi-setup", "关闭下一步预测"); + await h.emit("agent_start"); + await h.emit("agent_settled"); + assert.equal(h.isActive(), false); + + const beforeSecond = h.setActiveCalls.length; + await h.runCommand("openpi-setup", "开启下一步预测"); + assert.equal(h.isActive(), true); + const last = h.setActiveCalls.at(-1); + assert.ok(last); + assert.ok(last.includes("ask_user")); + assert.ok(last.includes("workflow")); + assert.ok(last.includes(CONFIGURE_MY_PI_SETUP_TOOL_NAME)); + assert.ok(h.setActiveCalls.length > beforeSecond); +}); + +test("session_start after host re-includes tools hides configure tool", async () => { + let activeTools = ["read", "bash", CONFIGURE_MY_PI_SETUP_TOOL_NAME]; + const handlers = new Map(); + const tools = new Map(); + const pi = { + events: { emit() {} }, + registerCommand() {}, + registerTool(tool: { name: string; parameters: unknown }) { + tools.set(tool.name, tool); + if (!activeTools.includes(tool.name)) { + activeTools = [...activeTools, tool.name]; + } + }, + on(event: string, handler: Handler) { + handlers.set(event, [...(handlers.get(event) ?? []), handler]); + }, + getActiveTools() { + return [...activeTools]; + }, + setActiveTools(names: string[]) { + activeTools = [...names]; + }, + getAllTools() { + return [...tools.keys()].map((name) => ({ name })); + }, + getThinkingLevel() { + return "off"; + }, + sendUserMessage() {}, + } as unknown as ExtensionAPI; + setupExtension(pi); + // Host reload includes all extension tools. + activeTools = [ + "read", + "bash", + "edit", + "write", + CONFIGURE_MY_PI_SETUP_TOOL_NAME, + ]; + const ctx = { mode: "rpc", hasUI: false } as unknown as ExtensionContext; + for (const handler of handlers.get("session_start") ?? []) { + await handler({ type: "session_start" }, ctx); + } + assert.equal(activeTools.includes(CONFIGURE_MY_PI_SETUP_TOOL_NAME), false); + assert.equal(tools.has(CONFIGURE_MY_PI_SETUP_TOOL_NAME), true); +}); + +test("setActiveTools never snapshot-restores unrelated tools away", async () => { + const h = visibilityHarness({ + initialActive: ["read", "bash", "ask_user", "workflow", "fd"], + }); + await h.emit("session_start"); + assert.deepEqual(h.activeNames().sort(), [ + "ask_user", + "bash", + "fd", + "read", + "workflow", + ]); + await h.runCommand("openpi-setup", "x"); + assert.deepEqual(h.activeNames().sort(), [ + "ask_user", + "bash", + CONFIGURE_MY_PI_SETUP_TOOL_NAME, + "fd", + "read", + "workflow", + ]); + await h.emit("agent_start"); + await h.emit("agent_settled"); + assert.deepEqual(h.activeNames().sort(), [ + "ask_user", + "bash", + "fd", + "read", + "workflow", + ]); +}); + +test("failed configure_my_pi_setup stays visible until the setup run settles", async () => { + const h = visibilityHarness(); + await h.emit("session_start"); + await h.runCommand("openpi-setup", "关闭下一步预测"); + await h.emit("agent_start"); + await h.emit("tool_execution_end", { + toolName: CONFIGURE_MY_PI_SETUP_TOOL_NAME, + isError: true, + }); + assert.equal(h.isActive(), true); + await h.emit("agent_settled"); + assert.equal(h.isActive(), false); +}); + test("offers optional Intercom only for an idle argument-free TUI setup", () => { const status = { configured: false, installed: false, active: false }; assert.equal( diff --git a/extensions/setup/index.ts b/extensions/setup/index.ts index 8c7a899fb..60be4632b 100644 --- a/extensions/setup/index.ts +++ b/extensions/setup/index.ts @@ -231,7 +231,54 @@ async function maybeOfferPiIntercom( } } +export const CONFIGURE_MY_PI_SETUP_TOOL_NAME = "configure_my_pi_setup"; + +type SetupEpisode = "idle" | "armed" | "active"; + +function showConfigureTool(pi: ExtensionAPI) { + const active = pi.getActiveTools(); + if (active.includes(CONFIGURE_MY_PI_SETUP_TOOL_NAME)) return; + pi.setActiveTools([...active, CONFIGURE_MY_PI_SETUP_TOOL_NAME]); +} + +function hideConfigureTool(pi: ExtensionAPI) { + const active = pi.getActiveTools(); + if (!active.includes(CONFIGURE_MY_PI_SETUP_TOOL_NAME)) return; + pi.setActiveTools( + active.filter((name) => name !== CONFIGURE_MY_PI_SETUP_TOOL_NAME), + ); +} + export default function openPiSetup(pi: ExtensionAPI) { + let episode: SetupEpisode = "idle"; + + const endEpisode = () => { + hideConfigureTool(pi); + episode = "idle"; + }; + + pi.on("session_start", () => { + endEpisode(); + }); + + pi.on("agent_start", () => { + if (episode === "armed") episode = "active"; + }); + + pi.on("tool_execution_end", (event) => { + if ( + episode === "active" && + event.toolName === CONFIGURE_MY_PI_SETUP_TOOL_NAME && + !event.isError + ) { + endEpisode(); + } + }); + + pi.on("agent_settled", () => { + if (episode === "active") endEpisode(); + }); + pi.registerTool({ name: "configure_my_pi_setup", label: "Configure OpenPI", @@ -501,6 +548,8 @@ export default function openPiSetup(pi: ExtensionAPI) { savedConfigExists, }); + showConfigureTool(pi); + episode = "armed"; pi.sendUserMessage( prompt.join("\n"), ctx.isIdle() ? undefined : { deliverAs: "followUp" },