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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Repo: https://github.com/openclaw/acpx

### Changes

- CLI/claude: add `--system-prompt <text>` and `--append-system-prompt <text>` 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.
Expand Down
25 changes: 19 additions & 6 deletions src/acp/agent-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,13 +340,26 @@ export function buildClaudeCodeOptionsMeta(
claudeCodeOptions.maxTurns = options.maxTurns;
}

if (Object.keys(claudeCodeOptions).length === 0) {
const meta: Record<string, unknown> = {};
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;
}
3 changes: 3 additions & 0 deletions src/cli/command-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,7 @@ export async function handleExec(
model: globalFlags.model,
allowedTools: globalFlags.allowedTools,
maxTurns: globalFlags.maxTurns,
systemPrompt: globalFlags.systemPrompt,
},
});

Expand Down Expand Up @@ -628,6 +629,7 @@ export async function handleSessionsNew(
model: globalFlags.model,
allowedTools: globalFlags.allowedTools,
maxTurns: globalFlags.maxTurns,
systemPrompt: globalFlags.systemPrompt,
},
});

Expand Down Expand Up @@ -668,6 +670,7 @@ export async function handleSessionsEnsure(
model: globalFlags.model,
allowedTools: globalFlags.allowedTools,
maxTurns: globalFlags.maxTurns,
systemPrompt: globalFlags.systemPrompt,
},
});

Expand Down
38 changes: 38 additions & 0 deletions src/cli/flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -42,6 +43,7 @@ export type GlobalFlags = PermissionFlags & {
model?: string;
allowedTools?: string[];
maxTurns?: number;
systemPrompt?: SystemPromptOption;
promptRetries?: number;
};

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -218,6 +245,16 @@ export function addGlobalFlags(command: Command): Command {
parseAllowedTools,
)
.option("--max-turns <count>", "Maximum turns for the session", parseMaxTurns)
.option(
"--system-prompt <text>",
"Replace the agent system prompt (claude-agent-acp via ACP _meta.systemPrompt)",
(value: string) => parseNonEmptyValue("System prompt", value),
)
.option(
"--append-system-prompt <text>",
"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 <count>",
"Retry failed prompt turns on transient errors (default: 0)",
Expand Down Expand Up @@ -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,
Expand Down
15 changes: 14 additions & 1 deletion src/cli/session/session-management.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<NonNullable<SessionRecord["acpx"]>["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) {
Expand Down
14 changes: 14 additions & 0 deletions src/runtime/engine/session-options.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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;
}
8 changes: 8 additions & 0 deletions src/session/conversation-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down
3 changes: 2 additions & 1 deletion src/session/mode-preference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
14 changes: 14 additions & 0 deletions src/session/persistence/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
2 changes: 2 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -292,6 +293,7 @@ export type SessionAcpxState = {
model?: string;
allowed_tools?: string[];
max_turns?: number;
system_prompt?: string | { append: string };
};
};

Expand Down
32 changes: 31 additions & 1 deletion test/cli-flags.test.ts
Original file line number Diff line number Diff line change
@@ -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");
Expand All @@ -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/,
);
});
56 changes: 56 additions & 0 deletions test/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | undefined;
asInternals(client).connection = {
newSession: async (params: Record<string, unknown>) => {
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<string, unknown> | undefined;
asInternals(client).connection = {
newSession: async (params: Record<string, unknown>) => {
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",
Expand Down
Loading