Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 34 additions & 2 deletions src/AgentMode.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,41 @@
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;
readonly description: string;
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(
Expand Down Expand Up @@ -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;

Expand All @@ -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 {
Expand Down
10 changes: 7 additions & 3 deletions src/CodexAcpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -403,7 +403,7 @@ export class CodexAcpClient {
onTurnStarted?: (turnId: string) => void,
shouldCancel?: () => boolean,
): Promise<TurnCompletedNotification | null> {
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);
Expand Down Expand Up @@ -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: []};
Expand All @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions src/CodexCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
Expand Down
40 changes: 40 additions & 0 deletions src/__tests__/CodexACPAgent/CodexAcpClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", {
Expand Down
59 changes: 59 additions & 0 deletions src/__tests__/CodexACPAgent/agent-mode.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/__tests__/CodexACPAgent/data/command-status.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
Expand Down
17 changes: 17 additions & 0 deletions src/__tests__/CodexACPAgent/e2e/acp-e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down