From 9504b2c0a46b82f3aaee6ea7f593f9ad0c6340a3 Mon Sep 17 00:00:00 2001 From: Teng Zhang Date: Sat, 24 Oct 2026 04:10:43 -0400 Subject: [PATCH 1/2] fix(tools): keep the tool order when navigation restores the same tools The re-apply reordered the tools even when pi restored the same loadout, which changed the prompt and re-billed the cached conversation. --- lib/rpc-manager-tool-exposure.test.mjs | 25 +++++++++++++++++++++++++ lib/rpc-manager.ts | 20 ++++++++++++++------ 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/lib/rpc-manager-tool-exposure.test.mjs b/lib/rpc-manager-tool-exposure.test.mjs index 3ace575c08..f5a8331abd 100644 --- a/lib/rpc-manager-tool-exposure.test.mjs +++ b/lib/rpc-manager-tool-exposure.test.mjs @@ -221,6 +221,31 @@ test("navigating an unpinned session keeps the branch's restored tools", async ( assert.deepEqual(made.state.active, ["read", "bash", "lookup", "scripted", "tool_search"]); }); +test("navigation keeps the active tool order when the restored set is the same", async (t) => { + const declared = ["read", "bash", "edit", "write", "codemode", "grep", "find"]; + const cases = [ + { name: "same order", restored: declared, calls: [] }, + // pi rebuilt the loadout from the extension registration order. + { name: "another order", restored: ["read", "bash", "edit", "write", "grep", "find", "codemode"], calls: [declared] }, + ]; + + for (const { name, restored, calls } of cases) { + const made = makeWrapper({ + active: declared, + navigateTree: async () => { + made.state.active = [...restored]; + return { cancelled: false }; + }, + }); + t.after(() => made.wrapper.destroy()); + + await made.wrapper.send({ type: "navigate_tree", targetId: "same" }); + + assert.deepEqual(made.state.activations, calls, name); + assert.deepEqual(made.state.active, declared, name); + } +}); + test("navigation keeps an extension tool off when the branch's loadout leaves it out", async (t) => { const made = makeWrapper({ active: ["read", "grep", "find", "ls", "lookup", "ask"], diff --git a/lib/rpc-manager.ts b/lib/rpc-manager.ts index af1692de01..912caa2da4 100644 --- a/lib/rpc-manager.ts +++ b/lib/rpc-manager.ts @@ -658,8 +658,9 @@ export class AgentSessionWrapper { /** * pi restores the target branch's loadout from its transcript when it navigates the tree, * which can bring back coding tools the pinned preset leaves out and drop codemode, so a - * normal session applies its selection again. Chat only declares no tools, and a - * subagent's tools are fixed by its profile; both keep what pi restored. + * normal session applies its selection again when the set changed, and otherwise keeps the + * order the prompt was in. Chat only declares no tools, and a subagent's tools are fixed by + * its profile; both keep what pi restored. */ private async navigateTreeKeepingToolSelection( targetId: string, @@ -672,10 +673,17 @@ export class AgentSessionWrapper { const entries = this.inner.sessionManager.getEntries() as unknown as SessionEntry[]; if (!readSubagentSessionResources(entries)) { const activeAfter = this.inner.getActiveToolNames(); - this.setActiveToolSelection( - readSessionToolSelection(entries) ?? activeAfter, - [...activeAfter, ...activeBefore.filter((name) => SESSION_TOOL_NAMES.has(name))], - ); + const isToolChanged = activeBefore.length !== activeAfter.length + || activeBefore.some((name) => !activeAfter.includes(name)); + if (isToolChanged) { + this.setActiveToolSelection( + readSessionToolSelection(entries) ?? activeAfter, + [...activeAfter, ...activeBefore.filter((name) => SESSION_TOOL_NAMES.has(name))], + ); + } else if (activeBefore.some((name, index) => name !== activeAfter[index])) { + // Same tools, other order: pi declares them in this order, so keep the previous one. + this.inner.setActiveToolsByName(activeBefore); + } } return { cancelled: false }; } From 98930503c46c4075fe77d93e708946ce4fd800b0 Mon Sep 17 00:00:00 2001 From: Alex Yang Date: Thu, 29 Oct 2026 15:48:18 +0900 Subject: [PATCH 2/2] docs(tools): note that navigation keeps the order of an unchanged tool set Co-Authored-By: Claude Opus 5.5 --- docs/agents/tools.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/agents/tools.md b/docs/agents/tools.md index 5bf40376b7..e855244b00 100644 --- a/docs/agents/tools.md +++ b/docs/agents/tools.md @@ -13,7 +13,7 @@ The last preset the user explicitly selected is kept in browser `localStorage` a - Pi Web's environment-sanitizing `bash` (`lib/project-command-env.ts`, ADR 0001), an extension tool standing in for pi's built-in, sets `defaultActive: false` too, so like the built-in it is on only when `defaultTools` (pi's default list included), a preset or `set_tools` names it; registered active, it came back in every session whatever `defaultTools` left out (#1235). A user extension's `bash` still replaces it (`preferUserBashExtension()`) and activates as in the CLI. - `get_tools` reports an active tool's description from `agent.state.tools`, what the model is sent. Under `codemode.mode: "only"`, while `codemode` is active, the active `direct` tools stay active and callable (`get_tools` reports them so, with `declarationHidden: true` from the AgentSession's private `_hiddenDeclarations`), but codemode's `hiddenDeclarations` leave their declarations out of every request, so the model reaches them only from scripts. The toolbar's Tools panel lists only what requests declare; presets still read `active`. A preset still bounds them: a `direct` tool is callable only while active. `prepareLoadout` hooks rewrite it there on every loadout change (tool registration included): codemode's definition holds only the intro, while the declared one lists the tools scripts can call, MCP namespaces and types, and appends the codemode declaration to the callable `direct` tools. - A preset replaces only the coding tools. `resolveActiveToolNames()` carries every other tool that is active, registered and not hidden, so what an extension, `tool_search` or a `defaultTools` entry such as `+codemode` activated survives startup, `set_tools` and `reload`, a tool an extension switched off stays off, and a preset never turns `codemode` on. It adds nothing else: pi activates extension tools itself, so re-adding them would override an extension (or a branch's recorded loadout) that keeps a `direct` tool off. `reload` carries the set pi rebuilt after extensions restarted, not the previous one. -- `navigateTree()` restores the target branch's loadout from its transcript (bringing back `write` under a Read-only pin, dropping `codemode`), so a normal session then reapplies its pin (the restored set when unpinned) and carries over from the branch it left the session's own tools: `codemode`, `tool_search` and pi-web's subagent tools. Other extension tools follow the target branch's loadout, as in the pi CLI; since pi 1.4 (#11062) one that branch never added or removed, such as one registered after that branch point, is active unless it is registered with `defaultActive: false`. Chat-only and subagent wrappers keep what pi restored. +- `navigateTree()` restores the target branch's loadout from its transcript (bringing back `write` under a Read-only pin, dropping `codemode`), so a normal session then reapplies its pin (the restored set when unpinned) and carries over from the branch it left the session's own tools: `codemode`, `tool_search` and pi-web's subagent tools. Other extension tools follow the target branch's loadout, as in the pi CLI; since pi 1.4 (#11062) one that branch never added or removed, such as one registered after that branch point, is active unless it is registered with `defaultActive: false`. When pi restores the same set that was active (editing a history message, or going back to a point after the last tool change), the session keeps the order that was active instead: reapplying moves every coding tool ahead of the carried ones, which rewrites the prompt's `` and rules sections and drops the prompt cache (#1405). Chat-only and subagent wrappers keep what pi restored. - A reopened normal session starts on the loadout its transcript last recorded (pi 1.3, as `pi --resume` without `--tools`: pi-web passes no `tools`), not on today's `defaultTools`, so `+codemode` or another `defaultTools` edit made since reaches only new sessions and transcripts that record no loadout. An unpinned session keeps the recorded tools; a pinned one gets its pin over the coding tools and carries the rest from that loadout. Chat only (`tools: []`) and subagents (`subagentToolOptions()`: an allowlist, or `noTools: "builtin"`) select their tools explicitly, so pi restores nothing for them. **Built-in extensions** (`lib/builtin-extensions.ts`, ADR 0006). The CLI's built-in `codemode`, `tool-search` and `mcp` are rebuilt from the factories the SDK root exports, under the CLI's names with `replaceable: true, builtin: true`, so `-builtin:`, project overrides, replacement by a third-party extension and `noExtensions` behave as in the CLI. Normal sessions load all three. Subagents load `codemode` through their profile's `codemode` policy, and `tool-search` and `mcp` with an MCP host of their own only under `mcp: on` (see [subagents.md](subagents.md)); they never share the normal session's.