From 19d58c1229d9296ffaefd2ccf58c264f5a589b56 Mon Sep 17 00:00:00 2001 From: xinyuan0801 Date: Tue, 24 Mar 2026 21:32:59 +0800 Subject: [PATCH] Add built-in Qoder ACP support --- CHANGELOG.md | 2 + README.md | 1 + agents/Qoder.md | 9 ++ agents/README.md | 2 + skills/acpx/SKILL.md | 7 +- src/agent-registry.ts | 1 + src/client.ts | 143 +++++++++++++++++++++- src/session-conversation-model.ts | 9 ++ src/session-persistence/parse.ts | 25 ++++ src/session-runtime.ts | 59 +++++++++ src/session-runtime/prompt-runner.ts | 32 +++++ src/types.ts | 5 + test/agent-registry.test.ts | 1 + test/client.test.ts | 66 +++++++++- test/integration.test.ts | 155 ++++++++++++++++++++++++ test/session-conversation-model.test.ts | 10 ++ test/session-persistence.test.ts | 33 +++++ 17 files changed, 552 insertions(+), 8 deletions(-) create mode 100644 agents/Qoder.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d98e69..253ba07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ Repo: https://github.com/openclaw/acpx - 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. - Agents/droid: add `factory-droid` and `factorydroid` aliases for the built-in Factory Droid adapter and sync the built-in docs. Thanks @vincentkoc. +- Agents/qoder: add built-in Qoder CLI ACP support via `qoder -> qodercli --acp` and document Qoder-specific auth notes. +- Agents/qoder: forward `--allowed-tools` and `--max-turns` session options into Qoder CLI startup flags, including persisted session reuse, without requiring a raw `--agent` override. ### Breaking diff --git a/README.md b/README.md index 397fc03..7b4bef3 100644 --- a/README.md +++ b/README.md @@ -294,6 +294,7 @@ Built-ins: | `kimi` | native (`kimi acp`) | [Kimi CLI](https://github.com/MoonshotAI/kimi-cli) | | `kiro` | native (`kiro-cli acp`) | [Kiro CLI](https://kiro.dev) | | `opencode` | `npx -y opencode-ai acp` | [OpenCode](https://opencode.ai) | +| `qoder` | native (`qodercli --acp`) | [Qoder CLI](https://docs.qoder.com/cli/acp) | | `qwen` | native (`qwen --acp`) | [Qwen Code](https://github.com/QwenLM/qwen-code) | `factory-droid` and `factorydroid` also resolve to the built-in `droid` adapter. diff --git a/agents/Qoder.md b/agents/Qoder.md new file mode 100644 index 0000000..4d032c3 --- /dev/null +++ b/agents/Qoder.md @@ -0,0 +1,9 @@ +# Qoder + +- Built-in name: `qoder` +- Default command: `qodercli --acp` +- Upstream: https://docs.qoder.com/cli/acp + +`acpx qoder` uses the same login state as Qoder CLI. For non-interactive runs, Qoder documents `QODER_PERSONAL_ACCESS_TOKEN` as the supported environment variable for authentication. + +`acpx qoder` also forwards `--max-turns` and `--allowed-tools` into Qoder CLI startup flags when those session options are set. This makes those Qoder-native startup settings available without using a raw `--agent` override. diff --git a/agents/README.md b/agents/README.md index 2235c2f..3292d76 100644 --- a/agents/README.md +++ b/agents/README.md @@ -15,6 +15,7 @@ Built-in agents: - `kimi -> kimi acp` - `kiro -> kiro-cli acp` - `opencode -> npx -y opencode-ai acp` +- `qoder -> qodercli --acp` - `qwen -> qwen --acp` Harness-specific docs in this directory: @@ -29,4 +30,5 @@ Harness-specific docs in this directory: - [Kimi](Kimi.md): built-in `kimi -> kimi acp` - [Kiro](Kiro.md): built-in `kiro -> kiro-cli acp` - [OpenCode](OpenCode.md): built-in `opencode -> npx -y opencode-ai acp` +- [Qoder](Qoder.md): built-in `qoder -> qodercli --acp` - [Qwen](Qwen.md): built-in `qwen -> qwen --acp` diff --git a/skills/acpx/SKILL.md b/skills/acpx/SKILL.md index 704eff2..c39c150 100644 --- a/skills/acpx/SKILL.md +++ b/skills/acpx/SKILL.md @@ -79,10 +79,13 @@ Friendly agent names resolve to commands: - `cursor` -> `cursor-agent acp` - `copilot` -> `copilot --acp --stdio` - `droid` -> `droid exec --output-format acp` (`factory-droid` and `factorydroid` also resolve to `droid`) +- `iflow` -> `iflow --experimental-acp` +- `kilocode` -> `npx -y @kilocode/cli acp` - `kimi` -> `kimi acp` -- `opencode` -> `npx -y opencode-ai acp` - `kiro` -> `kiro-cli acp` -- `kilocode` -> `npx -y @kilocode/cli acp` +- `opencode` -> `npx -y opencode-ai acp` +- `qoder` -> `qodercli --acp` + Forwards Qoder-native `--allowed-tools` and `--max-turns` startup flags from `acpx` session options. - `qwen` -> `qwen --acp` Rules: diff --git a/src/agent-registry.ts b/src/agent-registry.ts index 0ec23a0..07beb1f 100644 --- a/src/agent-registry.ts +++ b/src/agent-registry.ts @@ -18,6 +18,7 @@ export const AGENT_REGISTRY: Record = { kimi: "kimi acp", kiro: "kiro-cli acp", opencode: "npx -y opencode-ai acp", + qoder: "qodercli --acp", qwen: "qwen --acp", }; diff --git a/src/client.ts b/src/client.ts index 74edcea..3853075 100644 --- a/src/client.ts +++ b/src/client.ts @@ -5,7 +5,6 @@ import { Readable, Writable } from "node:stream"; import { ClientSideConnection, PROTOCOL_VERSION, - ndJsonStream, type AnyMessage, type AuthMethod, type CreateTerminalRequest, @@ -63,7 +62,8 @@ type CommandParts = { const REPLAY_IDLE_MS = 80; const REPLAY_DRAIN_TIMEOUT_MS = 5_000; const DRAIN_POLL_INTERVAL_MS = 20; -const AGENT_CLOSE_AFTER_STDIN_END_MS = 100; +const DEFAULT_AGENT_CLOSE_AFTER_STDIN_END_MS = 100; +const QODER_AGENT_CLOSE_AFTER_STDIN_END_MS = 750; const AGENT_CLOSE_TERM_GRACE_MS = 1_500; const AGENT_CLOSE_KILL_GRACE_MS = 1_000; const GEMINI_ACP_STARTUP_TIMEOUT_MS = 15_000; @@ -117,6 +117,10 @@ export type AgentLifecycleSnapshot = { }; type ConsoleErrorMethod = typeof console.error; +const QODER_BENIGN_STDOUT_LINES = new Set([ + "Received interrupt signal. Cleaning up resources...", + "Cleanup completed. Exiting...", +]); function shouldSuppressSdkConsoleError(args: unknown[]): boolean { if (args.length === 0) { @@ -282,6 +286,83 @@ function basenameToken(value: string): string { .replace(/\.(cmd|exe|bat)$/u, ""); } +export function resolveAgentCloseAfterStdinEndMs(agentCommand: string): number { + const { command } = splitCommandLine(agentCommand); + return basenameToken(command) === "qodercli" + ? QODER_AGENT_CLOSE_AFTER_STDIN_END_MS + : DEFAULT_AGENT_CLOSE_AFTER_STDIN_END_MS; +} + +export function shouldIgnoreNonJsonAgentOutputLine( + agentCommand: string, + trimmedLine: string, +): boolean { + const { command } = splitCommandLine(agentCommand); + return basenameToken(command) === "qodercli" && QODER_BENIGN_STDOUT_LINES.has(trimmedLine); +} + +function createNdJsonMessageStream( + agentCommand: string, + output: WritableStream, + input: ReadableStream, +): { + readable: ReadableStream; + writable: WritableStream; +} { + const textEncoder = new TextEncoder(); + const textDecoder = new TextDecoder(); + + const readable = new ReadableStream({ + async start(controller) { + let content = ""; + const reader = input.getReader(); + try { + while (true) { + const { value, done } = await reader.read(); + if (done) { + break; + } + if (!value) { + continue; + } + content += textDecoder.decode(value, { stream: true }); + const lines = content.split("\n"); + content = lines.pop() || ""; + for (const line of lines) { + const trimmedLine = line.trim(); + if (!trimmedLine || shouldIgnoreNonJsonAgentOutputLine(agentCommand, trimmedLine)) { + continue; + } + try { + const message = JSON.parse(trimmedLine) as AnyMessage; + controller.enqueue(message); + } catch (err) { + console.error("Failed to parse JSON message:", trimmedLine, err); + } + } + } + } finally { + reader.releaseLock(); + controller.close(); + } + }, + }); + + const writable = new WritableStream({ + async write(message) { + const content = JSON.stringify(message) + "\n"; + const writer = output.getWriter(); + try { + await writer.write(textEncoder.encode(content)); + } finally { + writer.releaseLock(); + } + }, + }); + + return { readable, writable }; +} + function isGeminiAcpCommand(command: string, args: readonly string[]): boolean { return ( basenameToken(command) === "gemini" && @@ -301,6 +382,51 @@ function isCopilotAcpCommand(command: string, args: readonly string[]): boolean return basenameToken(command) === "copilot" && args.includes("--acp"); } +function isQoderAcpCommand(command: string, args: readonly string[]): boolean { + return basenameToken(command) === "qodercli" && args.includes("--acp"); +} + +function hasCommandFlag(args: readonly string[], flagName: string): boolean { + return args.some((arg) => arg === flagName || arg.startsWith(`${flagName}=`)); +} + +function normalizeQoderAllowedToolName(tool: string): string { + switch (tool.trim().toLowerCase()) { + case "bash": + case "glob": + case "grep": + case "ls": + case "read": + case "write": + return tool.trim().toUpperCase(); + default: + return tool.trim(); + } +} + +export function buildQoderAcpCommandArgs( + initialArgs: readonly string[], + options: Pick, +): string[] { + const args = [...initialArgs]; + const sessionOptions = options.sessionOptions; + + if (typeof sessionOptions?.maxTurns === "number" && !hasCommandFlag(args, "--max-turns")) { + args.push(`--max-turns=${sessionOptions.maxTurns}`); + } + + if ( + Array.isArray(sessionOptions?.allowedTools) && + !hasCommandFlag(args, "--allowed-tools") && + !hasCommandFlag(args, "--disallowed-tools") + ) { + const encodedTools = sessionOptions.allowedTools.map(normalizeQoderAllowedToolName).join(","); + args.push(`--allowed-tools=${encodedTools}`); + } + + return args; +} + function readWindowsEnvValue(env: NodeJS.ProcessEnv, key: string): string | undefined { const matchedKey = Object.keys(env).find((entry) => entry.toUpperCase() === key); return matchedKey ? env[matchedKey] : undefined; @@ -928,7 +1054,10 @@ export class AcpClient { } const { command, args: initialArgs } = splitCommandLine(this.options.agentCommand); - const args = await resolveGeminiCommandArgs(command, initialArgs); + let args = await resolveGeminiCommandArgs(command, initialArgs); + if (isQoderAcpCommand(command, args)) { + args = buildQoderAcpCommandArgs(args, this.options); + } this.log(`spawning agent: ${command} ${args.join(" ")}`); const geminiAcp = isGeminiAcpCommand(command, args); const copilotAcp = isCopilotAcpCommand(command, args); @@ -967,7 +1096,9 @@ export class AcpClient { const input = Writable.toWeb(child.stdin); const output = Readable.toWeb(child.stdout) as ReadableStream; - const stream = this.createTappedStream(ndJsonStream(input, output)); + const stream = this.createTappedStream( + createNdJsonMessageStream(this.options.agentCommand, input, output), + ); const connection = new ClientSideConnection( () => ({ @@ -1339,6 +1470,8 @@ export class AcpClient { private async terminateAgentProcess( child: ChildProcessByStdio, ): Promise { + const stdinCloseGraceMs = resolveAgentCloseAfterStdinEndMs(this.options.agentCommand); + // Closing stdin is the most graceful shutdown signal for stdio-based ACP agents. if (!child.stdin.destroyed) { try { @@ -1348,7 +1481,7 @@ export class AcpClient { } } - let exited = await waitForChildExit(child, AGENT_CLOSE_AFTER_STDIN_END_MS); + let exited = await waitForChildExit(child, stdinCloseGraceMs); if (!exited && isChildProcessRunning(child)) { try { child.kill("SIGTERM"); diff --git a/src/session-conversation-model.ts b/src/session-conversation-model.ts index d43aaf8..8d46837 100644 --- a/src/session-conversation-model.ts +++ b/src/session-conversation-model.ts @@ -456,6 +456,15 @@ export function cloneSessionAcpxState( desired_mode_id: state.desired_mode_id, available_commands: state.available_commands ? [...state.available_commands] : undefined, config_options: state.config_options ? deepClone(state.config_options) : undefined, + session_options: state.session_options + ? { + model: state.session_options.model, + allowed_tools: state.session_options.allowed_tools + ? [...state.session_options.allowed_tools] + : undefined, + max_turns: state.session_options.max_turns, + } + : undefined, }; } diff --git a/src/session-persistence/parse.ts b/src/session-persistence/parse.ts index b9ada93..96fcdde 100644 --- a/src/session-persistence/parse.ts +++ b/src/session-persistence/parse.ts @@ -291,6 +291,31 @@ function parseAcpxState(raw: unknown): SessionAcpxState | undefined { state.config_options = record.config_options as SessionAcpxState["config_options"]; } + const sessionOptions = asRecord(record.session_options); + if (sessionOptions) { + const parsedSessionOptions: NonNullable = {}; + + if (typeof sessionOptions.model === "string") { + parsedSessionOptions.model = sessionOptions.model; + } + + if (isStringArray(sessionOptions.allowed_tools)) { + parsedSessionOptions.allowed_tools = [...sessionOptions.allowed_tools]; + } + + if ( + typeof sessionOptions.max_turns === "number" && + Number.isInteger(sessionOptions.max_turns) && + sessionOptions.max_turns > 0 + ) { + parsedSessionOptions.max_turns = sessionOptions.max_turns; + } + + if (Object.keys(parsedSessionOptions).length > 0) { + state.session_options = parsedSessionOptions; + } + } + return state; } diff --git a/src/session-runtime.ts b/src/session-runtime.ts index 63cf455..7f4169a 100644 --- a/src/session-runtime.ts +++ b/src/session-runtime.ts @@ -103,6 +103,61 @@ export type SessionAgentOptions = { maxTurns?: number; }; +function sessionOptionsFromRecord(record: SessionRecord): SessionAgentOptions | undefined { + const stored = record.acpx?.session_options; + if (!stored) { + return undefined; + } + + const sessionOptions: SessionAgentOptions = {}; + + if (typeof stored.model === "string" && stored.model.trim().length > 0) { + sessionOptions.model = stored.model; + } + if (Array.isArray(stored.allowed_tools)) { + sessionOptions.allowedTools = [...stored.allowed_tools]; + } + if (typeof stored.max_turns === "number") { + sessionOptions.maxTurns = stored.max_turns; + } + + return Object.keys(sessionOptions).length > 0 ? sessionOptions : undefined; +} + +function persistSessionOptions( + record: SessionRecord, + options: SessionAgentOptions | undefined, +): void { + 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, + } 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"), + ); + + if (hasValues && next) { + record.acpx = { + ...record.acpx, + session_options: next, + }; + return; + } + + if (!record.acpx) { + return; + } + + delete record.acpx.session_options; +} + export type RunOnceOptions = { agentCommand: string; cwd: string; @@ -519,6 +574,7 @@ async function runSessionPrompt(options: RunSessionPromptOptions): Promise 0) { + sessionOptions.model = stored.model; + } + if (Array.isArray(stored.allowed_tools)) { + sessionOptions.allowedTools = [...stored.allowed_tools]; + } + if (typeof stored.max_turns === "number") { + sessionOptions.maxTurns = stored.max_turns; + } + + return Object.keys(sessionOptions).length > 0 ? sessionOptions : undefined; +} + type WithConnectedSessionOptions = { sessionRecordId: string; mcpServers?: McpServer[]; @@ -56,6 +87,7 @@ async function withConnectedSession( authCredentials: options.authCredentials, authPolicy: options.authPolicy, verbose: options.verbose, + sessionOptions: sessionOptionsFromRecord(record), }); let activeSessionIdForControl = record.acpSessionId; let notifiedClientAvailable = false; diff --git a/src/types.ts b/src/types.ts index 415a18d..da83941 100644 --- a/src/types.ts +++ b/src/types.ts @@ -281,6 +281,11 @@ export type SessionAcpxState = { desired_mode_id?: string; available_commands?: string[]; config_options?: SessionConfigOption[]; + session_options?: { + model?: string; + allowed_tools?: string[]; + max_turns?: number; + }; }; export type SessionRecord = { diff --git a/test/agent-registry.test.ts b/test/agent-registry.test.ts index ad021b5..6109f34 100644 --- a/test/agent-registry.test.ts +++ b/test/agent-registry.test.ts @@ -51,6 +51,7 @@ test("listBuiltInAgents preserves the required example prefix and alphabetical t "kimi", "kiro", "opencode", + "qoder", "qwen", ]); }); diff --git a/test/client.test.ts b/test/client.test.ts index e928f90..b8ca8a6 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -2,7 +2,13 @@ import assert from "node:assert/strict"; import { PassThrough } from "node:stream"; import test from "node:test"; import type { RequestPermissionRequest, RequestPermissionResponse } from "@agentclientprotocol/sdk"; -import { AcpClient, buildAgentSpawnOptions } from "../src/client.js"; +import { + AcpClient, + buildAgentSpawnOptions, + buildQoderAcpCommandArgs, + resolveAgentCloseAfterStdinEndMs, + shouldIgnoreNonJsonAgentOutputLine, +} from "../src/client.js"; import { AuthPolicyError, PermissionDeniedError, @@ -133,6 +139,64 @@ test("buildAgentSpawnOptions normalizes auth env keys and preserves existing val ); }); +test("resolveAgentCloseAfterStdinEndMs gives qodercli extra EOF shutdown grace", () => { + assert.equal(resolveAgentCloseAfterStdinEndMs("qodercli --acp"), 750); + assert.equal(resolveAgentCloseAfterStdinEndMs("/Users/me/bin/qodercli --acp"), 750); + assert.equal(resolveAgentCloseAfterStdinEndMs("node ./test/mock-agent.js"), 100); +}); + +test("shouldIgnoreNonJsonAgentOutputLine ignores qoder shutdown chatter only", () => { + assert.equal( + shouldIgnoreNonJsonAgentOutputLine( + "qodercli --acp", + "Received interrupt signal. Cleaning up resources...", + ), + true, + ); + assert.equal( + shouldIgnoreNonJsonAgentOutputLine("qodercli --acp", "Cleanup completed. Exiting..."), + true, + ); + assert.equal( + shouldIgnoreNonJsonAgentOutputLine( + "node ./test/mock-agent.js", + "Cleanup completed. Exiting...", + ), + false, + ); + assert.equal( + shouldIgnoreNonJsonAgentOutputLine("qodercli --acp", "unexpected non-json output"), + false, + ); +}); + +test("buildQoderAcpCommandArgs forwards allowed-tools and max-turns", () => { + assert.deepEqual( + buildQoderAcpCommandArgs(["--acp"], { + sessionOptions: { + allowedTools: ["Read", "Grep", "custom_tool"], + maxTurns: 9, + }, + }), + ["--acp", "--max-turns=9", "--allowed-tools=READ,GREP,custom_tool"], + ); +}); + +test("buildQoderAcpCommandArgs preserves explicit qoder startup flags", () => { + assert.deepEqual( + buildQoderAcpCommandArgs( + ["--acp", "--max-turns=3", "--allowed-tools=READ", "--disallowed-tools=BASH"], + { + sessionOptions: { + allowedTools: ["Write"], + maxTurns: 7, + }, + }, + ), + ["--acp", "--max-turns=3", "--allowed-tools=READ", "--disallowed-tools=BASH"], + ); +}); + test("AcpClient prefers env auth credentials over config credentials", async () => { await withEnv( { diff --git a/test/integration.test.ts b/test/integration.test.ts index 137837c..2e08a2d 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -153,6 +153,111 @@ test("integration: built-in iflow agent resolves to iflow --experimental-acp", a }); }); +test("integration: built-in qoder agent resolves to qodercli --acp", async () => { + await withTempHome(async (homeDir) => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-integration-cwd-")); + const fakeBinDir = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-fake-qoder-")); + + try { + await writeFakeQoderAgent(fakeBinDir); + + const result = await runCli( + ["--approve-all", "--cwd", cwd, "--format", "quiet", "qoder", "exec", "echo hello"], + homeDir, + { + env: { + PATH: `${fakeBinDir}${path.delimiter}${process.env.PATH ?? ""}`, + }, + }, + ); + + assert.equal(result.code, 0, result.stderr); + assert.match(result.stdout, /hello/); + } finally { + await fs.rm(fakeBinDir, { recursive: true, force: true }); + await fs.rm(cwd, { recursive: true, force: true }); + } + }); +}); + +test("integration: qoder session reuse preserves persisted startup flags", async () => { + await withTempHome(async (homeDir) => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-integration-cwd-")); + const fakeBinDir = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-fake-qoder-")); + const argLogPath = path.join(fakeBinDir, "qoder-args.log"); + + try { + await writeFakeQoderAgent(fakeBinDir, argLogPath); + const { createSession } = await import("../src/session.js"); + const { runSessionSetModeDirect } = await import("../src/session-runtime/prompt-runner.js"); + const previousHome = process.env.HOME; + const previousPath = process.env.PATH; + process.env.HOME = homeDir; + process.env.PATH = `${fakeBinDir}${path.delimiter}${process.env.PATH ?? ""}`; + + try { + const record = await createSession({ + agentCommand: "qodercli --acp", + cwd, + permissionMode: "approve-reads", + timeoutMs: 10_000, + sessionOptions: { + allowedTools: ["Read", "Grep"], + maxTurns: 4, + }, + }); + + const result = await runSessionSetModeDirect({ + sessionRecordId: record.acpxRecordId, + modeId: "plan", + timeoutMs: 10_000, + }); + assert.equal(result.record.acpxRecordId, record.acpxRecordId); + } finally { + if (previousHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = previousHome; + } + process.env.PATH = previousPath; + } + + const argLines = (await fs.readFile(argLogPath, "utf8")) + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter((line) => line.length > 0); + assert.equal( + argLines.length >= 2, + true, + `expected at least two qoder invocations:\n${argLines.join("\n")}`, + ); + assert.equal( + argLines.some( + (line) => + line.includes("--acp") && + line.includes("--max-turns=4") && + line.includes("--allowed-tools=READ,GREP"), + ), + true, + `expected persisted qoder flags in logged invocations:\n${argLines.join("\n")}`, + ); + assert.equal( + argLines.slice(-1)[0]?.includes("--allowed-tools=READ,GREP") ?? false, + true, + `expected reused prompt spawn to preserve allowed-tools:\n${argLines.join("\n")}`, + ); + assert.equal( + argLines.slice(-1)[0]?.includes("--max-turns=4") ?? false, + true, + `expected reused prompt spawn to preserve max-turns:\n${argLines.join("\n")}`, + ); + } finally { + await fs.rm(fakeBinDir, { recursive: true, force: true }); + await fs.rm(cwd, { recursive: true, force: true }); + } + }); +}); + test("integration: exec forwards model, allowed-tools, and max-turns in session/new _meta", async () => { await withTempHome(async (homeDir) => { const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-integration-cwd-")); @@ -1754,6 +1859,56 @@ async function writeFakeIflowAgent(binDir: string): Promise { ); } +async function writeFakeQoderAgent(binDir: string, argLogPath?: string): Promise { + if (process.platform === "win32") { + await fs.writeFile( + path.join(binDir, "qodercli.cmd"), + [ + "@echo off", + "setlocal", + ...(argLogPath ? [`echo %*>> "${argLogPath}"`] : []), + ":shift_known", + 'if "%~1"=="--acp" shift & goto shift_known', + 'if /I "%~1"=="--max-turns" shift & shift & goto shift_known', + 'if /I "%~1"=="--allowed-tools" shift & shift & goto shift_known', + 'if /I "%~1"=="--disallowed-tools" shift & shift & goto shift_known', + 'echo %~1 | findstr /B /C:"--max-turns=" >nul && shift & goto shift_known', + 'echo %~1 | findstr /B /C:"--allowed-tools=" >nul && shift & goto shift_known', + 'echo %~1 | findstr /B /C:"--disallowed-tools=" >nul && shift & goto shift_known', + `"${process.execPath}" "${MOCK_AGENT_PATH}" %*`, + "", + ].join("\r\n"), + { encoding: "utf8" }, + ); + return; + } + + await fs.writeFile( + path.join(binDir, "qodercli"), + [ + "#!/bin/sh", + ...(argLogPath ? [`printf '%s\\n' "$*" >> ${JSON.stringify(argLogPath)}`] : []), + 'while [ "$#" -gt 0 ]; do', + ' case "$1" in', + " --acp|--max-turns=*|--allowed-tools=*|--disallowed-tools=*)", + " shift", + " ;;", + " --max-turns|--allowed-tools|--disallowed-tools)", + " shift", + ' [ "$#" -gt 0 ] && shift', + " ;;", + " *)", + " break", + " ;;", + " esac", + "done", + `exec "${process.execPath}" "${MOCK_AGENT_PATH}" "$@"`, + "", + ].join("\n"), + { encoding: "utf8", mode: 0o755 }, + ); +} + async function withTempHome(run: (homeDir: string) => Promise): Promise { const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-integration-home-")); try { diff --git a/test/session-conversation-model.test.ts b/test/session-conversation-model.test.ts index 024b6b7..db1cc75 100644 --- a/test/session-conversation-model.test.ts +++ b/test/session-conversation-model.test.ts @@ -211,9 +211,19 @@ test("cloneSessionAcpxState preserves desired mode id", () => { current_mode_id: "auto", desired_mode_id: "plan", available_commands: ["review"], + session_options: { + model: "sonnet", + allowed_tools: ["Read", "Grep"], + max_turns: 7, + }, }); assert.equal(cloned?.current_mode_id, "auto"); assert.equal(cloned?.desired_mode_id, "plan"); assert.deepEqual(cloned?.available_commands, ["review"]); + assert.deepEqual(cloned?.session_options, { + model: "sonnet", + allowed_tools: ["Read", "Grep"], + max_turns: 7, + }); }); diff --git a/test/session-persistence.test.ts b/test/session-persistence.test.ts index af65874..70b7baf 100644 --- a/test/session-persistence.test.ts +++ b/test/session-persistence.test.ts @@ -49,6 +49,39 @@ test("listSessions preserves acpx desired_mode_id", async () => { }); }); +test("listSessions preserves acpx session_options", async () => { + await withTempHome(async (homeDir) => { + const session = await loadSessionModule(); + const cwd = path.join(homeDir, "workspace"); + + await writeSessionRecord( + homeDir, + makeSessionRecord({ + acpxRecordId: "session-options", + acpSessionId: "session-options", + agentCommand: "agent-a", + cwd, + acpx: { + session_options: { + model: "sonnet", + allowed_tools: ["Read", "Grep"], + max_turns: 7, + }, + }, + }), + ); + + const sessions = await session.listSessions(); + const record = sessions.find((entry) => entry.acpxRecordId === "session-options"); + assert.ok(record); + assert.deepEqual(record.acpx?.session_options, { + model: "sonnet", + allowed_tools: ["Read", "Grep"], + max_turns: 7, + }); + }); +}); + test("listSessions ignores unsupported conversation message shapes", async () => { await withTempHome(async (homeDir) => { const sessionDir = path.join(homeDir, ".acpx", "sessions");