From 63e770e10ae2cfbed069380bd833a2bd8b151571 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 7 Jun 2026 19:21:56 +0000 Subject: [PATCH] Add Plan mode for read-only feature planning Introduce a new "Plan" session mode that lets users design a feature before making changes, mirroring Codex's and Claude Code's plan mode. - AgentMode.Plan: read-only sandbox + "never" approvals, carrying a planning instruction (planInstructions) injected as the first turn input. Combined with the read-only sandbox this blocks edits while the model investigates and emits a step-by-step plan via update_plan (already forwarded to ACP clients as plan updates). - Surfaced automatically in the ACP mode picker via AgentMode.all(); switchable mid-session through the existing setSessionMode path and selectable at startup via INITIAL_AGENT_MODE=plan. - /status now shows the active Mode. - Tests: plan-instruction injection (and non-injection for Agent and Read-only modes), AgentMode unit coverage, an e2e mode-switch case, and updated /status snapshots. https://claude.ai/code/session_01RA7uHwHd1pbYE9Mkh6n2mD --- src/AgentMode.ts | 36 ++++++++++- src/CodexAcpClient.ts | 10 +++- src/CodexCommands.ts | 1 + .../CodexACPAgent/CodexAcpClient.test.ts | 40 +++++++++++++ .../CodexACPAgent/agent-mode.test.ts | 59 +++++++++++++++++++ .../data/command-status-with-rate-limits.json | 2 +- .../CodexACPAgent/data/command-status.json | 2 +- .../CodexACPAgent/e2e/acp-e2e.test.ts | 17 ++++++ 8 files changed, 160 insertions(+), 7 deletions(-) create mode 100644 src/__tests__/CodexACPAgent/agent-mode.test.ts diff --git a/src/AgentMode.ts b/src/AgentMode.ts index 46df6a3d..55c091fd 100644 --- a/src/AgentMode.ts +++ b/src/AgentMode.ts @@ -1,6 +1,24 @@ import type {AskForApproval, SandboxMode, SandboxPolicy} from "./app-server/v2"; import type {SessionMode, SessionModeState} from "@agentclientprotocol/sdk"; +/** + * Instruction injected at the start of every turn while Plan mode is active. + * Combined with the read-only sandbox, it turns a turn into a design-only + * planning turn: the model investigates and proposes a step-by-step plan + * (rendered via the update_plan tool) instead of making changes. This is what + * differentiates Plan from the plain Read-only mode, which also blocks writes + * but gives no planning directive. + */ +const PLAN_MODE_INSTRUCTIONS = `You are operating in PLAN MODE. Your task is to investigate and design, not to execute. + +Rules for this turn: +- Do NOT edit, create, move, or delete any files. Do NOT run any command that mutates the workspace, git state, or environment, and do NOT make network changes. You are running in a read-only sandbox; mutating actions will be blocked. +- Investigate the codebase as needed using read-only actions (reading files, searching, listing directories, read-only shell commands) to ground your plan in the actual code. +- Produce a clear, step-by-step implementation plan. Use the update_plan tool to record the plan as ordered, verifiable steps. Reference concrete files, functions, and symbols you discovered. +- Call out key decisions, trade-offs, dependencies/sequencing, and risks or open questions. + +When the plan is ready, present a short summary and explicitly ask the user to switch to "Agent" mode (the mode selector) to execute it. Do not attempt to implement the changes yourself while in Plan mode.`; + export class AgentMode { readonly id: string; readonly name: string; @@ -8,14 +26,16 @@ export class AgentMode { readonly approvalPolicy: AskForApproval; readonly sandboxPolicy: SandboxPolicy; readonly sandboxMode: SandboxMode; + readonly planInstructions: string | null; - private constructor(id: string, name: string, description: string, approval: AskForApproval, sandbox: SandboxPolicy, sandboxMode: SandboxMode) { + private constructor(id: string, name: string, description: string, approval: AskForApproval, sandbox: SandboxPolicy, sandboxMode: SandboxMode, planInstructions: string | null = null) { this.id = id; this.name = name; this.description = description; this.approvalPolicy = approval; this.sandboxPolicy = sandbox; this.sandboxMode = sandboxMode; // same as sandboxPolicy, need to look for + this.planInstructions = planInstructions; } static readonly ReadOnly = new AgentMode( @@ -51,6 +71,18 @@ export class AgentMode { {"type": "dangerFullAccess"}, "danger-full-access" ); + static readonly Plan = new AgentMode( + "plan", + "Plan", + "Investigate read-only and produce a step-by-step implementation plan before making any changes.", + "never", + { + "type": "readOnly", + "networkAccess": false + }, + "read-only", + PLAN_MODE_INSTRUCTIONS + ); static DEFAULT_AGENT_MODE = AgentMode.Agent; @@ -70,7 +102,7 @@ export class AgentMode { } static all(): AgentMode[] { - return [AgentMode.ReadOnly, AgentMode.Agent, AgentMode.AgentFullAccess]; + return [AgentMode.ReadOnly, AgentMode.Plan, AgentMode.Agent, AgentMode.AgentFullAccess]; } static find(modeId: string): AgentMode | null { diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index b06a0c9a..273d3408 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -403,7 +403,7 @@ export class CodexAcpClient { onTurnStarted?: (turnId: string) => void, shouldCancel?: () => boolean, ): Promise { - const input = buildPromptItems(request.prompt); + const input = buildPromptItems(request.prompt, agentMode.planInstructions); const effort = modelId.effort as ReasoningEffort | null; //TODO remove unsafe conversion await this.refreshSkills(cwd, request._meta); @@ -603,8 +603,8 @@ export type SessionMetadataWithThread = SessionMetadata & { thread: Thread, } -function buildPromptItems(prompt: acp.ContentBlock[]): UserInput[] { - return prompt.map((block): UserInput | null => { +function buildPromptItems(prompt: acp.ContentBlock[], planInstructions?: string | null): UserInput[] { + const items = prompt.map((block): UserInput | null => { switch (block.type) { case "text": return {type: "text", text: block.text, text_elements: []}; @@ -627,6 +627,10 @@ function buildPromptItems(prompt: acp.ContentBlock[]): UserInput[] { return null; } }).filter((block): block is UserInput => block !== null); + if (planInstructions) { + items.unshift({type: "text", text: planInstructions, text_elements: []}); + } + return items; } function formatUriAsLink(name: string | null | undefined, uri: string): string { diff --git a/src/CodexCommands.ts b/src/CodexCommands.ts index a125d71d..3bcafb5d 100644 --- a/src/CodexCommands.ts +++ b/src/CodexCommands.ts @@ -201,6 +201,7 @@ export class CodexCommands { const lines = [ `**Model:** ${sessionState.currentModelId}`, `**Directory:** ${sessionState.cwd}`, + `**Mode:** ${agentMode.name}`, `**Approval:** ${agentMode.approvalPolicy}`, `**Sandbox:** ${agentMode.sandboxMode}`, `**Account:** ${accountText}`, diff --git a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts index 804d2a08..f1bfe25d 100644 --- a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts +++ b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts @@ -1057,6 +1057,46 @@ describe('ACP server test', { timeout: 40_000 }, () => { })); }); + it ('should inject the plan instruction as the first input item in Plan mode', async () => { + const { mockFixture, turnStartSpy } = setupPromptFixture({ agentMode: AgentMode.Plan }); + + await mockFixture.getCodexAcpAgent().prompt({ sessionId: "id", prompt: [{ type: "text", text: "Add feature X" }] }); + + expect(turnStartSpy).toHaveBeenCalledWith(expect.objectContaining({ + input: [ + { type: "text", text: AgentMode.Plan.planInstructions, text_elements: [] }, + { type: "text", text: "Add feature X", text_elements: [] }, + ], + approvalPolicy: "never", + sandboxPolicy: { type: "readOnly", networkAccess: false }, + })); + }); + + it ('should not inject the plan instruction in Agent mode', async () => { + const { mockFixture, turnStartSpy } = setupPromptFixture({ agentMode: AgentMode.Agent }); + + await mockFixture.getCodexAcpAgent().prompt({ sessionId: "id", prompt: [{ type: "text", text: "Add feature X" }] }); + + expect(turnStartSpy).toHaveBeenCalledWith(expect.objectContaining({ + input: [ + { type: "text", text: "Add feature X", text_elements: [] }, + ], + })); + }); + + it ('should not inject the plan instruction in Read-only mode', async () => { + const { mockFixture, turnStartSpy } = setupPromptFixture({ agentMode: AgentMode.ReadOnly }); + + await mockFixture.getCodexAcpAgent().prompt({ sessionId: "id", prompt: [{ type: "text", text: "Add feature X" }] }); + + expect(turnStartSpy).toHaveBeenCalledWith(expect.objectContaining({ + input: [ + { type: "text", text: "Add feature X", text_elements: [] }, + ], + approvalPolicy: "on-request", + })); + }); + it ('should show rate limits from multiple sources in status', async () => { const rateLimits: RateLimitsMap = new Map(); rateLimits.set("limit-1", { diff --git a/src/__tests__/CodexACPAgent/agent-mode.test.ts b/src/__tests__/CodexACPAgent/agent-mode.test.ts new file mode 100644 index 00000000..6d5fad21 --- /dev/null +++ b/src/__tests__/CodexACPAgent/agent-mode.test.ts @@ -0,0 +1,59 @@ +import {afterEach, beforeEach, describe, expect, it} from "vitest"; +import {AgentMode} from "../../AgentMode"; + +describe("AgentMode plan mode", () => { + it("resolves the plan mode by id", () => { + expect(AgentMode.find("plan")).toBe(AgentMode.Plan); + }); + + it("carries planning instructions only for Plan mode", () => { + expect(AgentMode.Plan.planInstructions).toBeTruthy(); + expect(AgentMode.Plan.planInstructions?.length ?? 0).toBeGreaterThan(0); + expect(AgentMode.ReadOnly.planInstructions).toBeNull(); + expect(AgentMode.Agent.planInstructions).toBeNull(); + expect(AgentMode.AgentFullAccess.planInstructions).toBeNull(); + }); + + it("uses a read-only sandbox with no approvals", () => { + expect(AgentMode.Plan.sandboxMode).toBe("read-only"); + expect(AgentMode.Plan.sandboxPolicy).toEqual({type: "readOnly", networkAccess: false}); + expect(AgentMode.Plan.approvalPolicy).toBe("never"); + }); + + it("includes plan in the available modes exposed to clients", () => { + expect(AgentMode.all().map((mode) => mode.id)).toContain("plan"); + + const state = AgentMode.Agent.toSessionModeState(); + expect(state.availableModes).toEqual( + expect.arrayContaining([ + expect.objectContaining({id: "plan", name: "Plan"}), + ]), + ); + }); + + describe("getInitialAgentMode", () => { + let previous: string | undefined; + + beforeEach(() => { + previous = process.env["INITIAL_AGENT_MODE"]; + }); + + afterEach(() => { + if (previous === undefined) { + delete process.env["INITIAL_AGENT_MODE"]; + } else { + process.env["INITIAL_AGENT_MODE"] = previous; + } + }); + + it("honors INITIAL_AGENT_MODE=plan", () => { + process.env["INITIAL_AGENT_MODE"] = "plan"; + expect(AgentMode.getInitialAgentMode()).toBe(AgentMode.Plan); + }); + + it("falls back to the default mode for unknown values", () => { + process.env["INITIAL_AGENT_MODE"] = "does-not-exist"; + expect(AgentMode.getInitialAgentMode()).toBe(AgentMode.DEFAULT_AGENT_MODE); + }); + }); +}); diff --git a/src/__tests__/CodexACPAgent/data/command-status-with-rate-limits.json b/src/__tests__/CodexACPAgent/data/command-status-with-rate-limits.json index 13403fd0..10ec3e14 100644 --- a/src/__tests__/CodexACPAgent/data/command-status-with-rate-limits.json +++ b/src/__tests__/CodexACPAgent/data/command-status-with-rate-limits.json @@ -7,7 +7,7 @@ "sessionUpdate": "agent_message_chunk", "content": { "type": "text", - "text": "**Model:** model-id[effort] \n**Directory:** /test/cwd \n**Approval:** on-request \n**Sandbox:** workspace-write \n**Account:** not logged in \n**Session:** `session-id` \n \n**Token usage:** data not available yet \n**Context window:** data not available yet \n**Standard 1h limit:** 75% left \n**Fast 1d limit:** 20% left" + "text": "**Model:** model-id[effort] \n**Directory:** /test/cwd \n**Mode:** Agent \n**Approval:** on-request \n**Sandbox:** workspace-write \n**Account:** not logged in \n**Session:** `session-id` \n \n**Token usage:** data not available yet \n**Context window:** data not available yet \n**Standard 1h limit:** 75% left \n**Fast 1d limit:** 20% left" } } } diff --git a/src/__tests__/CodexACPAgent/data/command-status.json b/src/__tests__/CodexACPAgent/data/command-status.json index 50260582..4400d522 100644 --- a/src/__tests__/CodexACPAgent/data/command-status.json +++ b/src/__tests__/CodexACPAgent/data/command-status.json @@ -7,7 +7,7 @@ "sessionUpdate": "agent_message_chunk", "content": { "type": "text", - "text": "**Model:** model-id[effort] \n**Directory:** /test/cwd \n**Approval:** on-request \n**Sandbox:** workspace-write \n**Account:** not logged in \n**Session:** `session-id` \n \n**Token usage:** data not available yet \n**Context window:** data not available yet \n**Limits:** data not available yet" + "text": "**Model:** model-id[effort] \n**Directory:** /test/cwd \n**Mode:** Agent \n**Approval:** on-request \n**Sandbox:** workspace-write \n**Account:** not logged in \n**Session:** `session-id` \n \n**Token usage:** data not available yet \n**Context window:** data not available yet \n**Limits:** data not available yet" } } } diff --git a/src/__tests__/CodexACPAgent/e2e/acp-e2e.test.ts b/src/__tests__/CodexACPAgent/e2e/acp-e2e.test.ts index a098976d..7a621e52 100644 --- a/src/__tests__/CodexACPAgent/e2e/acp-e2e.test.ts +++ b/src/__tests__/CodexACPAgent/e2e/acp-e2e.test.ts @@ -78,6 +78,23 @@ describeE2E("E2E tests", () => { }); }); + it("enters Plan mode via setSessionMode and reflects it in /status", async () => { + fixture = await createAuthenticatedFixture(); + const session = await fixture.createSession(); + + const targetMode = AgentMode.Plan; + await fixture.connection.setSessionMode({ + sessionId: session.sessionId, + modeId: targetMode.id, + }); + + await fixture.expectStatus(session.sessionId, { + Mode: targetMode.name, + Approval: targetMode.approvalPolicy, + Sandbox: targetMode.sandboxMode, + }); + }); + it("respects INITIAL_AGENT_MODE when seeding the initial session mode", async () => { const initialMode = AgentMode.ReadOnly; fixture = await createAuthenticatedFixture(initialMode);