diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index ca8ad644e..2d04ce959 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added `task.isolation.apply` (default `true`) to choose whether successful isolated `task` runs automatically apply their changes to the parent checkout or retain patch/branch artifacts for later integration. + ## [17.0.8] - 2026-07-22 ### Added diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index 2fb6c85e4..52b43a209 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -4089,6 +4089,18 @@ export const SETTINGS_SCHEMA = { }, }, + "task.isolation.apply": { + type: "boolean", + default: true, + ui: { + tab: "tasks", + group: "Isolation", + label: "Apply Isolated Changes", + description: + "Automatically apply successful isolated task changes to the parent checkout; disable to retain patch or branch artifacts", + }, + }, + "task.isolation.merge": { type: "enum", values: ["patch", "branch"] as const, diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 62ca7f7ea..ea4936cdf 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -134,6 +134,7 @@ async function checkForNewVersion(currentVersion: string): Promise agent.blocking), @@ -566,6 +568,7 @@ export class TaskTool implements AgentTool { const explicit = { "task.isolation.mode": "rcopy", + "task.isolation.apply": false, "task.isolation.merge": "branch", "task.isolation.commits": "ai", "task.eager": "always", diff --git a/packages/coding-agent/test/task/structured-subagent.test.ts b/packages/coding-agent/test/task/structured-subagent.test.ts index fc28abf50..f73f13117 100644 --- a/packages/coding-agent/test/task/structured-subagent.test.ts +++ b/packages/coding-agent/test/task/structured-subagent.test.ts @@ -31,7 +31,13 @@ const AGENT: AgentDefinition = { }; function session( - options: { planMode?: boolean; outputSchema?: unknown; maxDepth?: number; isolationMode?: "none" | "worktree" } = {}, + options: { + planMode?: boolean; + outputSchema?: unknown; + maxDepth?: number; + isolationMode?: "none" | "worktree"; + isolationApply?: boolean; + } = {}, ): ToolSession { return { cwd: "/tmp", @@ -41,6 +47,7 @@ function session( "task.maxRecursionDepth": options.maxDepth ?? 2, "task.isolation.mode": options.isolationMode ?? "none", "task.enableLsp": true, + ...(options.isolationApply !== undefined ? { "task.isolation.apply": options.isolationApply } : {}), }), getSessionFile: () => null, getSessionSpawns: () => "*", @@ -397,4 +404,54 @@ describe("structured subagent primitive", () => { expect(await fs.stat(artifactsDir ?? "")).toBeDefined(); await fs.rm(settled.artifactsDir, { recursive: true, force: true }); }); + + it("defaults task isolation to auto-apply and lets config retain artifacts", async () => { + mockDiscovery(); + const defaultPolicy = await resolveEffectiveSubagentPolicy( + request({ session: session({ isolationMode: "worktree" }), isolation: { requested: true } }), + ); + expect(defaultPolicy.applyChanges).toBe(true); + + const capturePolicy = await resolveEffectiveSubagentPolicy( + request({ + session: session({ isolationMode: "worktree", isolationApply: false }), + isolation: { requested: true }, + }), + ); + expect(capturePolicy.applyChanges).toBe(false); + + const evalPolicy = await resolveEffectiveSubagentPolicy( + request({ + invocationKind: "eval", + session: session({ isolationMode: "worktree", isolationApply: false }), + isolation: { requested: true }, + }), + ); + expect(evalPolicy.applyChanges).toBe(true); + }); + + it("retains successful isolated task artifacts when auto-apply is disabled", async () => { + mockDiscovery(); + let artifactsDir: string | undefined; + vi.spyOn(isolationRunner, "prepareIsolationContext").mockResolvedValue({ repoRoot: "/tmp" } as never); + vi.spyOn(isolationRunner, "runIsolatedSubprocess").mockImplementation(async ({ baseOptions }) => { + artifactsDir = baseOptions.artifactsDir; + return { ...result(), patchPath: "/recovery/Worker.patch" }; + }); + const merge = vi.spyOn(isolationRunner, "mergeIsolatedChanges"); + + const settled = await runStructuredSubagent( + request({ + session: session({ isolationMode: "worktree", isolationApply: false }), + isolation: { requested: true }, + }), + ); + + expect(merge).not.toHaveBeenCalled(); + expect(settled.changesApplied).toBeNull(); + expect(settled.mergeSummary).toContain("/recovery/Worker.patch"); + expect(artifactsDirsFromRegistry()).toContain(settled.artifactsDir); + expect(await fs.stat(artifactsDir ?? "")).toBeDefined(); + await fs.rm(settled.artifactsDir, { recursive: true, force: true }); + }); }); diff --git a/packages/coding-agent/test/task/task-batch.test.ts b/packages/coding-agent/test/task/task-batch.test.ts index ed8db870a..f9e007b55 100644 --- a/packages/coding-agent/test/task/task-batch.test.ts +++ b/packages/coding-agent/test/task/task-batch.test.ts @@ -125,7 +125,7 @@ describe("task.batch schema gating", () => { expect(items?.properties?.schemaMode).toBeDefined(); }); - it("places isolated per item in the batch shape when isolation is enabled", async () => { + it("keeps isolation boolean-only and describes the configured apply behavior", async () => { mockDiscovery(); const tool = await TaskTool.create( @@ -134,7 +134,24 @@ describe("task.batch schema gating", () => { const properties = getSchemaProperties(tool); expect(properties.isolated).toBeUndefined(); const items = (properties.tasks as { items?: { properties?: Record } }).items; - expect(items?.properties?.isolated).toBeDefined(); + const isolatedSchema = items?.properties?.isolated; + if (!isolatedSchema || typeof isolatedSchema !== "object" || !("type" in isolatedSchema)) { + throw new Error("Expected isolated to be a boolean schema"); + } + expect(isolatedSchema.type).toBe("boolean"); + expect(items?.properties?.apply).toBeUndefined(); + expect(tool.description).toContain("automatically applied to the parent checkout"); + + const captureTool = await TaskTool.create( + createSession({ + settings: { + "task.batch": true, + "task.isolation.mode": "auto", + "task.isolation.apply": false, + }, + }), + ); + expect(captureTool.description).toContain("without modifying the parent checkout"); }); it("hides isolation from the dynamic batch schema in plan mode", async () => {