From 4510e645d15eadea769b2fcbdcf12137b4bd2646 Mon Sep 17 00:00:00 2001 From: Miguel Salinas Date: Tue, 7 Apr 2026 12:58:57 -0700 Subject: [PATCH] feat(claude): forward --system-prompt / --append-system-prompt via ACP _meta Adds global flags --system-prompt and --append-system-prompt that forward through ACP session/new _meta.systemPrompt, using the shape landed in agentclientprotocol/claude-agent-acp#91: _meta.systemPrompt = "..." # replace _meta.systemPrompt = { append: "..." } # append The value is persisted in session_options.system_prompt so acpx claude sessions ensure / reuse flows keep the override, and rides alongside the existing claudeCode options in _meta. Codex and other agents ignore the field. Mutual exclusion between the two flags is enforced at resolve time. --- CHANGELOG.md | 1 + src/acp/agent-command.ts | 25 +++++++++--- src/cli/command-handlers.ts | 3 ++ src/cli/flags.ts | 38 ++++++++++++++++++ src/cli/session/session-management.ts | 15 ++++++- src/runtime/engine/session-options.ts | 14 +++++++ src/session/conversation-model.ts | 8 ++++ src/session/mode-preference.ts | 3 +- src/session/persistence/parse.ts | 14 +++++++ src/types.ts | 2 + test/cli-flags.test.ts | 32 ++++++++++++++- test/client.test.ts | 56 +++++++++++++++++++++++++++ test/session-persistence.test.ts | 53 +++++++++++++++++++++++++ 13 files changed, 255 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d260a39..316b5a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Repo: https://github.com/openclaw/acpx ### Changes +- CLI/claude: add `--system-prompt ` and `--append-system-prompt ` global flags that forward through ACP `_meta.systemPrompt` on `session/new`, letting callers replace or append to the Claude Code system prompt without dropping out of persistent acpx sessions. The value is persisted in `session_options.system_prompt` so ensure/reuse flows keep the override. Codex and other agents ignore the field. - Conformance/ACP: add a data-driven ACP core v1 conformance suite with CI smoke coverage, nightly coverage, and a hardened runner that reports startup failures cleanly and scopes filesystem checks to the session cwd. (#130) Thanks @lynnzc. - CLI/prompts: add `--prompt-retries` to retry transient prompt failures with exponential backoff while preserving strict JSON behavior and avoiding replay after prompt side effects. (#142) Thanks @lupuletic and @dutifulbob. - Output: add `--suppress-reads` to mask raw file-read bodies in text and JSON output while keeping normal tool activity visible. (#136) Thanks @hayatosc. diff --git a/src/acp/agent-command.ts b/src/acp/agent-command.ts index 29141ab..2305128 100644 --- a/src/acp/agent-command.ts +++ b/src/acp/agent-command.ts @@ -340,13 +340,26 @@ export function buildClaudeCodeOptionsMeta( claudeCodeOptions.maxTurns = options.maxTurns; } - if (Object.keys(claudeCodeOptions).length === 0) { + const meta: Record = {}; + if (Object.keys(claudeCodeOptions).length > 0) { + meta.claudeCode = { options: claudeCodeOptions }; + } + + const systemPrompt = options.systemPrompt; + if (typeof systemPrompt === "string" && systemPrompt.length > 0) { + meta.systemPrompt = systemPrompt; + } else if ( + systemPrompt && + typeof systemPrompt === "object" && + typeof systemPrompt.append === "string" && + systemPrompt.append.length > 0 + ) { + meta.systemPrompt = { append: systemPrompt.append }; + } + + if (Object.keys(meta).length === 0) { return undefined; } - return { - claudeCode: { - options: claudeCodeOptions, - }, - }; + return meta; } diff --git a/src/cli/command-handlers.ts b/src/cli/command-handlers.ts index daf8c4f..8b8f99a 100644 --- a/src/cli/command-handlers.ts +++ b/src/cli/command-handlers.ts @@ -308,6 +308,7 @@ export async function handleExec( model: globalFlags.model, allowedTools: globalFlags.allowedTools, maxTurns: globalFlags.maxTurns, + systemPrompt: globalFlags.systemPrompt, }, }); @@ -628,6 +629,7 @@ export async function handleSessionsNew( model: globalFlags.model, allowedTools: globalFlags.allowedTools, maxTurns: globalFlags.maxTurns, + systemPrompt: globalFlags.systemPrompt, }, }); @@ -668,6 +670,7 @@ export async function handleSessionsEnsure( model: globalFlags.model, allowedTools: globalFlags.allowedTools, maxTurns: globalFlags.maxTurns, + systemPrompt: globalFlags.systemPrompt, }, }); diff --git a/src/cli/flags.ts b/src/cli/flags.ts index af1cc02..ae361a0 100644 --- a/src/cli/flags.ts +++ b/src/cli/flags.ts @@ -5,6 +5,7 @@ import { DEFAULT_AGENT_NAME, resolveAgentCommand as resolveAgentCommandFromRegistry, } from "../agent-registry.js"; +import type { SystemPromptOption } from "../runtime/engine/session-options.js"; import { DEFAULT_QUEUE_OWNER_TTL_MS } from "../session/session.js"; import { AUTH_POLICIES, @@ -42,6 +43,7 @@ export type GlobalFlags = PermissionFlags & { model?: string; allowedTools?: string[]; maxTurns?: number; + systemPrompt?: SystemPromptOption; promptRetries?: number; }; @@ -159,6 +161,31 @@ export function parseMaxTurns(value: string): number { return parsed; } +export function resolveSystemPromptFlag(opts: { + systemPrompt?: unknown; + appendSystemPrompt?: unknown; +}): SystemPromptOption | undefined { + const replace = + typeof opts.systemPrompt === "string" && opts.systemPrompt.length > 0 + ? opts.systemPrompt + : undefined; + const append = + typeof opts.appendSystemPrompt === "string" && opts.appendSystemPrompt.length > 0 + ? opts.appendSystemPrompt + : undefined; + + if (replace !== undefined && append !== undefined) { + throw new InvalidArgumentError("Use only one of --system-prompt or --append-system-prompt"); + } + if (replace !== undefined) { + return replace; + } + if (append !== undefined) { + return { append }; + } + return undefined; +} + export function parsePromptRetries(value: string): number { const parsed = Number(value); if (!Number.isInteger(parsed) || parsed < 0) { @@ -218,6 +245,16 @@ export function addGlobalFlags(command: Command): Command { parseAllowedTools, ) .option("--max-turns ", "Maximum turns for the session", parseMaxTurns) + .option( + "--system-prompt ", + "Replace the agent system prompt (claude-agent-acp via ACP _meta.systemPrompt)", + (value: string) => parseNonEmptyValue("System prompt", value), + ) + .option( + "--append-system-prompt ", + "Append text to the agent system prompt (claude-agent-acp via ACP _meta.systemPrompt.append)", + (value: string) => parseNonEmptyValue("Append system prompt", value), + ) .option( "--prompt-retries ", "Retry failed prompt turns on transient errors (default: 0)", @@ -309,6 +346,7 @@ export function resolveGlobalFlags(command: Command, config: ResolvedAcpxConfig) model: typeof opts.model === "string" ? parseNonEmptyValue("Model", opts.model) : undefined, allowedTools: Array.isArray(opts.allowedTools) ? opts.allowedTools : undefined, maxTurns: typeof opts.maxTurns === "number" ? opts.maxTurns : undefined, + systemPrompt: resolveSystemPromptFlag(opts), promptRetries: typeof opts.promptRetries === "number" ? opts.promptRetries : undefined, approveAll: opts.approveAll ? true : undefined, approveReads: opts.approveReads ? true : undefined, diff --git a/src/cli/session/session-management.ts b/src/cli/session/session-management.ts index df1dbe8..1ec5cbb 100644 --- a/src/cli/session/session-management.ts +++ b/src/cli/session/session-management.ts @@ -27,19 +27,32 @@ function persistSessionOptions( record: SessionRecord, options: SessionAgentOptions | undefined, ): void { + const systemPromptOption = options?.systemPrompt; + const normalizedSystemPrompt = + typeof systemPromptOption === "string" && systemPromptOption.length > 0 + ? systemPromptOption + : systemPromptOption && + typeof systemPromptOption === "object" && + typeof systemPromptOption.append === "string" && + systemPromptOption.append.length > 0 + ? { append: systemPromptOption.append } + : undefined; + const next = options && ({ model: typeof options.model === "string" ? options.model : undefined, allowed_tools: Array.isArray(options.allowedTools) ? [...options.allowedTools] : undefined, max_turns: typeof options.maxTurns === "number" ? options.maxTurns : undefined, + system_prompt: normalizedSystemPrompt, } satisfies NonNullable["session_options"]>); const hasValues = Boolean( next && ((typeof next.model === "string" && next.model.trim().length > 0) || (Array.isArray(next.allowed_tools) && next.allowed_tools.length > 0) || - typeof next.max_turns === "number"), + typeof next.max_turns === "number" || + next.system_prompt !== undefined), ); if (hasValues && next) { diff --git a/src/runtime/engine/session-options.ts b/src/runtime/engine/session-options.ts index 1dd2e4c..63f5e33 100644 --- a/src/runtime/engine/session-options.ts +++ b/src/runtime/engine/session-options.ts @@ -1,9 +1,12 @@ import type { SessionRecord } from "../../types.js"; +export type SystemPromptOption = string | { append: string }; + export type SessionAgentOptions = { model?: string; allowedTools?: string[]; maxTurns?: number; + systemPrompt?: SystemPromptOption; }; export function sessionOptionsFromRecord(record: SessionRecord): SessionAgentOptions | undefined { @@ -23,6 +26,17 @@ export function sessionOptionsFromRecord(record: SessionRecord): SessionAgentOpt if (typeof stored.max_turns === "number") { sessionOptions.maxTurns = stored.max_turns; } + const storedSystemPrompt = stored.system_prompt; + if (typeof storedSystemPrompt === "string" && storedSystemPrompt.length > 0) { + sessionOptions.systemPrompt = storedSystemPrompt; + } else if ( + storedSystemPrompt && + typeof storedSystemPrompt === "object" && + typeof (storedSystemPrompt as { append?: unknown }).append === "string" && + (storedSystemPrompt as { append: string }).append.length > 0 + ) { + sessionOptions.systemPrompt = { append: (storedSystemPrompt as { append: string }).append }; + } return Object.keys(sessionOptions).length > 0 ? sessionOptions : undefined; } diff --git a/src/session/conversation-model.ts b/src/session/conversation-model.ts index cb1a9b0..dea16a8 100644 --- a/src/session/conversation-model.ts +++ b/src/session/conversation-model.ts @@ -465,6 +465,14 @@ export function cloneSessionAcpxState( ? [...state.session_options.allowed_tools] : undefined, max_turns: state.session_options.max_turns, + ...(state.session_options.system_prompt !== undefined + ? { + system_prompt: + typeof state.session_options.system_prompt === "string" + ? state.session_options.system_prompt + : { append: state.session_options.system_prompt.append }, + } + : {}), } : undefined, }; diff --git a/src/session/mode-preference.ts b/src/session/mode-preference.ts index e8f3a48..b21dce8 100644 --- a/src/session/mode-preference.ts +++ b/src/session/mode-preference.ts @@ -56,7 +56,8 @@ export function setDesiredModelId(record: SessionRecord, modelId: string | undef if ( typeof sessionOptions.model === "string" || Array.isArray(sessionOptions.allowed_tools) || - typeof sessionOptions.max_turns === "number" + typeof sessionOptions.max_turns === "number" || + sessionOptions.system_prompt !== undefined ) { acpx.session_options = sessionOptions; } else { diff --git a/src/session/persistence/parse.ts b/src/session/persistence/parse.ts index 2a28488..4f4799d 100644 --- a/src/session/persistence/parse.ts +++ b/src/session/persistence/parse.ts @@ -323,6 +323,20 @@ function parseAcpxState(raw: unknown): SessionAcpxState | undefined { parsedSessionOptions.max_turns = sessionOptions.max_turns; } + const rawSystemPrompt = sessionOptions.system_prompt; + if (typeof rawSystemPrompt === "string" && rawSystemPrompt.length > 0) { + parsedSessionOptions.system_prompt = rawSystemPrompt; + } else { + const appendRecord = asRecord(rawSystemPrompt); + if ( + appendRecord && + typeof appendRecord.append === "string" && + appendRecord.append.length > 0 + ) { + parsedSessionOptions.system_prompt = { append: appendRecord.append }; + } + } + if (Object.keys(parsedSessionOptions).length > 0) { state.session_options = parsedSessionOptions; } diff --git a/src/types.ts b/src/types.ts index 3f913f0..920c920 100644 --- a/src/types.ts +++ b/src/types.ts @@ -173,6 +173,7 @@ export type AcpClientOptions = { model?: string; allowedTools?: string[]; maxTurns?: number; + systemPrompt?: string | { append: string }; }; onAcpMessage?: (direction: AcpMessageDirection, message: AcpJsonRpcMessage) => void; onAcpOutputMessage?: (direction: AcpMessageDirection, message: AcpJsonRpcMessage) => void; @@ -292,6 +293,7 @@ export type SessionAcpxState = { model?: string; allowed_tools?: string[]; max_turns?: number; + system_prompt?: string | { append: string }; }; }; diff --git a/test/cli-flags.test.ts b/test/cli-flags.test.ts index 62c741d..a0a473d 100644 --- a/test/cli-flags.test.ts +++ b/test/cli-flags.test.ts @@ -1,6 +1,10 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { hasExplicitPermissionModeFlag, resolvePermissionMode } from "../src/cli/flags.js"; +import { + hasExplicitPermissionModeFlag, + resolvePermissionMode, + resolveSystemPromptFlag, +} from "../src/cli/flags.js"; test("resolvePermissionMode honors explicit approve-reads overrides", () => { assert.equal(resolvePermissionMode({ approveReads: true }, "approve-all"), "approve-reads"); @@ -14,3 +18,29 @@ test("hasExplicitPermissionModeFlag detects explicit permission grants", () => { assert.equal(hasExplicitPermissionModeFlag({ approveAll: true }), true); assert.equal(hasExplicitPermissionModeFlag({ denyAll: true }), true); }); + +test("resolveSystemPromptFlag returns undefined when neither flag is set", () => { + assert.equal(resolveSystemPromptFlag({}), undefined); + assert.equal(resolveSystemPromptFlag({ systemPrompt: "" }), undefined); + assert.equal(resolveSystemPromptFlag({ appendSystemPrompt: "" }), undefined); +}); + +test("resolveSystemPromptFlag returns string for --system-prompt", () => { + assert.equal( + resolveSystemPromptFlag({ systemPrompt: "you are an obsidian assistant" }), + "you are an obsidian assistant", + ); +}); + +test("resolveSystemPromptFlag returns append object for --append-system-prompt", () => { + assert.deepEqual(resolveSystemPromptFlag({ appendSystemPrompt: "always speak in spanish" }), { + append: "always speak in spanish", + }); +}); + +test("resolveSystemPromptFlag rejects combining --system-prompt and --append-system-prompt", () => { + assert.throws( + () => resolveSystemPromptFlag({ systemPrompt: "a", appendSystemPrompt: "b" }), + /Use only one of --system-prompt or --append-system-prompt/, + ); +}); diff --git a/test/client.test.ts b/test/client.test.ts index 376081a..ac5b578 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -418,6 +418,62 @@ test("AcpClient createSession forwards claudeCode options in _meta", async () => }); }); +test("AcpClient createSession forwards systemPrompt string in _meta", async () => { + const client = makeClient({ + sessionOptions: { + systemPrompt: "you are an obsidian assistant", + }, + }); + + let capturedParams: Record | undefined; + asInternals(client).connection = { + newSession: async (params: Record) => { + capturedParams = params; + return { sessionId: "session-sp-string" }; + }, + }; + + await client.createSession("/tmp/acpx-client-system-prompt"); + assert.deepEqual(capturedParams, { + cwd: "/tmp/acpx-client-system-prompt", + mcpServers: [], + _meta: { + systemPrompt: "you are an obsidian assistant", + }, + }); +}); + +test("AcpClient createSession forwards systemPrompt append in _meta alongside claudeCode options", async () => { + const client = makeClient({ + sessionOptions: { + model: "sonnet", + systemPrompt: { append: "always speak in spanish" }, + }, + }); + + let capturedParams: Record | undefined; + asInternals(client).connection = { + newSession: async (params: Record) => { + capturedParams = params; + return { sessionId: "session-sp-append" }; + }, + }; + + await client.createSession("/tmp/acpx-client-system-prompt-append"); + assert.deepEqual(capturedParams, { + cwd: "/tmp/acpx-client-system-prompt-append", + mcpServers: [], + _meta: { + claudeCode: { + options: { + model: "sonnet", + }, + }, + systemPrompt: { append: "always speak in spanish" }, + }, + }); +}); + test("AcpClient createSession forwards codex model metadata without setting it explicitly", async () => { const client = makeClient({ agentCommand: "npx @zed-industries/codex-acp", diff --git a/test/session-persistence.test.ts b/test/session-persistence.test.ts index 5c08d42..4001bce 100644 --- a/test/session-persistence.test.ts +++ b/test/session-persistence.test.ts @@ -107,6 +107,59 @@ test("listSessions preserves acpx session_options", async () => { }); }); +test("listSessions preserves acpx session_options system_prompt string and append", async () => { + await withTempHome(async (homeDir) => { + const session = await loadSessionModule(); + const cwd = path.join(homeDir, "workspace"); + + await writeSessionRecord( + homeDir, + makeSessionRecord({ + acpxRecordId: "session-system-prompt-string", + acpSessionId: "session-system-prompt-string", + agentCommand: "agent-a", + cwd, + acpx: { + session_options: { + system_prompt: "you are an obsidian assistant", + }, + }, + }), + ); + await writeSessionRecord( + homeDir, + makeSessionRecord({ + acpxRecordId: "session-system-prompt-append", + acpSessionId: "session-system-prompt-append", + agentCommand: "agent-a", + cwd, + acpx: { + session_options: { + system_prompt: { append: "always speak in spanish" }, + }, + }, + }), + ); + + const sessions = await session.listSessions(); + const stringRecord = sessions.find( + (entry) => entry.acpxRecordId === "session-system-prompt-string", + ); + const appendRecord = sessions.find( + (entry) => entry.acpxRecordId === "session-system-prompt-append", + ); + assert.ok(stringRecord); + assert.ok(appendRecord); + assert.equal( + stringRecord.acpx?.session_options?.system_prompt, + "you are an obsidian assistant", + ); + assert.deepEqual(appendRecord.acpx?.session_options?.system_prompt, { + append: "always speak in spanish", + }); + }); +}); + test("listSessions ignores unsupported conversation message shapes", async () => { await withTempHome(async (homeDir) => { const sessionDir = path.join(homeDir, ".acpx", "sessions");