diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 357b58478..30200c8e4 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixed - Pinned internal Pi dependencies to exact release versions to prevent fresh installs from mixing incompatible packages ([#10865](https://github.com/earendil-works/pi/issues/10865)) +- Fixed resumed sessions dropping tools activated during the session. Resume now restores the session's active tools, with `--tools +name`/`-name` entries applied to them, unless `--tools` with tool names, `--no-tools`, or `--no-builtin-tools` selects tools explicitly ([#10785](https://github.com/earendil-works/pi/issues/10785)) ## [1.2.0] - 2026-10-12 diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index ca21a5473..8b330d555 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -267,8 +267,9 @@ export interface AgentSessionConfig { /** Initial active built-in tool names. Default: [read, bash, edit, write] */ initialActiveToolNames?: string[]; /** - * Whether the initial tools come from the `defaultTools` setting. When true, reload activates - * tools newly added to the setting. Tools removed from it stay active. + * Whether the initial tools come from the `defaultTools` setting. When true, a resumed session + * replaces them with the loadout its transcript records, with `defaultToolModifiers` applied, and + * reload activates tools newly added to the setting. Tools removed from it stay active. */ usesDefaultTools?: boolean; /** @@ -522,7 +523,7 @@ export class AgentSession { activeToolNames: this._initialActiveToolNames, includeAllExtensionTools: true, }); - if (this._initialActiveToolNames === undefined) this._restoreToolsFromTranscript(); + if (this._usesDefaultTools) this._restoreToolsFromTranscript(this._defaultToolModifiers); } get modelRuntime(): ModelRuntime { @@ -1809,12 +1810,16 @@ export class AgentSession { * Restore the active tool loadout declared by the session transcript, if it declares one. * Tools reachable only from other tools are never declared, but they do not depend on the active * set, so the transcript's declarations are the whole loadout. + * @param modifiers `+name`/`-name` entries applied to the restored loadout. */ - private _restoreToolsFromTranscript(): void { + private _restoreToolsFromTranscript(modifiers: readonly string[] = []): void { this._pendingToolNames.clear(); const current = getCurrentSystemMessage(this.sessionManager.buildSessionContext().messages); if (!current) return; - const names = (current.toolsAdded ?? []).map((tool) => tool.name); + const names = applyToolModifiers( + (current.toolsAdded ?? []).map((tool) => tool.name), + modifiers, + ); this._pendingToolNames = new Set(names.filter((name) => this._isAllowedTool(name))); this._setActiveTools(names); } diff --git a/packages/coding-agent/test/default-tools-setting.test.ts b/packages/coding-agent/test/default-tools-setting.test.ts index 91f7361ec..3ab70eaa9 100644 --- a/packages/coding-agent/test/default-tools-setting.test.ts +++ b/packages/coding-agent/test/default-tools-setting.test.ts @@ -289,6 +289,104 @@ describe("defaultTools setting", () => { }); }); + // #10785 + describe("resume", () => { + const extensionTools: InlineExtension = (pi) => { + pi.registerTool({ + name: "inactive_tool", + label: "Inactive Tool", + description: "Extension tool registered inactive", + parameters: Type.Object({}), + execute: async () => ({ content: [{ type: "text", text: "ok" }], details: {} }), + defaultActive: false, + }); + pi.registerTool({ + name: "active_tool", + label: "Active Tool", + description: "Extension tool registered active", + parameters: Type.Object({}), + execute: async () => ({ content: [{ type: "text", text: "ok" }], details: {} }), + }); + }; + + /** A session whose transcript records the loadout `toolNames`, as a run records it. */ + function createRecordedSession(toolNames: string[]): SessionManager { + const sessionManager = SessionManager.inMemory(tempDir); + sessionManager.appendMessage({ + role: "system", + content: "", + toolsAdded: toolNames.map((name) => ({ name, description: name, parameters: Type.Object({}) })), + timestamp: Date.now(), + }); + return sessionManager; + } + + async function resume(sessionManager: SessionManager, options: ToolOptions = {}) { + const settingsManager = SettingsManager.inMemory(); + const resourceLoader = new DefaultResourceLoader({ + cwd: tempDir, + agentDir, + settingsManager, + extensionFactories: [extensionTools], + }); + await resourceLoader.reload(); + return ( + await createAgentSession({ + cwd: tempDir, + agentDir, + model: getModel("anthropic", "claude-sonnet-4-5")!, + settingsManager, + sessionManager, + resourceLoader, + ...options, + }) + ).session; + } + + it("restores the loadout the transcript records", async () => { + // active_tool was deactivated during the session, inactive_tool activated. + const session = await resume(createRecordedSession(["read", "grep", "inactive_tool"])); + expect(session.getActiveToolNames().sort()).toEqual(["grep", "inactive_tool", "read"]); + session.dispose(); + }); + + it("does not restore excluded tools", async () => { + const session = await resume(createRecordedSession(["read", "grep", "inactive_tool"]), { + excludeTools: ["inactive_tool"], + }); + expect(session.getActiveToolNames().sort()).toEqual(["grep", "read"]); + session.dispose(); + }); + + it("uses explicit tool options instead of the recorded loadout", async () => { + const recorded = ["read", "grep", "inactive_tool"]; + + const allowlisted = await resume(createRecordedSession(recorded), { tools: ["read", "write"] }); + expect(allowlisted.getActiveToolNames()).toEqual(["read", "write"]); + allowlisted.dispose(); + + const builtinless = await resume(createRecordedSession(recorded), { noTools: "builtin" }); + expect(builtinless.getActiveToolNames()).toEqual(["active_tool"]); + builtinless.dispose(); + }); + + it("applies +name and -name tool options to the recorded loadout", async () => { + const session = await resume(createRecordedSession(["read", "grep", "inactive_tool"]), { + tools: ["-grep", "+write"], + }); + expect(session.getActiveToolNames().sort()).toEqual(["inactive_tool", "read", "write"]); + session.dispose(); + }); + + it("uses the default tools when the transcript records no loadout", async () => { + const sessionManager = SessionManager.inMemory(tempDir); + sessionManager.appendMessage({ role: "user", content: "hi", timestamp: Date.now() }); + const session = await resume(sessionManager); + expect(session.getActiveToolNames().sort()).toEqual(["active_tool", "bash", "edit", "read", "write"]); + session.dispose(); + }); + }); + it("applies through service-based session creation", async () => { const settingsManager = SettingsManager.inMemory({ defaultTools: ["ls"] }); const services = await createAgentSessionServices({ cwd: tempDir, agentDir, settingsManager }); diff --git a/packages/coding-agent/test/suite/harness.ts b/packages/coding-agent/test/suite/harness.ts index 220a1558f..bdad9e97a 100644 --- a/packages/coding-agent/test/suite/harness.ts +++ b/packages/coding-agent/test/suite/harness.ts @@ -238,6 +238,7 @@ export async function createHarness(options: HarnessOptions = {}): Promise