diff --git a/.claude/skills/run-codex/scripts/run-codex-test.ts b/.claude/skills/run-codex/scripts/run-codex-test.ts index d74b28c3..0e69273e 100644 --- a/.claude/skills/run-codex/scripts/run-codex-test.ts +++ b/.claude/skills/run-codex/scripts/run-codex-test.ts @@ -17,12 +17,19 @@ import {CodexAcpServer} from "../../../../src/CodexAcpServer"; import type {AgentSideConnection} from "@agentclientprotocol/sdk"; // Parse command line arguments -function parseArgs(): { prompt: string; cwd: string; output: string; json: boolean } { +function parseArgs(): { + prompt: string; + cwd: string; + output: string; + json: boolean; + systemPromptAppend?: string; +} { const args = process.argv.slice(2); let prompt = ""; let cwd = process.cwd(); let output = "all"; let json = false; + let systemPromptAppend: string | undefined; for (let i = 0; i < args.length; i++) { const arg = args[i]; @@ -34,6 +41,8 @@ function parseArgs(): { prompt: string; cwd: string; output: string; json: boole output = args[++i] || "all"; } else if (arg === "--json") { json = true; + } else if (arg === "--system-prompt-append") { + systemPromptAppend = args[++i] || ""; } else if (arg === "--help" || arg === "-h") { console.log(` Usage: npm run codex-test -- [options] @@ -43,6 +52,8 @@ Options: -c, --cwd Working directory for the session (default: current dir) -o, --output Output type: all, codex, acp, summary (default: all) --json Output events as JSON + --system-prompt-append + Append session-scoped developer instructions -h, --help Show this help message Examples: @@ -59,7 +70,7 @@ Examples: process.exit(1); } - return { prompt, cwd, output, json }; + return { prompt, cwd, output, json, systemPromptAppend }; } type MethodCallEvent = { method: string; args: unknown[] }; @@ -76,7 +87,7 @@ function createMockAcpConnection(events: MethodCallEvent[]): AgentSideConnection } async function main() { - const { prompt, cwd, output, json } = parseArgs(); + const { prompt, cwd, output, json, systemPromptAppend } = parseArgs(); // Find Codex binary const pathToCodex = path.resolve(process.cwd(), "node_modules", ".bin", process.platform === "win32" ? "codex.cmd" : "codex"); @@ -91,6 +102,7 @@ async function main() { console.log(`Prompt: ${prompt}`); console.log(`CWD: ${cwd}`); console.log(`Output: ${output}`); + console.log(`System prompt append: ${systemPromptAppend?.trim() ? "configured" : "none"}`); console.log("=".repeat(60)); console.log(""); @@ -145,7 +157,13 @@ async function main() { // Create session console.log("\n--- Creating Session ---\n"); - const sessionResponse = await codexAcpAgent.newSession({ cwd, mcpServers: [] }); + const sessionResponse = await codexAcpAgent.newSession({ + cwd, + mcpServers: [], + ...(systemPromptAppend && { + _meta: {systemPrompt: {append: systemPromptAppend}}, + }), + }); console.log(`Session ID: ${sessionResponse.sessionId}`); console.log(`Model: ${sessionResponse.models?.currentModelId}`); diff --git a/README.md b/README.md index 092f61ba..63bde6d6 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Use [OpenAI Codex](https://github.com/openai/codex) from [Agent Client Protocol] - Shell command, file change, [permission request](docs/permission-extension.md), MCP tool call, terminal output, reasoning, plan, web search, image generation, image view, token usage, and review events. - [Native ACP subagent sessions](docs/subagent-sessions.md) (after capability negotiation) with separate child histories and root-routed permissions; a legacy tool-call fallback otherwise. - Session-scoped long-running goals through the provider-neutral [goal extension](docs/goal-extension.md). +- Client-provided, session-scoped instructions through the [system prompt append extension](docs/system-prompt-extension.md), mapped to Codex developer instructions without replacing its base prompt. - A per-turn [agent file-change report](docs/agent-file-change-report.md) after capability negotiation. - Client-provided MCP servers over command-based stdio config and HTTP transport. - Slash commands: `/status`, `/mcp`, `/skills`, `/goal`, `/review`, `/review-branch`, `/review-commit`, `/compact`, and `/logout`, as well as configured skills. diff --git a/docs/system-prompt-extension.md b/docs/system-prompt-extension.md new file mode 100644 index 00000000..fd4e9eed --- /dev/null +++ b/docs/system-prompt-extension.md @@ -0,0 +1,46 @@ +# System prompt append extension + +`codex-acp` supports appending client-owned, session-scoped instructions without replacing Codex's base/system prompt. The adapter maps the appended text to Codex `developerInstructions`, which is injected as a developer-role instruction layer. + +## Capability + +The adapter advertises the extension in the `initialize` response: + +```json +{ + "_meta": { + "systemPrompt": { + "version": 1, + "append": true, + "maxBytes": 262144 + } + } +} +``` + +`append: true` is the only supported mode. The adapter does not support replacing Codex's base instructions. + +## Session requests + +Clients append instructions with `_meta.systemPrompt.append`: + +```json +{ + "cwd": "/workspace/project", + "mcpServers": [], + "_meta": { + "systemPrompt": { + "append": "Act as a database performance expert for this session." + } + } +} +``` + +The extension is accepted on `session/new`, `session/resume`, `session/load`, and `session/fork`: + +- On `session/new`, the text configures the new Codex thread's developer instructions. +- On `session/resume` and `session/load`, supplied text is reapplied as the thread configuration override. Omitting the field leaves Codex's restored configuration unchanged. +- On `session/fork`, supplied text is applied to the fork. Omitting it leaves instruction inheritance to Codex. +- Ordinary `session/prompt` requests never repeat or modify the session-scoped instructions. + +Blank append text is treated as absent. Non-string append values, unsupported fields, string-form `systemPrompt` overrides, and content larger than the advertised UTF-8 byte limit are rejected with `invalid_params` before session side effects. diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index 8d78cae0..e0544050 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -66,6 +66,7 @@ import { import {CodexSubagentSubscriptions} from "./subagents/CodexSubagentSubscriptions"; import {forkSession as runForkSession} from "./SessionFork"; import type {SessionMetadata, SessionMetadataWithThread} from "./SessionMetadata"; +import {readSystemPromptAppend} from "./SystemPrompt"; export type {SessionMetadata, SessionMetadataWithThread} from "./SessionMetadata"; /** @@ -471,6 +472,7 @@ export class CodexAcpClient { } async resumeSession(request: acp.ResumeSessionRequest, onSubscribed?: () => void): Promise { + const developerInstructions = readSystemPromptAppend(request._meta); const additionalDirectories = readAdditionalDirectories(request.cwd, request.additionalDirectories, request._meta); await this.refreshSkills(request.cwd, additionalDirectories); @@ -479,6 +481,7 @@ export class CodexAcpClient { cwd: request.cwd, modelProvider: await this.getResumeModelProvider(), threadId: request.sessionId, + ...(developerInstructions !== undefined && {developerInstructions}), }); onSubscribed?.(); const codexModels = await this.fetchAvailableModels(); @@ -510,6 +513,7 @@ export class CodexAcpClient { } async loadSession(request: acp.LoadSessionRequest, onSubscribed?: () => void): Promise { + const developerInstructions = readSystemPromptAppend(request._meta); const additionalDirectories = readAdditionalDirectories(request.cwd, request.additionalDirectories, request._meta); await this.refreshSkills(request.cwd, additionalDirectories); @@ -518,6 +522,7 @@ export class CodexAcpClient { cwd: request.cwd, modelProvider: await this.getResumeModelProvider(), threadId: request.sessionId, + ...(developerInstructions !== undefined && {developerInstructions}), }); onSubscribed?.(); const historyResponse = await this.codexClient.threadRead({ @@ -546,6 +551,7 @@ export class CodexAcpClient { } async newSession(request: acp.NewSessionRequest): Promise { + const developerInstructions = readSystemPromptAppend(request._meta); const additionalDirectories = readAdditionalDirectories(request.cwd, request.additionalDirectories, request._meta); await this.refreshSkills(request.cwd, additionalDirectories); @@ -553,6 +559,7 @@ export class CodexAcpClient { config: await this.createSessionConfig(request.cwd, additionalDirectories, request.mcpServers), modelProvider: this.getModelProvider(), cwd: request.cwd, + ...(developerInstructions !== undefined && {developerInstructions}), }); const codexModels = await this.fetchAvailableModels(); diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index 3828e7c4..6cc4b9db 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -49,6 +49,7 @@ import {SteeringQueue} from "./SteeringQueue"; import type {QuotaMeta} from "./QuotaMeta"; import {logger} from "./Logger"; import {sanitizeMcpServerName} from "./McpServerName"; +import {readSystemPromptAppend, SYSTEM_PROMPT_CAPABILITY} from "./SystemPrompt"; import {createResponseItemHistoryFallbackUpdates} from "./ResponseItemHistoryFallback"; import { GOAL_CONTROL_ACTIONS, @@ -347,6 +348,7 @@ export class CodexAcpServer { }, authMethods: getCodexAuthMethods(_params.clientCapabilities), _meta: { + systemPrompt: SYSTEM_PROMPT_CAPABILITY, steering: { supported: true, }, @@ -710,6 +712,7 @@ export class CodexAcpServer { } async loadSession(params: acp.LoadSessionRequest): Promise { + readSystemPromptAppend(params._meta); if (this.providerUpdate !== null) { await this.providerUpdate; } @@ -736,6 +739,7 @@ export class CodexAcpServer { } async resumeSession(params: acp.ResumeSessionRequest): Promise { + readSystemPromptAppend(params._meta); if (this.providerUpdate !== null) { await this.providerUpdate; } @@ -755,6 +759,7 @@ export class CodexAcpServer { } async forkSession(params: acp.ForkSessionRequest): Promise { + readSystemPromptAppend(params._meta); if (this.providerUpdate !== null) { await this.providerUpdate; } @@ -867,6 +872,7 @@ export class CodexAcpServer { async newSession( params: acp.NewSessionRequest, ): Promise { + readSystemPromptAppend(params._meta); if (this.providerUpdate !== null) { await this.providerUpdate; } diff --git a/src/SessionFork.ts b/src/SessionFork.ts index b52b7cae..edaad74c 100644 --- a/src/SessionFork.ts +++ b/src/SessionFork.ts @@ -6,6 +6,7 @@ import type {ModeKind} from "./app-server/ModeKind"; import type {ServiceTier} from "./app-server/ServiceTier"; import type {Model, ThreadForkParams} from "./app-server/v2"; import type {SessionMetadata} from "./SessionMetadata"; +import {readSystemPromptAppend} from "./SystemPrompt"; export type SessionForkDependencies = { codexClient: CodexAppServerClient; @@ -26,6 +27,7 @@ export async function forkSession( additionalDirectories: string[], dependencies: SessionForkDependencies, ): Promise { + const developerInstructions = readSystemPromptAppend(request._meta); await dependencies.refreshSkills(request.cwd, additionalDirectories); const lastTurnId = await resolveForkTurnId(request, dependencies.codexClient); const response = await dependencies.codexClient.threadFork({ @@ -36,6 +38,7 @@ export async function forkSession( ), cwd: request.cwd, ...(lastTurnId !== undefined && {lastTurnId}), + ...(developerInstructions !== undefined && {developerInstructions}), modelProvider: await dependencies.getResumeModelProvider(), threadId: request.sessionId, }); diff --git a/src/SystemPrompt.ts b/src/SystemPrompt.ts new file mode 100644 index 00000000..c6b739ba --- /dev/null +++ b/src/SystemPrompt.ts @@ -0,0 +1,63 @@ +import {RequestError} from "@agentclientprotocol/sdk"; + +export const SYSTEM_PROMPT_EXTENSION_VERSION = 1; +export const SYSTEM_PROMPT_APPEND_MAX_BYTES = 256 * 1024; + +export type SystemPromptCapability = { + version: typeof SYSTEM_PROMPT_EXTENSION_VERSION; + append: true; + maxBytes: typeof SYSTEM_PROMPT_APPEND_MAX_BYTES; +}; + +export const SYSTEM_PROMPT_CAPABILITY: SystemPromptCapability = { + version: SYSTEM_PROMPT_EXTENSION_VERSION, + append: true, + maxBytes: SYSTEM_PROMPT_APPEND_MAX_BYTES, +}; + +/** + * Reads the provider-neutral system-prompt extension used on ACP session + * lifecycle requests. Codex receives the appended text as developer + * instructions, leaving its base/system instructions unchanged. + */ +export function readSystemPromptAppend( + meta?: Record | null, +): string | undefined { + const rawSystemPrompt = meta?.["systemPrompt"]; + if (rawSystemPrompt === undefined) { + return undefined; + } + if (!isUnknownRecord(rawSystemPrompt)) { + throw RequestError.invalidParams( + undefined, + "systemPrompt must be an object containing an append string", + ); + } + + const unsupportedKeys = Object.keys(rawSystemPrompt).filter(key => key !== "append"); + if (unsupportedKeys.length > 0) { + throw RequestError.invalidParams( + undefined, + `systemPrompt contains unsupported fields: ${unsupportedKeys.join(", ")}`, + ); + } + + const append = rawSystemPrompt["append"]; + if (typeof append !== "string") { + throw RequestError.invalidParams(undefined, "systemPrompt.append must be a string"); + } + if (new TextEncoder().encode(append).byteLength > SYSTEM_PROMPT_APPEND_MAX_BYTES) { + throw RequestError.invalidParams( + undefined, + `systemPrompt.append must not exceed ${SYSTEM_PROMPT_APPEND_MAX_BYTES} UTF-8 bytes`, + ); + } + if (append.trim().length === 0) { + return undefined; + } + return append; +} + +function isUnknownRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/__tests__/CodexACPAgent/initialize.test.ts b/src/__tests__/CodexACPAgent/initialize.test.ts index 9f8fe458..912be0e2 100644 --- a/src/__tests__/CodexACPAgent/initialize.test.ts +++ b/src/__tests__/CodexACPAgent/initialize.test.ts @@ -64,6 +64,11 @@ describe('CodexACPAgent - initialize', () => { }, authMethods: getCodexAuthMethods(), _meta: { + systemPrompt: { + version: 1, + append: true, + maxBytes: 262144, + }, steering: { supported: true, }, diff --git a/src/__tests__/CodexACPAgent/system-prompt.test.ts b/src/__tests__/CodexACPAgent/system-prompt.test.ts new file mode 100644 index 00000000..8381410c --- /dev/null +++ b/src/__tests__/CodexACPAgent/system-prompt.test.ts @@ -0,0 +1,141 @@ +import {describe, expect, it, vi} from "vitest"; +import type {CodexAcpServer} from "../../CodexAcpServer"; +import { + readSystemPromptAppend, + SYSTEM_PROMPT_APPEND_MAX_BYTES, +} from "../../SystemPrompt"; +import {createCodexMockTestFixture, createTestModel} from "../acp-test-utils"; + +describe("system prompt append metadata", () => { + it("accepts append text without changing its formatting", () => { + expect(readSystemPromptAppend({ + systemPrompt: {append: " You are a database expert.\n"}, + })).toBe(" You are a database expert.\n"); + }); + + it("treats absent and blank append text as unspecified", () => { + expect(readSystemPromptAppend()).toBeUndefined(); + expect(readSystemPromptAppend({systemPrompt: {append: " \n\t "}})).toBeUndefined(); + }); + + it.each([ + [{systemPrompt: "replace the system prompt"}, "systemPrompt must be an object"], + [{systemPrompt: null}, "systemPrompt must be an object"], + [{systemPrompt: {}}, "systemPrompt.append must be a string"], + [{systemPrompt: {append: 42}}, "systemPrompt.append must be a string"], + [{systemPrompt: {append: "valid", mode: "override"}}, "unsupported fields: mode"], + ])("rejects unsupported metadata %#", (meta, message) => { + expect(() => readSystemPromptAppend(meta)).toThrow(message); + }); + + it("applies the byte limit to the raw UTF-8 append text", () => { + const exactlyAtLimit = "é".repeat(SYSTEM_PROMPT_APPEND_MAX_BYTES / 2); + expect(readSystemPromptAppend({systemPrompt: {append: exactlyAtLimit}})).toBe(exactlyAtLimit); + expect(() => readSystemPromptAppend({ + systemPrompt: {append: `${exactlyAtLimit}a`}, + })).toThrow(`must not exceed ${SYSTEM_PROMPT_APPEND_MAX_BYTES} UTF-8 bytes`); + }); + + it("maps append text onto new, resume, load, and fork developer instructions", async () => { + const fixture = createCodexMockTestFixture(); + const client = fixture.getCodexAcpClient(); + const appServer = fixture.getCodexAppServerClient(); + const model = createTestModel({id: "gpt-5"}); + + vi.spyOn(appServer, "skillsExtraRootsSet").mockResolvedValue(undefined); + vi.spyOn(appServer, "listSkills").mockResolvedValue({data: []}); + vi.spyOn(appServer, "configRead").mockResolvedValue({config: {model_provider: "openai"}} as never); + vi.spyOn(appServer, "listModels").mockResolvedValue({data: [model], nextCursor: null}); + const threadStart = vi.spyOn(appServer, "threadStart").mockResolvedValue({ + thread: {id: "new-thread"}, + model: model.id, + modelProvider: "openai", + reasoningEffort: model.defaultReasoningEffort, + serviceTier: null, + } as never); + const threadResume = vi.spyOn(appServer, "threadResume").mockImplementation(async ({threadId}) => ({ + thread: {id: threadId}, + model: model.id, + modelProvider: "openai", + reasoningEffort: model.defaultReasoningEffort, + serviceTier: null, + }) as never); + vi.spyOn(appServer, "threadRead").mockImplementation(async ({threadId}) => ({ + thread: {id: threadId}, + }) as never); + const threadFork = vi.spyOn(appServer, "threadFork").mockResolvedValue({ + thread: {id: "fork-thread"}, + model: model.id, + modelProvider: "openai", + reasoningEffort: model.defaultReasoningEffort, + serviceTier: null, + } as never); + vi.spyOn(appServer, "threadUnsubscribe").mockResolvedValue({status: "unsubscribed"}); + + const _meta = {systemPrompt: {append: "You are a database expert."}}; + await client.newSession({cwd: "/workspace", mcpServers: [], _meta}); + await client.resumeSession({sessionId: "resume-thread", cwd: "/workspace", _meta}); + await client.loadSession({sessionId: "load-thread", cwd: "/workspace", mcpServers: [], _meta}); + await client.forkSession({sessionId: "source-thread", cwd: "/workspace", _meta}); + + expect(threadStart).toHaveBeenCalledWith(expect.objectContaining({ + developerInstructions: "You are a database expert.", + })); + expect(threadStart.mock.calls[0]![0]).not.toHaveProperty("baseInstructions"); + expect(threadResume).toHaveBeenNthCalledWith(1, expect.objectContaining({ + threadId: "resume-thread", + developerInstructions: "You are a database expert.", + })); + expect(threadResume).toHaveBeenNthCalledWith(2, expect.objectContaining({ + threadId: "load-thread", + developerInstructions: "You are a database expert.", + })); + expect(threadFork).toHaveBeenCalledWith(expect.objectContaining({ + threadId: "source-thread", + developerInstructions: "You are a database expert.", + })); + + await client.newSession({cwd: "/workspace", mcpServers: []}); + await client.resumeSession({sessionId: "resume-without-append", cwd: "/workspace"}); + await client.loadSession({ + sessionId: "load-without-append", + cwd: "/workspace", + mcpServers: [], + }); + await client.forkSession({sessionId: "fork-without-append", cwd: "/workspace"}); + + expect(threadStart.mock.calls[1]![0]).not.toHaveProperty("developerInstructions"); + expect(threadResume.mock.calls[2]![0]).not.toHaveProperty("developerInstructions"); + expect(threadResume.mock.calls[3]![0]).not.toHaveProperty("developerInstructions"); + expect(threadFork.mock.calls[1]![0]).not.toHaveProperty("developerInstructions"); + }); + + it.each(["new", "resume", "load", "fork"] as const)( + "rejects invalid metadata before public %s-session side effects", + async route => { + const fixture = createCodexMockTestFixture(); + const agent = fixture.getCodexAcpAgent(); + const client = fixture.getCodexAcpClient(); + const authRequired = vi.spyOn(client, "authRequired"); + + await expect(invokeSessionRoute(agent, route)).rejects.toThrow( + "systemPrompt must be an object containing an append string", + ); + expect(authRequired).not.toHaveBeenCalled(); + }, + ); +}); + +function invokeSessionRoute(agent: CodexAcpServer, route: "new" | "resume" | "load" | "fork") { + const _meta = {systemPrompt: "override is not supported"}; + switch (route) { + case "new": + return agent.newSession({cwd: "/workspace", mcpServers: [], _meta}); + case "resume": + return agent.resumeSession({sessionId: "resume-thread", cwd: "/workspace", _meta}); + case "load": + return agent.loadSession({sessionId: "load-thread", cwd: "/workspace", mcpServers: [], _meta}); + case "fork": + return agent.forkSession({sessionId: "source-thread", cwd: "/workspace", _meta}); + } +}