Skip to content
Closed
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ Use [OpenAI Codex](https://github.com/openai/codex) from [Agent Client Protocol]
- Typed warnings and errors through the opt-in [AIR session failure extension](docs/air-extensions.md#session-failure).
- All AIR extensions, capabilities, and `_meta` keys: [AIR extensions](docs/air-extensions.md).
- A per-turn [agent file-change report](docs/air-extensions.md#agent-file-change-report) after capability negotiation.
- Client-provided, session-scoped [system prompt append](docs/system-prompt-extension.md), preserving configured developer instructions.
- Client-provided MCP servers over command-based stdio config and HTTP transport.
- Slash commands: `/status`, `/mcp` (with `/mcp reconnect`), `/skills`, `/goal`, `/review`, `/review-branch`, `/review-commit`, `/compact`, and `/logout`, as well as configured skills.

Expand Down
2 changes: 1 addition & 1 deletion docs/air-extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ Only these differences are allowed:
ACP defines no merge for `_meta` keys, so the `_meta` of each report keeps every key that the adapter sent before.
- Bug fixes: a unique MCP startup tool call id, the result of a dynamic tool in `content`,
no output after a tool call ended, and a terminal status for a replayed image generation.
- Feature changes for every client: the `/mcp` command has a new description and an input hint.
- Feature changes for every client: the `/mcp` command has a new description and an input hint; initialization advertises the opt-in [system prompt append extension](system-prompt-extension.md).
- The client gets no AIR-only key.
That is no `_meta.jetbrains.air` key and none of the earlier keys in [Removed keys](#removed-keys).
- The output of a command arrives once, in the chunks or in `content`, as [Zed conventions](#zed-conventions) describe.
Expand Down
42 changes: 42 additions & 0 deletions docs/system-prompt-extension.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Session system prompt append

Clients may send `_meta.systemPrompt.append` on `session/new`, `session/load`,
`session/resume`, and `session/fork` to install session-scoped developer
instructions. Ordinary prompts remain ordinary user messages. Codex owns tool
execution, history, and compaction; this extension does not replace its base
instructions or write instruction files.

Check the initialization response before using the extension:

```json
{"_meta":{"systemPrompt":{"version":1,"append":true,"maxBytes":262144}}}
```

Example session metadata:

```json
{"_meta":{"systemPrompt":{"append":"Review database migrations for this session."}}}
```

The adapter reads effective user/project `developer_instructions` for the
requested working directory, honoring a `CODEX_CONFIG` override, and appends the
client text with a blank line. The underlying configuration is not modified.
Each session gets its own value; repeated loads reconstruct it from configuration
rather than appending to the already-appended restored instructions.

Absent or whitespace-only text leaves the existing native lifecycle unchanged.
With explicit text, new and forked threads receive the combined value; load and
resume apply it to an **unloaded** thread. Codex ignores instruction overrides on
already-loaded threads, including idle threads. Those requests are rejected
rather than silently claiming success: close/unload the thread first, or open it
in a fresh adapter process. Omitting metadata leaves native restoration and
inheritance behavior to Codex. This is not an in-place instruction editor.

Only the `{ "append": "..." }` form is supported. Replacement strings, unknown
fields, non-string values, and values over 256 KiB in UTF-8 are rejected before
session creation or skill changes. Config lookup failures are propagated; the
adapter never substitutes an empty configuration and loses user instructions.

AIR `customInstructions` remains supported on its upstream new-session path. Combining a nonblank `systemPrompt.append` with AIR `customInstructions` is rejected before thread creation rather than silently choosing an override.

The `clear: true` capability advertises explicit empty append support: `{append:""}` reloads only effective configured developer instructions, clearing a prior session addition. Omitted metadata remains unchanged. This requires an unloaded thread just like a nonempty append.
42 changes: 42 additions & 0 deletions src/CodexAcpClient.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {readSessionInstructionAppend} from "./SessionInstructions";
import {
type ApiKeyAuthRequest,
CODEX_API_KEY_ENV_VAR,
Expand Down Expand Up @@ -672,12 +673,15 @@ export class CodexAcpClient {
}

async resumeSession(request: acp.ResumeSessionRequest, onSubscribed?: () => void): Promise<SessionMetadata> {
const append = readSessionInstructionAppend(request._meta);
const instructionOverride = await this.sessionInstructionOverride(append, request.cwd, request.sessionId);
const additionalDirectories = readAdditionalDirectories(request.cwd, request.additionalDirectories, request._meta);
await this.refreshSkills(request.cwd, additionalDirectories);

const sessionConfig = await this.createSessionConfig(request.cwd, additionalDirectories, request.mcpServers ?? []);
const response = await this.resumeThread({
excludeTurns: true,
...instructionOverride,
config: sessionConfig.config,
cwd: request.cwd,
modelProvider: await this.getResumeModelProvider(),
Expand Down Expand Up @@ -706,6 +710,7 @@ export class CodexAcpClient {
const additionalDirectories = readAdditionalDirectories(request.cwd, request.additionalDirectories, request._meta);
return await runForkSession(request, additionalDirectories, {
codexClient: this.codexClient,
sessionInstructionOverride: (append, cwd) => this.sessionInstructionOverride(append, cwd),
refreshSkills: (cwd, directories) => this.refreshSkills(cwd, directories),
createSessionConfig: (cwd, directories, mcpServers) =>
this.createSessionConfig(cwd, directories, mcpServers),
Expand All @@ -719,12 +724,15 @@ export class CodexAcpClient {
}

async loadSession(request: acp.LoadSessionRequest, onSubscribed?: () => void): Promise<SessionMetadataWithThread> {
const append = readSessionInstructionAppend(request._meta);
const instructionOverride = await this.sessionInstructionOverride(append, request.cwd, request.sessionId);
const additionalDirectories = readAdditionalDirectories(request.cwd, request.additionalDirectories, request._meta);
await this.refreshSkills(request.cwd, additionalDirectories);

const sessionConfig = await this.createSessionConfig(request.cwd, additionalDirectories, request.mcpServers ?? []);
const response = await this.resumeThread({
excludeTurns: true,
...instructionOverride,
config: sessionConfig.config,
cwd: request.cwd,
modelProvider: await this.getResumeModelProvider(),
Expand Down Expand Up @@ -789,12 +797,21 @@ export class CodexAcpClient {
}

async newSession(request: acp.NewSessionRequest): Promise<SessionMetadata> {
const append = readSessionInstructionAppend(request._meta);
const custom = airCustomInstructions(request._meta);
// Both extensions target developerInstructions. Never silently replace an
// append with AIR's override; callers must choose one instruction contract.
if (append !== undefined && custom !== undefined) {
throw RequestError.invalidParams(undefined, "systemPrompt.append and AIR customInstructions cannot be combined");
}
const instructionOverride = await this.sessionInstructionOverride(append, request.cwd);
const additionalDirectories = readAdditionalDirectories(request.cwd, request.additionalDirectories, request._meta);
const developerInstructions = airCustomInstructions(request._meta);
await this.refreshSkills(request.cwd, additionalDirectories);

const sessionConfig = await this.createSessionConfig(request.cwd, additionalDirectories, request.mcpServers);
const response = await this.codexClient.threadStart({
...instructionOverride,
config: sessionConfig.config,
modelProvider: this.getModelProvider(),
cwd: request.cwd,
Expand Down Expand Up @@ -936,6 +953,31 @@ export class CodexAcpClient {
return this.codexClient.mcpStartup.version();
}

private async sessionInstructionOverride(append: string | undefined, cwd: string, sessionId?: string): Promise<{developerInstructions?: string}> {
if (append === undefined) return {};
if (sessionId !== undefined) {
// Codex ignores instruction overrides for already-loaded threads, even
// idle ones. Fail explicitly rather than claiming a role was applied.
let cursor: string | undefined;
do {
const page = await this.codexClient.threadLoadedList({...(cursor !== undefined && {cursor})});
if (page.data.includes(sessionId)) {
throw RequestError.invalidParams(undefined, "Cannot append instructions to a loaded thread; close it before loading or resuming with instructions");
}
cursor = page.nextCursor ?? undefined;
} while (cursor !== undefined);
}
const effective = await this.codexClient.configRead({cwd, includeLayers: false});
// Read the effective project/user configuration, not restored thread text:
// the latter may already include this append and would duplicate it on load.
const configured = this.config["developer_instructions"] !== undefined
? this.config["developer_instructions"] : effective.config.developer_instructions;
if (configured != null && typeof configured !== "string") {
throw RequestError.invalidParams(undefined, "Configured developer_instructions must be a string");
}
return {developerInstructions: configured ? (append ? `${configured}\n\n${append}` : configured) : append};
}

private async createSessionConfig(
projectPath: string,
additionalDirectories: string[],
Expand Down
6 changes: 6 additions & 0 deletions src/CodexAcpServer.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {readSessionInstructionAppend, SYSTEM_PROMPT_CAPABILITY} from "./SessionInstructions";
import * as acp from "@agentclientprotocol/sdk";
import {RequestError, type SessionId, type SessionModeState} from "@agentclientprotocol/sdk";
import {CodexEventHandler, type CompletedPlan} from "./CodexEventHandler";
Expand Down Expand Up @@ -511,6 +512,7 @@ export class CodexAcpServer {
},
authMethods: getCodexAuthMethods(_params.clientCapabilities),
_meta: {
systemPrompt: SYSTEM_PROMPT_CAPABILITY,
steering: {
supported: true,
},
Expand Down Expand Up @@ -1142,6 +1144,7 @@ export class CodexAcpServer {
}

async loadSession(params: acp.LoadSessionRequest): Promise<LegacyLoadSessionResponse> {
readSessionInstructionAppend(params._meta);
return this.recovery === null
? await this.loadSessionOnce(params)
: await this.recovery.trackOpen(params.sessionId, () => this.loadSessionOnce(params));
Expand Down Expand Up @@ -1198,6 +1201,7 @@ export class CodexAcpServer {
}

async resumeSession(params: acp.ResumeSessionRequest): Promise<LegacyResumeSessionResponse> {
readSessionInstructionAppend(params._meta);
return this.recovery === null
? await this.resumeSessionOnce(params)
: await this.recovery.trackOpen(params.sessionId, () => this.resumeSessionOnce(params));
Expand Down Expand Up @@ -1225,6 +1229,7 @@ export class CodexAcpServer {
}

async forkSession(params: acp.ForkSessionRequest): Promise<acp.ForkSessionResponse> {
readSessionInstructionAppend(params._meta);
if (this.providerUpdate !== null) {
await this.waitForProviderUpdate();
}
Expand Down Expand Up @@ -1357,6 +1362,7 @@ export class CodexAcpServer {
async newSession(
params: acp.NewSessionRequest,
): Promise<LegacyNewSessionResponse> {
readSessionInstructionAppend(params._meta);
if (this.providerUpdate !== null) {
await this.waitForProviderUpdate();
}
Expand Down
4 changes: 3 additions & 1 deletion src/JsonSnapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ export class JsonText {
}

/** Deeper values go to `JSON.stringify`, so that the walk does not use more stack than the serializer. */
const MAX_DEPTH = 256;
// Leave stack headroom for native JSON.stringify on Node/ARM64; a deep
// subtree falls back before the recursive snapshot walk consumes its budget.
const MAX_DEPTH = 64;
const apply = Reflect.apply;
const getPrototypeOf = Object.getPrototypeOf;
/** `JSON.rawJSON` values serialize as their raw text. */
Expand Down
5 changes: 5 additions & 0 deletions src/SessionFork.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {readSessionInstructionAppend} from "./SessionInstructions";
import {createHash} from "node:crypto";
import type * as acp from "@agentclientprotocol/sdk";
import {RequestError} from "@agentclientprotocol/sdk";
Expand All @@ -10,6 +11,7 @@ import type {SessionMetadata} from "./SessionMetadata";

export type SessionForkDependencies = {
codexClient: CodexAppServerClient;
sessionInstructionOverride(append: string | undefined, cwd: string): Promise<{developerInstructions?: string}>;
refreshSkills(cwd: string, additionalDirectories: string[]): Promise<void>;
createSessionConfig(
cwd: string,
Expand All @@ -29,6 +31,8 @@ export async function forkSession(
additionalDirectories: string[],
dependencies: SessionForkDependencies,
): Promise<SessionMetadata> {
const append = readSessionInstructionAppend(request._meta);
const instructionOverride = await dependencies.sessionInstructionOverride(append, request.cwd);
await dependencies.refreshSkills(request.cwd, additionalDirectories);
const lastTurnId = await resolveForkTurnId(request, dependencies.codexClient);
const sessionConfig = await dependencies.createSessionConfig(
Expand All @@ -37,6 +41,7 @@ export async function forkSession(
request.mcpServers ?? [],
);
const response = await dependencies.codexClient.threadFork({
...instructionOverride,
excludeTurns: true,
config: sessionConfig.config,
cwd: request.cwd,
Expand Down
20 changes: 20 additions & 0 deletions src/SessionInstructions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import {RequestError} from "@agentclientprotocol/sdk";

export const SYSTEM_PROMPT_CAPABILITY = {version: 1, append: true, clear: true, maxBytes: 256 * 1024};

/** Session metadata is an opt-in append, never a replacement of Codex's base prompt. */
export function readSessionInstructionAppend(meta?: Record<string, unknown> | null): string | undefined {
const prompt = meta?.["systemPrompt"];
if (prompt === undefined) return undefined;
if (prompt === null || typeof prompt !== "object" || Array.isArray(prompt)
|| Object.keys(prompt).some(key => key !== "append")
|| !("append" in prompt) || typeof prompt.append !== "string") {
throw RequestError.invalidParams(undefined, "systemPrompt must contain only an append string");
}
if (Buffer.byteLength(prompt.append, "utf8") > SYSTEM_PROMPT_CAPABILITY.maxBytes) {
throw RequestError.invalidParams(undefined, `systemPrompt.append exceeds ${SYSTEM_PROMPT_CAPABILITY.maxBytes} UTF-8 bytes`);
}
// Explicit empty append resets the per-session addition to configured rules.
// Omitted metadata still preserves native history without an override.
return prompt.append;
}
10 changes: 7 additions & 3 deletions src/__tests__/CodexACPAgent/agent-file-change-report.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {realpathSync} from "node:fs";
import {beforeEach, describe, expect, it, vi} from "vitest";
import * as acp from "@agentclientprotocol/sdk";
import {
Expand All @@ -11,6 +12,9 @@ import {logger} from "../../Logger";
import type {Turn, TurnCompletedNotification} from "../../app-server/v2";
import {AGENT_FILE_CHANGE_REPORT_MAX_DIFF_BYTES} from "../../AgentFileChangeReport";

// Use a real fixture root: /workspace may be a symlink on the test host.
const reportWorkspace = realpathSync(process.cwd());

function createTurn(id: string, status: Turn["status"]): Turn {
return {
id,
Expand Down Expand Up @@ -73,7 +77,7 @@ async function setupMainPrompt(negotiateCapability = true): Promise<{
...(negotiateCapability ? {clientCapabilities: FILE_CHANGE_REPORT_CLIENT_CAPABILITIES} : {}),
});
const sessionState = createTestSessionState({
cwd: "/workspace",
cwd: reportWorkspace,
additionalDirectories: ["/generated"],
});
vi.spyOn(fixture.getCodexAcpAgent(), "getSessionState").mockReturnValue(sessionState);
Expand Down Expand Up @@ -134,7 +138,7 @@ describe("agent file-change report lifecycle", () => {
version: 1,
requestId: "request-42",
status: "reported",
paths: ["/workspace/src/Main.kt"],
paths: [`${reportWorkspace}/src/Main.kt`],
declaredComplete: false,
truncated: false,
uncertainty: "Codex turn diffs may omit same-content renames and changes made outside apply_patch, including shell commands, version-control commands, generators, and child processes.",
Expand Down Expand Up @@ -164,7 +168,7 @@ describe("agent file-change report lifecycle", () => {
);

expect(reportedUpdates(fixture)[0]).toMatchObject({
_meta: {jetbrains: {air: {agentFileChangeReport: {paths: ["/workspace/final.txt"]}}}},
_meta: {jetbrains: {air: {agentFileChangeReport: {paths: [`${reportWorkspace}/final.txt`]}}}},
});
});

Expand Down
2 changes: 2 additions & 0 deletions src/__tests__/CodexACPAgent/initialize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ describe('CodexACPAgent - initialize', () => {
},
authMethods: getCodexAuthMethods(),
_meta: {
systemPrompt: {version: 1, append: true, clear: true, maxBytes: 262144},
steering: {
supported: true,
},
Expand All @@ -80,6 +81,7 @@ describe('CodexACPAgent - initialize', () => {
clientCapabilities: {_meta: {jetbrains: {air: {version: 1, capabilities: []}}}},
});
expect(result._meta).toEqual({
systemPrompt: {version: 1, append: true, clear: true, maxBytes: 262144},
steering: {
supported: true,
},
Expand Down
Loading