Skip to content
Merged
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
10 changes: 10 additions & 0 deletions docs/agent-mode-and-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,16 @@ When the autonomous agent is enabled, Copilot can:

The agent activates automatically when you're in **Copilot Plus** mode. You don't need to do anything special — just ask your question.

## Choosing an Operating Mode

The mode picker beside the message box controls how much the active agent can do:

- **Default** — the agent can work in your vault and asks before sensitive actions.
- **Plan** — the agent reads and reasons without changing your vault.
- **Auto** — the agent can work without individual approval prompts. Use it only when you trust the task and workspace.

The available modes depend on the selected agent. Copilot normalizes equivalent modes across supported versions of Claude, Codex, and OpenCode.

### Max Iterations

The agent works in iteration cycles (think → use a tool → think → use a tool → answer). You can control the maximum number of iterations before the agent stops:
Expand Down
111 changes: 110 additions & 1 deletion src/agentMode/backends/codex/CodexBackend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,21 @@ describe("CodexBackend.buildSpawnDescriptor", () => {
});
});

it("forwards the Copilot base prompt + pill-syntax directive via -c developer_instructions", async () => {
it("forwards the Copilot prompt through both current and legacy adapter config paths", async () => {
const backend = new CodexBackend();
const desc = await backend.buildSpawnDescriptor({ vaultBasePath: "/vault" });
expect(desc.command).toBe("/usr/local/bin/codex-acp");

const config = JSON.parse(desc.env.CODEX_CONFIG as string);
expect(config.developer_instructions).toContain("Obsidian Copilot");
expect(config.developer_instructions).toContain(
"NOT a software-engineering agent or CLI coding tool"
);
expect(config.developer_instructions).toContain("{folder_name}");
expect(config.developer_instructions).toContain("{activeNote}");
expect(config.developer_instructions).not.toContain("metadata.copilot-enabled-agents");
expect(config.developer_instructions).not.toContain("copilot/skills/<name>/SKILL.md");

const cIdx = desc.args.indexOf("-c");
expect(cIdx).toBeGreaterThanOrEqual(0);
const value = desc.args[cIdx + 1];
Expand Down Expand Up @@ -179,6 +190,104 @@ describe("CodexBackend.buildSpawnDescriptor", () => {
'sandbox_mode="workspace-write"',
])
);
expect(JSON.parse(desc.env.CODEX_CONFIG as string)).toEqual(
expect.objectContaining({
approval_policy: "on-request",
sandbox_mode: "workspace-write",
})
);
});

it("preserves user CODEX_CONFIG keys while enforcing Copilot-owned fields", async () => {
setSettings({
agentMode: {
byok: {},
mcpServers: [],
activeBackend: "codex",
debugFullFrames: false,
welcomeDismissed: false,
skills: { folder: "copilot/skills" },
backends: {
codex: {
binaryPath: "/usr/local/bin/codex-acp",
envOverrides: {
CODEX_CONFIG: JSON.stringify({
model: "custom-model",
developer_instructions: "drop Copilot prompt",
approval_policy: "never",
sandbox_mode: "danger-full-access",
}),
},
},
},
},
});

const desc = await new CodexBackend().buildSpawnDescriptor({ vaultBasePath: "/vault" });
const config = JSON.parse(desc.env.CODEX_CONFIG as string);
expect(config).toEqual(
expect.objectContaining({
model: "custom-model",
approval_policy: "on-request",
sandbox_mode: "workspace-write",
})
);
expect(config.developer_instructions).toContain("Obsidian Copilot");
expect(config.developer_instructions).not.toContain("drop Copilot prompt");
});

it.each(["not-json", "[]", "null"])(
"rejects an invalid CODEX_CONFIG override without echoing it (%s)",
async (CODEX_CONFIG) => {
setSettings({
agentMode: {
byok: {},
mcpServers: [],
activeBackend: "codex",
debugFullFrames: false,
welcomeDismissed: false,
skills: { folder: "copilot/skills" },
backends: {
codex: {
binaryPath: "/usr/local/bin/codex-acp",
envOverrides: { CODEX_CONFIG },
},
},
},
});

await expect(
new CodexBackend().buildSpawnDescriptor({ vaultBasePath: "/vault" })
).rejects.toThrow("Codex CODEX_CONFIG must be a valid JSON object.");
}
);

it("starts current codex-acp adapters in their canonical default mode", async () => {
const backend = new CodexBackend();
const desc = await backend.buildSpawnDescriptor({ vaultBasePath: "/vault" });
expect(desc.env.INITIAL_AGENT_MODE).toBe("agent");
});

it("lets a user override the initial codex-acp mode", async () => {
setSettings({
agentMode: {
byok: {},
mcpServers: [],
activeBackend: "codex",
debugFullFrames: false,
welcomeDismissed: false,
skills: { folder: "copilot/skills" },
backends: {
codex: {
binaryPath: "/usr/local/bin/codex-acp",
envOverrides: { INITIAL_AGENT_MODE: "read-only" },
},
},
},
});
const backend = new CodexBackend();
const desc = await backend.buildSpawnDescriptor({ vaultBasePath: "/vault" });
expect(desc.env.INITIAL_AGENT_MODE).toBe("read-only");
});

it("does not add a project.md fallback to the codex spawn args", async () => {
Expand Down
23 changes: 16 additions & 7 deletions src/agentMode/backends/codex/CodexBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ import { AcpBackend, AcpSpawnDescriptor } from "@/agentMode/acp/types";
import { buildSimpleSpawnDescriptor } from "@/agentMode/backends/shared/simpleBinaryBackend";
import { buildAgentSystemPrompt } from "@/agentMode/backends/shared/agentSystemPrompt";
import { buildCopilotPlusEnv } from "@/agentMode/backends/shared/copilotPlusEnv";
import { mergeCodexConfigEnv } from "./codexConfigEnv";

/**
* Spawns the user-provided `codex-acp` binary
* (`@zed-industries/codex-acp`). The package wraps the local `codex` CLI
* and exposes it as an ACP server over stdio. Authentication is inherited
* Spawns the user-provided `codex-acp` binary. The package exposes Codex as
* an ACP server over stdio. Authentication is inherited
* from the user's existing `codex login` (`~/.codex/auth.json`) or
* `OPENAI_API_KEY` / `CODEX_API_KEY` exported in the user's shell — we
* deliberately do not inject keys so ChatGPT-login subscriptions work
Expand All @@ -22,8 +22,13 @@ export class CodexBackend implements AcpBackend {
getSettings().agentMode?.backends?.codex?.binaryPath,
"Codex binary path not configured. Open Agent Mode settings and set the path to codex-acp.",
getSettings().agentMode?.backends?.codex?.envOverrides,
// Builtin Copilot Plus skill scripts read the license from the env.
await buildCopilotPlusEnv()
{
// Builtin Copilot Plus skill scripts read the license from the env.
...(await buildCopilotPlusEnv()),
// Newer adapters derive the initial ACP mode from this variable rather
// than Codex's approval/sandbox config. User env overrides still win.
INITIAL_AGENT_MODE: "agent",
}
);
// Forward the shared composed system prompt — the Copilot base framing
// (unless the user disabled it), the pill-syntax directive, and the user's
Expand All @@ -33,12 +38,16 @@ export class CodexBackend implements AcpBackend {
// spawn time; the host restarts codex on prompt changes via
// `restartOnSystemPromptChange`.
const directive = buildAgentSystemPrompt();
// Current @agentclientprotocol/codex-acp server mode ignores arbitrary
// argv and merges CODEX_CONFIG into every session. Keep the argv path
// below for legacy @zed-industries/codex-acp versions.
descriptor.env.CODEX_CONFIG = mergeCodexConfigEnv(descriptor.env.CODEX_CONFIG, directive);
descriptor.args = [
...descriptor.args,
"-c",
`developer_instructions=${toTomlBasicString(directive)}`,
// Pin spawn-time approval/sandbox so codex-acp's first
// `currentModeId` report matches the canonical `auto` preset
// Pin spawn-time approval/sandbox so legacy codex-acp's first
// `currentModeId` report matches its canonical `auto` preset
// (workspace-write + on-request), which Agent Mode surfaces as
// canonical `default` (ask mode). Without this, codex-acp derives
// the initial mode from the user's `~/.codex/config.toml` defaults
Expand Down
38 changes: 38 additions & 0 deletions src/agentMode/backends/codex/codexConfigEnv.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
interface CodexManagedConfig {
developer_instructions: string;
approval_policy: "on-request";
sandbox_mode: "workspace-write";
}

function parseCodexConfig(value: string | undefined): Record<string, unknown> {
if (!value?.trim()) return {};

let parsed: unknown;
try {
parsed = JSON.parse(value);
} catch {
throw new Error("Codex CODEX_CONFIG must be a valid JSON object.");
}

if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("Codex CODEX_CONFIG must be a valid JSON object.");
}
return parsed as Record<string, unknown>;
}

/**
* Current codex-acp versions ignore server-mode argv and consume Codex config
* from this JSON env var. Plugin-owned fields win so inherited/user config
* cannot silently remove the prompt and safety defaults Agent Mode requires.
*/
export function mergeCodexConfigEnv(
existing: string | undefined,
developerInstructions: string
): string {
const managed: CodexManagedConfig = {
developer_instructions: developerInstructions,
approval_policy: "on-request",
sandbox_mode: "workspace-write",
};
return JSON.stringify({ ...parseCodexConfig(existing), ...managed });
}
77 changes: 77 additions & 0 deletions src/agentMode/backends/codex/codexModeMapping.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { translateBackendState } from "@/agentMode/session/translateBackendState";
import type { BackendDescriptor, RawModeState } from "@/agentMode/session/types";
import { buildCodexModeMapping } from "./codexModeMapping";

function modes(currentModeId: string, ids: string[]): RawModeState {
return {
currentModeId,
availableModes: ids.map((id) => ({ id, name: id })),
};
}

describe("buildCodexModeMapping", () => {
it("maps the current agentclientprotocol adapter inventory", () => {
const modeState = modes("agent", ["read-only", "agent", "agent-full-access"]);
const mapping = buildCodexModeMapping(modeState);
const state = translateBackendState({ models: null, modes: modeState, configOptions: null }, {
getModeMapping: buildCodexModeMapping,
} as unknown as BackendDescriptor);

expect(mapping.canonical).toEqual({
default: "agent",
plan: "read-only",
auto: "agent-full-access",
});
expect(mapping.readOnlyModeId).toBe("read-only");
expect(state.mode).toEqual({
current: "default",
options: [
{ value: "default", label: "Default" },
{ value: "plan", label: "Plan" },
{ value: "auto", label: "Auto" },
],
apply: {
default: { kind: "setMode", nativeId: "agent" },
plan: { kind: "setMode", nativeId: "read-only" },
auto: { kind: "setMode", nativeId: "agent-full-access" },
},
});
});

it("keeps the legacy zed adapter inventory working", () => {
const mapping = buildCodexModeMapping(modes("auto", ["read-only", "auto", "full-access"]));

expect(mapping.canonical).toEqual({
default: "auto",
plan: "read-only",
auto: "full-access",
});
});

it("prefers a genuine native plan mode when advertised", () => {
const mapping = buildCodexModeMapping(
modes("plan", ["read-only", "agent", "plan", "agent-full-access"])
);

expect(mapping.canonical.plan).toBe("plan");
});

it("omits canonical choices that the adapter does not advertise", () => {
const mapping = buildCodexModeMapping(modes("custom", ["custom"]));

expect(mapping.canonical).toEqual({
default: undefined,
plan: undefined,
auto: undefined,
});
expect(mapping.readOnlyModeId).toBeNull();
});

it("retains the legacy read-only contract for inventory-free fan-out setup", () => {
expect(buildCodexModeMapping(null)).toEqual({
kind: "setMode",
canonical: { default: "auto", plan: "read-only", auto: "full-access" },
readOnlyModeId: "read-only",
});
});
});
46 changes: 46 additions & 0 deletions src/agentMode/backends/codex/codexModeMapping.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { ModeMapping, RawModeState } from "@/agentMode/session/types";

const CODEX_MODE_CANDIDATES = {
default: ["agent", "auto", "default"],
plan: ["plan", "read-only"],
auto: ["agent-full-access", "full-access", "bypassPermissions"],
} as const;

const LEGACY_CODEX_MODES = {
default: "auto",
plan: "read-only",
auto: "full-access",
} as const;

function firstAdvertised(
advertised: ReadonlySet<string>,
candidates: readonly string[]
): string | undefined {
return candidates.find((candidate) => advertised.has(candidate));
}

/**
* Codex ACP adapters have used multiple native mode vocabularies. Resolve
* against the live inventory so an adapter rename cannot silently remove the
* user's path out of a restrictive mode.
*/
export function buildCodexModeMapping(modeState: RawModeState | null): ModeMapping {
if (!modeState) {
return {
kind: "setMode",
canonical: LEGACY_CODEX_MODES,
readOnlyModeId: "read-only",
};
}

const advertised = new Set(modeState.availableModes.map((mode) => mode.id));
return {
kind: "setMode",
canonical: {
default: firstAdvertised(advertised, CODEX_MODE_CANDIDATES.default),
plan: firstAdvertised(advertised, CODEX_MODE_CANDIDATES.plan),
auto: firstAdvertised(advertised, CODEX_MODE_CANDIDATES.auto),
},
readOnlyModeId: advertised.has("read-only") ? "read-only" : null,
};
}
Loading
Loading