From 7c8e0e445c38d4dceb44c9a3b763bac1e9a47ab9 Mon Sep 17 00:00:00 2001 From: "subrina.tai" Date: Thu, 19 Mar 2026 08:40:33 +0800 Subject: [PATCH 1/7] feat: pass --model through sendSession, queue owner, and loadSession _meta Previously --model was only applied during session/new (createSession). This meant any prompt to a live session silently ignored the model flag. Changes: - SessionSendOptions: add sessionOptions field - QueueOwnerRuntimeOptions + SessionSendLike: add sessionOptions field - queueOwnerRuntimeOptionsFromSend: propagate sessionOptions - runSessionQueueOwner: pass sessionOptions to sharedClient - RunSessionPromptOptions: add sessionOptions field - withConnectedSession (prompt-runner): pass sessionOptions to AcpClient - loadSessionWithOptions (client): include _meta from sessionOptions - CLI prompt command: wire globalFlags.model/allowedTools/maxTurns into sendSession Fixes: model flag silently ignored on live sessions (queue owner running) --- src/cli-core.ts | 5 +++++ src/client.ts | 1 + src/session-runtime.ts | 2 ++ src/session-runtime/prompt-runner.ts | 2 ++ src/session-runtime/queue-owner-process.ts | 4 ++++ 5 files changed, 14 insertions(+) diff --git a/src/cli-core.ts b/src/cli-core.ts index 666fb77f..eb79fdf8 100644 --- a/src/cli-core.ts +++ b/src/cli-core.ts @@ -290,6 +290,11 @@ async function handlePrompt( promptRetries: globalFlags.promptRetries, verbose: globalFlags.verbose, waitForCompletion: flags.wait !== false, + sessionOptions: { + model: globalFlags.model, + allowedTools: globalFlags.allowedTools, + maxTurns: globalFlags.maxTurns, + }, }); if ("queued" in result) { diff --git a/src/client.ts b/src/client.ts index 015fa8a1..fba3a9fd 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1249,6 +1249,7 @@ export class AcpClient { sessionId, cwd: asAbsoluteCwd(cwd), mcpServers: this.options.mcpServers ?? [], + _meta: buildClaudeCodeOptionsMeta(this.options.sessionOptions), }), ); diff --git a/src/session-runtime.ts b/src/session-runtime.ts index ad9d6393..fba775f5 100644 --- a/src/session-runtime.ts +++ b/src/session-runtime.ts @@ -228,6 +228,7 @@ export type SessionSendOptions = { maxQueueDepth?: number; client?: AcpClient; promptRetries?: number; + sessionOptions?: SessionAgentOptions; } & TimedRunOptions; export type SessionEnsureOptions = { @@ -338,6 +339,7 @@ type RunSessionPromptOptions = { suppressSdkConsoleErrors?: boolean; verbose?: boolean; promptRetries?: number; + sessionOptions?: SessionAgentOptions; onClientAvailable?: (controller: ActiveSessionController) => void; onClientClosed?: () => void; onPromptActive?: () => Promise | void; diff --git a/src/session-runtime/prompt-runner.ts b/src/session-runtime/prompt-runner.ts index 9967a87c..4aa2b856 100644 --- a/src/session-runtime/prompt-runner.ts +++ b/src/session-runtime/prompt-runner.ts @@ -12,6 +12,7 @@ import { writeSessionRecord, } from "../session-persistence.js"; import { withInterrupt, withTimeout } from "../session-runtime-helpers.js"; +import type { SessionAgentOptions } from "../session-runtime.js"; import type { AuthPolicy, McpServer, @@ -67,6 +68,7 @@ type WithConnectedSessionOptions = { authPolicy?: AuthPolicy; timeoutMs?: number; verbose?: boolean; + sessionOptions?: SessionAgentOptions; onClientAvailable?: (controller: ActiveSessionController) => void; onClientClosed?: () => void; run: (client: AcpClient, sessionId: string, record: SessionRecord) => Promise; diff --git a/src/session-runtime/queue-owner-process.ts b/src/session-runtime/queue-owner-process.ts index 43e56429..5d222959 100644 --- a/src/session-runtime/queue-owner-process.ts +++ b/src/session-runtime/queue-owner-process.ts @@ -1,5 +1,6 @@ import { spawn } from "node:child_process"; import { realpathSync } from "node:fs"; +import type { SessionAgentOptions } from "../session-runtime.js"; import type { AuthPolicy, McpServer, @@ -19,6 +20,7 @@ export type QueueOwnerRuntimeOptions = { ttlMs?: number; maxQueueDepth?: number; promptRetries?: number; + sessionOptions?: SessionAgentOptions; }; type SessionSendLike = { @@ -33,6 +35,7 @@ type SessionSendLike = { ttlMs?: number; maxQueueDepth?: number; promptRetries?: number; + sessionOptions?: SessionAgentOptions; }; export function sanitizeQueueOwnerExecArgv( @@ -131,6 +134,7 @@ export function queueOwnerRuntimeOptionsFromSend( ttlMs: options.ttlMs, maxQueueDepth: options.maxQueueDepth, promptRetries: options.promptRetries, + sessionOptions: options.sessionOptions, }; } From 2c5d8b0d78573a892db6ca42387efbe088dc09a1 Mon Sep 17 00:00:00 2001 From: "subrina.tai" Date: Thu, 19 Mar 2026 11:23:40 +0800 Subject: [PATCH 2/7] fix: pre-set model on running owner in sendSession before prompt --- src/session-runtime.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/session-runtime.ts b/src/session-runtime.ts index fba775f5..ca0f4b4c 100644 --- a/src/session-runtime.ts +++ b/src/session-runtime.ts @@ -1448,6 +1448,22 @@ export async function runSessionQueueOwner(options: QueueOwnerRuntimeOptions): P export async function sendSession(options: SessionSendOptions): Promise { const waitForCompletion = options.waitForCompletion !== false; + // If a model is requested and an owner is already running, update the model + // BEFORE submitting the prompt (fresh sessions are handled via trySetModel in createSession). + if (options.sessionOptions?.model) { + await trySetConfigOptionOnRunningOwner( + options.sessionId, + "model", + options.sessionOptions.model, + options.timeoutMs, + options.verbose, + ).catch((err: unknown) => { + if (options.verbose) { + process.stderr.write(`[acpx] warning: failed to pre-set model on running owner: ${err}\n`); + } + }); + } + const queuedToOwner = await submitToRunningOwner(options, waitForCompletion); if (queuedToOwner) { return queuedToOwner; From e0b1b1751e107c5823f9bf31106f9adbccb733d1 Mon Sep 17 00:00:00 2001 From: "subrina.tai" Date: Fri, 20 Mar 2026 10:51:47 +0800 Subject: [PATCH 3/7] fix: propagate sessionOptions through queue owner and support mid-session model switch - queue-owner-env: parse sessionOptions from ACPX_QUEUE_OWNER_PAYLOAD - session-runtime: route set_config_option through sharedClient live connection - client: add updateSessionOptions() for runtime model updates Fixes mid-session model switching and first-prompt model selection. --- src/client.ts | 15 +++++++++++++++ src/queue-owner-env.ts | 16 ++++++++++++++++ src/session-runtime.ts | 23 +++++++++++++++++++++++ 3 files changed, 54 insertions(+) diff --git a/src/client.ts b/src/client.ts index fba3a9fd..d387ee21 100644 --- a/src/client.ts +++ b/src/client.ts @@ -946,6 +946,21 @@ export class AcpClient { this.eventHandlers = {}; } + updateSessionOptions(update: Partial>): void { + if (!this.options.sessionOptions) { + this.options.sessionOptions = {}; + } + if (update.model !== undefined) { + this.options.sessionOptions.model = update.model; + } + if (update.allowedTools !== undefined) { + this.options.sessionOptions.allowedTools = update.allowedTools; + } + if (update.maxTurns !== undefined) { + this.options.sessionOptions.maxTurns = update.maxTurns; + } + } + updateRuntimeOptions(options: { permissionMode?: PermissionMode; nonInteractivePermissions?: NonInteractivePermissionPolicy; diff --git a/src/queue-owner-env.ts b/src/queue-owner-env.ts index b013f8d7..da11c11e 100644 --- a/src/queue-owner-env.ts +++ b/src/queue-owner-env.ts @@ -76,6 +76,22 @@ export function parseQueueOwnerPayload(raw: string): QueueOwnerRuntimeOptions { options.promptRetries = Math.max(0, Math.round(record.promptRetries)); } + const sessionOpts = asRecord(record.sessionOptions); + if (sessionOpts) { + options.sessionOptions = {}; + if (typeof sessionOpts.model === "string" && sessionOpts.model.trim().length > 0) { + options.sessionOptions.model = sessionOpts.model; + } + if (Array.isArray(sessionOpts.allowedTools)) { + options.sessionOptions.allowedTools = sessionOpts.allowedTools.filter( + (t): t is string => typeof t === "string", + ); + } + if (typeof sessionOpts.maxTurns === "number" && Number.isFinite(sessionOpts.maxTurns)) { + options.sessionOptions.maxTurns = Math.max(1, Math.round(sessionOpts.maxTurns)); + } + } + return options; } diff --git a/src/session-runtime.ts b/src/session-runtime.ts index ca0f4b4c..5ebac606 100644 --- a/src/session-runtime.ts +++ b/src/session-runtime.ts @@ -1293,6 +1293,25 @@ export async function runSessionQueueOwner(options: QueueOwnerRuntimeOptions): P }); }, setSessionConfigOptionFallback: async (configId: string, value: string, timeoutMs?: number) => { + // If the sharedClient has a reusable session, route through it directly + // instead of creating a temporary connection that doesn't affect the live session. + const currentRecord = await resolveSessionRecord(options.sessionId); + if (sharedClient.hasReusableSession(currentRecord.acpSessionId)) { + const response = await withTimeout( + (async () => + await sharedClient.setSessionConfigOption( + currentRecord.acpSessionId, + configId, + value, + ))(), + timeoutMs, + ); + if (configId === "model") { + sharedClient.updateSessionOptions({ model: value }); + } + return response; + } + const result = await runSessionSetConfigOptionDirect({ sessionRecordId: options.sessionId, configId, @@ -1304,6 +1323,10 @@ export async function runSessionQueueOwner(options: QueueOwnerRuntimeOptions): P timeoutMs, verbose: options.verbose, }); + // Update sharedClient's sessionOptions so future reconnections use the new model. + if (configId === "model") { + sharedClient.updateSessionOptions({ model: value }); + } return result.response; }, }); From 199a1e67b3eb51f783d0f5043873147a541acfb1 Mon Sep 17 00:00:00 2001 From: "subrina.tai" Date: Fri, 20 Mar 2026 10:58:36 +0800 Subject: [PATCH 4/7] fix: use set_config_option for model changes to enable alias resolution client.ts routed model changes through session/set_model (unstable_setSessionModel) which bypassed the Claude adapter's resolveModelPreference fuzzy matching. Model aliases like 'sonnet' or 'haiku' were passed raw to query.setModel() which didn't resolve them. Now tries session/set_config_option first (which has alias resolution on the adapter side), then falls back to session/set_model for adapters like Droid that only support the dedicated method. --- src/client.ts | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/client.ts b/src/client.ts index d387ee21..db389904 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1352,6 +1352,43 @@ export class AcpClient { value: string, ): Promise { const connection = this.getConnection(); + // For model changes: prefer session/set_config_option (supports alias resolution + // via resolveModelPreference on Claude adapter) then fall back to session/set_model + // for adapters like Droid that only support the dedicated method. + if (configId === "model") { + try { + return await this.runConnectionRequest(() => + connection.setSessionConfigOption({ + sessionId, + configId, + value, + }), + ); + } catch (error) { + const acp = extractAcpError(error); + if (acp && isLikelySessionControlUnsupportedError(acp)) { + // Adapter doesn't support set_config_option — fall back to set_model + try { + await this.runConnectionRequest(() => + connection.unstable_setSessionModel({ sessionId, modelId: value }), + ); + return { configOptions: [] }; + } catch (fallbackError) { + throw maybeWrapSessionControlError( + "session/set_model", + fallbackError, + `for model="${value}"`, + ); + } + } + throw maybeWrapSessionControlError( + "session/set_config_option", + error, + `for "model"="${value}"`, + ); + } + } + try { return await this.runConnectionRequest(() => connection.setSessionConfigOption({ From 793f22983f72c48150e6f19878e632e8cfc602dd Mon Sep 17 00:00:00 2001 From: "subrina.tai" Date: Fri, 20 Mar 2026 11:25:14 +0800 Subject: [PATCH 5/7] fix: persist session record after queue owner prompts runQueuedTask never saved the session record to disk after runSessionPrompt, so when loadSession failed and a fresh session was created with a new acpSessionId, subsequent control commands (e.g. model switch) read the stale record from disk and got 'Session not found' from the adapter. Now writes the record after each prompt in the queue owner, ensuring set_config_option fallback sees the correct acpSessionId. --- src/session-runtime.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/session-runtime.ts b/src/session-runtime.ts index 5ebac606..7b2e3c36 100644 --- a/src/session-runtime.ts +++ b/src/session-runtime.ts @@ -574,6 +574,15 @@ async function runQueuedTask( client: options.sharedClient, }); + // Persist the record after each prompt so that control commands + // (e.g. set_config_option for model changes) see the updated acpSessionId + // when the session was recreated due to a loadSession fallback. + if (result.record) { + await writeSessionRecord(result.record).catch(() => { + // best effort — control commands will still work via sharedClient + }); + } + if (task.waitForCompletion) { task.send({ type: "result", From 2714c5837035d1cf50ad3302d2e98dc6d27b8690 Mon Sep 17 00:00:00 2001 From: williamkhoo Date: Thu, 2 Apr 2026 14:41:02 +0800 Subject: [PATCH 6/7] fix: lint error --- src/session-runtime.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/session-runtime.ts b/src/session-runtime.ts index 7b2e3c36..8cb88c57 100644 --- a/src/session-runtime.ts +++ b/src/session-runtime.ts @@ -1491,7 +1491,9 @@ export async function sendSession(options: SessionSendOptions): Promise { if (options.verbose) { - process.stderr.write(`[acpx] warning: failed to pre-set model on running owner: ${err}\n`); + process.stderr.write( + `[acpx] warning: failed to pre-set model on running owner: ${formatErrorMessage(err)}\n`, + ); } }); } From 354599cb0903cc3813913596b926980e0ea6efac Mon Sep 17 00:00:00 2001 From: williamkhoo Date: Thu, 2 Apr 2026 14:59:36 +0800 Subject: [PATCH 7/7] fix: honor --model for reconnect prompts --- src/session-runtime.ts | 37 ++++++++++++++++++++++-- src/session-runtime/prompt-runner.ts | 21 +++++++++++++- test/integration.test.ts | 42 ++++++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 3 deletions(-) diff --git a/src/session-runtime.ts b/src/session-runtime.ts index 8cb88c57..30c4a362 100644 --- a/src/session-runtime.ts +++ b/src/session-runtime.ts @@ -140,6 +140,25 @@ function sessionOptionsFromRecord(record: SessionRecord): SessionAgentOptions | return Object.keys(sessionOptions).length > 0 ? sessionOptions : undefined; } +function mergeSessionOptions( + preferred: SessionAgentOptions | undefined, + fallback: SessionAgentOptions | undefined, +): SessionAgentOptions | undefined { + const merged: SessionAgentOptions = { + ...fallback, + }; + if (preferred?.model !== undefined) { + merged.model = preferred.model; + } + if (preferred?.allowedTools !== undefined) { + merged.allowedTools = preferred.allowedTools; + } + if (preferred?.maxTurns !== undefined) { + merged.maxTurns = preferred.maxTurns; + } + return Object.keys(merged).length > 0 ? merged : undefined; +} + function persistSessionOptions( record: SessionRecord, options: SessionAgentOptions | undefined, @@ -543,6 +562,7 @@ async function runQueuedTask( authPolicy?: AuthPolicy; suppressSdkConsoleErrors?: boolean; promptRetries?: number; + sessionOptions?: SessionAgentOptions; onClientAvailable?: (controller: ActiveSessionController) => void; onClientClosed?: () => void; onPromptActive?: () => Promise | void; @@ -568,6 +588,7 @@ async function runQueuedTask( suppressSdkConsoleErrors: task.suppressSdkConsoleErrors ?? options.suppressSdkConsoleErrors, verbose: options.verbose, promptRetries: options.promptRetries, + sessionOptions: options.sessionOptions, onClientAvailable: options.onClientAvailable, onClientClosed: options.onClientClosed, onPromptActive: options.onPromptActive, @@ -676,7 +697,7 @@ async function runSessionPrompt(options: RunSessionPromptOptions): Promise { diff --git a/src/session-runtime/prompt-runner.ts b/src/session-runtime/prompt-runner.ts index 4aa2b856..042c33bb 100644 --- a/src/session-runtime/prompt-runner.ts +++ b/src/session-runtime/prompt-runner.ts @@ -59,6 +59,25 @@ function sessionOptionsFromRecord(record: SessionRecord): return Object.keys(sessionOptions).length > 0 ? sessionOptions : undefined; } +function mergeSessionOptions( + preferred: SessionAgentOptions | undefined, + fallback: SessionAgentOptions | undefined, +): SessionAgentOptions | undefined { + const merged: SessionAgentOptions = { + ...fallback, + }; + if (preferred?.model !== undefined) { + merged.model = preferred.model; + } + if (preferred?.allowedTools !== undefined) { + merged.allowedTools = preferred.allowedTools; + } + if (preferred?.maxTurns !== undefined) { + merged.maxTurns = preferred.maxTurns; + } + return Object.keys(merged).length > 0 ? merged : undefined; +} + type WithConnectedSessionOptions = { sessionRecordId: string; mcpServers?: McpServer[]; @@ -94,7 +113,7 @@ async function withConnectedSession( authCredentials: options.authCredentials, authPolicy: options.authPolicy, verbose: options.verbose, - sessionOptions: sessionOptionsFromRecord(record), + sessionOptions: mergeSessionOptions(options.sessionOptions, sessionOptionsFromRecord(record)), }); let activeSessionIdForControl = record.acpSessionId; let notifiedClientAvailable = false; diff --git a/test/integration.test.ts b/test/integration.test.ts index 87d1017b..7d63b55a 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -868,6 +868,48 @@ test("integration: exec --model skips session/set_model when agent does not adve }); }); +test("integration: prompt --model updates existing session model before prompt", async () => { + await withTempHome(async (homeDir) => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-integration-cwd-")); + + try { + const ensured = await runCli( + [...baseAgentArgs(cwd), "sessions", "ensure", "--name", "model-prompt-session"], + homeDir, + ); + assert.equal(ensured.code, 0, ensured.stderr); + + const result = await runCli( + [ + ...baseAgentArgs(cwd), + "--format", + "json", + "--model", + "haiku", + "prompt", + "-s", + "model-prompt-session", + "echo hello", + ], + homeDir, + ); + assert.equal(result.code, 0, result.stderr); + + const payloads = parseJsonRpcOutputLines(result.stdout); + const setConfigRequest = payloads.find( + (payload) => + payload.method === "session/set_config_option" && + (payload.params as { configId?: string; value?: string } | undefined)?.configId === + "model" && + (payload.params as { configId?: string; value?: string } | undefined)?.value === "haiku", + ); + assert(setConfigRequest, "expected session/set_config_option for model=haiku"); + } finally { + await fs.rm(cwd, { recursive: true, force: true }); + } + }); +}); + test("integration: exec --model fails when session/set_model fails", async () => { await withTempHome(async (homeDir) => { const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-integration-cwd-"));