diff --git a/docs/CLI.md b/docs/CLI.md index 513dee5b..a7f48cc8 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -102,20 +102,21 @@ or close a PR if you run it against a live repository. All global options: -| Option | Description | Details | -| ---------------------------------------- | ---------------------------------------------- | ------------------------------------------------------------------- | -| `--agent ` | Raw ACP agent command (escape hatch) | Do not combine with positional agent token. | -| `--cwd ` | Working directory | Defaults to current directory. Stored as absolute path for scoping. | -| `--approve-all` | Auto-approve all permissions | Permission mode `approve-all`. | -| `--approve-reads` | Auto-approve reads/searches, prompt for others | Default permission mode. | -| `--deny-all` | Deny all permissions | Permission mode `deny-all`. | -| `--format ` | Output format | `text` (default), `json`, `quiet`. | -| `--suppress-reads` | Suppress read file contents | Replaces raw read payloads with `[read output suppressed]`. | -| `--json-strict` | Strict JSON mode | Requires `--format json`; suppresses non-JSON stderr output. | -| `--non-interactive-permissions ` | Non-TTY prompt policy | `deny` (default) or `fail` when approval prompt cannot be shown. | -| `--timeout ` | Max wait time for agent response | Must be positive. Decimal seconds allowed. | -| `--ttl ` | Queue owner idle TTL before shutdown | Default `300`. `0` disables TTL. | -| `--verbose` | Enable verbose logs | Prints ACP/debug details to stderr. | +| Option | Description | Details | +| ---------------------------------------- | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--agent ` | Raw ACP agent command (escape hatch) | Do not combine with positional agent token. | +| `--cwd ` | Working directory | Defaults to current directory. Stored as absolute path for scoping. | +| `--approve-all` | Auto-approve all permissions | Permission mode `approve-all`. | +| `--approve-reads` | Auto-approve reads/searches, prompt for others | Default permission mode. | +| `--deny-all` | Deny all permissions | Permission mode `deny-all`. | +| `--format ` | Output format | `text` (default), `json`, `quiet`. | +| `--suppress-reads` | Suppress read file contents | Replaces raw read payloads with `[read output suppressed]`. | +| `--json-strict` | Strict JSON mode | Requires `--format json`; suppresses non-JSON stderr output. | +| `--non-interactive-permissions ` | Non-TTY prompt policy | `deny` (default) or `fail` when approval prompt cannot be shown. | +| `--timeout ` | Max wait time for agent response | Must be positive. Decimal seconds allowed. | +| `--ttl ` | Queue owner idle TTL before shutdown | Default `300`. `0` disables TTL. | +| `--model ` | Set agent model | Passed through to agent-specific session creation metadata when applicable; if the agent advertises models, `acpx` also applies it via ACP `session/set_model`. | +| `--verbose` | Enable verbose logs | Prints ACP/debug details to stderr. | Permission flags are mutually exclusive. Using more than one of `--approve-all`, `--approve-reads`, `--deny-all` is a usage error. @@ -287,6 +288,7 @@ Behavior: - Calls ACP `session/set_config_option`. - Routes through queue-owner IPC when an owner is active. - Falls back to a direct client reconnect when no owner is running. +- **`set model `**: Intercepted to call `session/set_model` instead. Some agents support `session/set_model` but not `session/set_config_option` for model changes; routing through the dedicated method ensures broad compatibility. ## `sessions` subcommand diff --git a/skills/acpx/SKILL.md b/skills/acpx/SKILL.md index 08af1ebf..c264ec5e 100644 --- a/skills/acpx/SKILL.md +++ b/skills/acpx/SKILL.md @@ -139,12 +139,13 @@ Behavior: - Runs a single prompt in a temporary ACP session - Does not reuse or save persistent session state -### Cancel / Mode / Config +### Cancel / Mode / Config / Model ```bash acpx codex cancel acpx codex set-mode auto acpx codex set thought_level high +acpx codex set model gpt-5.4 ``` Behavior: @@ -153,8 +154,9 @@ Behavior: - `set-mode`: calls ACP `session/set_mode`. - `set-mode` mode ids are adapter-defined; unsupported values are rejected by the adapter (often `Invalid params`). - `set`: calls ACP `session/set_config_option`. -- For codex, `--model ` is applied after session creation via `session/set_config_option`. - For codex, `thought_level` is accepted as a compatibility alias for codex-acp `reasoning_effort`. +- `--model `: passed through to agent-specific session creation metadata when applicable; if the agent advertises models, `acpx` also applies it via `session/set_model`. +- `set model `: calls `session/set_model`. This is the generic ACP method for mid-session model switching. - `set-mode`/`set` route through queue-owner IPC when active, otherwise reconnect directly. ### Sessions @@ -200,6 +202,7 @@ Behavior: - `--suppress-reads`: suppress raw read-file contents while preserving the selected format - `--timeout `: max wait time (positive number) - `--ttl `: queue owner idle TTL before shutdown (default `300`, `0` disables TTL) +- `--model `: request an agent model during session creation; when the agent advertises models, `acpx` also applies it via `session/set_model` - `--verbose`: verbose ACP/debug logs to stderr Permission flags are mutually exclusive. diff --git a/src/cli-core.ts b/src/cli-core.ts index b3e68477..666fb77f 100644 --- a/src/cli-core.ts +++ b/src/cli-core.ts @@ -421,6 +421,32 @@ function printSetModeResultByFormat( process.stdout.write(`mode set: ${modeId}\n`); } +function printSetModelResultByFormat( + modelId: string, + result: { record: SessionRecord; resumed: boolean }, + format: OutputFormat, +): void { + if ( + emitJsonResult(format, { + action: "model_set", + modelId, + resumed: result.resumed, + acpxRecordId: result.record.acpxRecordId, + acpxSessionId: result.record.acpSessionId, + agentSessionId: result.record.agentSessionId, + }) + ) { + return; + } + + if (format === "quiet") { + process.stdout.write(`${modelId}\n`); + return; + } + + process.stdout.write(`model set: ${modelId}\n`); +} + function printSetConfigOptionResultByFormat( configId: string, value: string, @@ -526,6 +552,40 @@ async function handleSetMode( printSetModeResultByFormat(modeId, result, globalFlags.format); } +async function handleSetModel( + explicitAgentName: string | undefined, + modelId: string, + flags: StatusFlags, + command: Command, + config: ResolvedAcpxConfig, +): Promise { + const globalFlags = resolveGlobalFlags(command, config); + const agent = resolveAgentInvocation(explicitAgentName, globalFlags, config); + const { setSessionModel } = await loadSessionModule(); + const record = await findRoutedSessionOrThrow( + agent.agentCommand, + agent.agentName, + agent.cwd, + resolveSessionNameFromFlags(flags, command), + ); + const result = await setSessionModel({ + sessionId: record.acpxRecordId, + modelId, + mcpServers: config.mcpServers, + nonInteractivePermissions: globalFlags.nonInteractivePermissions, + authCredentials: config.auth, + authPolicy: globalFlags.authPolicy, + timeoutMs: globalFlags.timeout, + verbose: globalFlags.verbose, + }); + + if (globalFlags.verbose && result.loadError) { + process.stderr.write(`[acpx] loadSession failed, started fresh session: ${result.loadError}\n`); + } + + printSetModelResultByFormat(modelId, result, globalFlags.format); +} + async function handleSetConfigOption( explicitAgentName: string | undefined, configId: string, @@ -536,6 +596,10 @@ async function handleSetConfigOption( ): Promise { const globalFlags = resolveGlobalFlags(command, config); const agent = resolveAgentInvocation(explicitAgentName, globalFlags, config); + if (configId === "model") { + await handleSetModel(explicitAgentName, value, flags, command, config); + return; + } const resolvedConfigId = resolveCompatibleConfigId(agent, configId); const { setSessionConfigOption } = await loadSessionModule(); const record = await findRoutedSessionOrThrow( diff --git a/src/cli/status-command.ts b/src/cli/status-command.ts index f5d2df3d..b746e391 100644 --- a/src/cli/status-command.ts +++ b/src/cli/status-command.ts @@ -66,6 +66,8 @@ export async function handleStatus( process.stdout.write(`agent: ${agent.agentCommand}\n`); process.stdout.write("pid: -\n"); process.stdout.write("status: no-session\n"); + process.stdout.write("model: -\n"); + process.stdout.write("mode: -\n"); process.stdout.write("uptime: -\n"); process.stdout.write("lastPromptTime: -\n"); return; @@ -78,6 +80,9 @@ export async function handleStatus( agentCommand: record.agentCommand, pid: health.pid ?? record.pid ?? null, status: running ? "running" : "dead", + model: record.acpx?.current_model_id ?? null, + mode: record.acpx?.current_mode_id ?? null, + availableModels: record.acpx?.available_models ?? null, uptime: running ? (formatUptime(record.agentStartedAt) ?? null) : null, lastPromptTime: record.lastPromptAt ?? null, exitCode: running ? null : (record.lastAgentExitCode ?? null), @@ -91,6 +96,9 @@ export async function handleStatus( status: running ? "alive" : "dead", pid: payload.pid ?? undefined, summary: running ? "queue owner healthy" : "queue owner unavailable", + model: payload.model ?? undefined, + mode: payload.mode ?? undefined, + availableModels: payload.availableModels ?? undefined, uptime: payload.uptime ?? undefined, lastPromptTime: payload.lastPromptTime ?? undefined, exitCode: payload.exitCode ?? undefined, @@ -115,6 +123,8 @@ export async function handleStatus( process.stdout.write(`agent: ${payload.agentCommand}\n`); process.stdout.write(`pid: ${payload.pid ?? "-"}\n`); process.stdout.write(`status: ${payload.status}\n`); + process.stdout.write(`model: ${payload.model ?? "-"}\n`); + process.stdout.write(`mode: ${payload.mode ?? "-"}\n`); process.stdout.write(`uptime: ${payload.uptime ?? "-"}\n`); process.stdout.write(`lastPromptTime: ${payload.lastPromptTime ?? "-"}\n`); if (payload.status === "dead") { diff --git a/src/client.ts b/src/client.ts index 8d893c14..015fa8a1 100644 --- a/src/client.ts +++ b/src/client.ts @@ -27,10 +27,10 @@ import { type WaitForTerminalExitResponse, type WriteTextFileRequest, type WriteTextFileResponse, + type SessionModelState, } from "@agentclientprotocol/sdk"; import { extractAcpError } from "./acp-error-shapes.js"; import { isSessionUpdateNotification } from "./acp-jsonrpc.js"; -import { isCodexAcpCommand } from "./codex-compat.js"; import { AgentDisconnectedError, AgentSpawnError, @@ -86,10 +86,12 @@ type LoadSessionOptions = { export type SessionCreateResult = { sessionId: string; agentSessionId?: string; + models?: SessionModelState; }; export type SessionLoadResult = { agentSessionId?: string; + models?: SessionModelState; }; type AgentDisconnectReason = "process_exit" | "process_close" | "pipe_close" | "connection_close"; @@ -762,7 +764,7 @@ function formatSessionControlAcpSummary(acp: { } function maybeWrapSessionControlError( - method: "session/set_mode" | "session/set_config_option", + method: "session/set_mode" | "session/set_config_option" | "session/set_model", error: unknown, context?: string, ): unknown { @@ -1190,7 +1192,6 @@ export class AcpClient { const connection = this.getConnection(); const { command, args } = splitCommandLine(this.options.agentCommand); const claudeAcp = isClaudeAcpCommand(command, args); - const codexAcp = isCodexAcpCommand(command, args); let result: Awaited>; try { @@ -1215,21 +1216,11 @@ export class AcpClient { } this.loadedSessionId = result.sessionId; - if ( - codexAcp && - typeof this.options.sessionOptions?.model === "string" && - this.options.sessionOptions.model.trim().length > 0 - ) { - await this.setSessionConfigOption( - result.sessionId, - "model", - this.options.sessionOptions.model, - ); - } return { sessionId: result.sessionId, agentSessionId: extractRuntimeSessionId(result._meta), + models: result.models ?? undefined, }; } @@ -1271,8 +1262,10 @@ export class AcpClient { } this.loadedSessionId = sessionId; + return { agentSessionId: extractRuntimeSessionId(response?._meta), + models: response?.models ?? undefined, }; } @@ -1360,6 +1353,41 @@ export class AcpClient { } } + async setSessionModel(sessionId: string, modelId: string): Promise { + const connection = this.getConnection(); + try { + await this.runConnectionRequest(() => + connection.unstable_setSessionModel({ + sessionId, + modelId, + }), + ); + } catch (error) { + const wrapped = maybeWrapSessionControlError( + "session/set_model", + error, + `for model "${modelId}"`, + ); + if (wrapped !== error) { + throw wrapped; + } + const acp = extractAcpError(error); + const summary = acp + ? formatSessionControlAcpSummary(acp) + : error instanceof Error + ? error.message + : String(error); + if (error instanceof Error) { + throw new Error(`Failed session/set_model for model "${modelId}": ${summary}`, { + cause: error, + }); + } + throw new Error(`Failed session/set_model for model "${modelId}": ${summary}`, { + cause: error, + }); + } + } + async cancel(sessionId: string): Promise { const connection = this.getConnection(); this.cancellingSessionIds.add(sessionId); diff --git a/src/errors.ts b/src/errors.ts index b0c63525..51b7dcb2 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -111,6 +111,17 @@ export class SessionModeReplayError extends AcpxOperationalError { } } +export class SessionModelReplayError extends AcpxOperationalError { + constructor(message: string, options?: AcpxErrorOptions) { + super(message, { + outputCode: "RUNTIME", + detailCode: "SESSION_MODEL_REPLAY_FAILED", + origin: "acp", + ...options, + }); + } +} + export class ClaudeAcpSessionCreateTimeoutError extends AcpxOperationalError { constructor(message: string, options?: AcpxErrorOptions) { super(message, { diff --git a/src/queue-ipc-server.ts b/src/queue-ipc-server.ts index 634f0c31..4ee8a8d7 100644 --- a/src/queue-ipc-server.ts +++ b/src/queue-ipc-server.ts @@ -92,6 +92,7 @@ export type QueueTask = { export type QueueOwnerControlHandlers = { cancelPrompt: () => Promise; setSessionMode: (modeId: string, timeoutMs?: number) => Promise; + setSessionModel: (modelId: string, timeoutMs?: number) => Promise; setSessionConfigOption: ( configId: string, value: string, @@ -408,6 +409,22 @@ export class SessionQueueOwner { return; } + if (request.type === "set_model") { + this.handleControlRequest({ + socket, + requestId: request.requestId, + run: async () => { + await this.controlHandlers.setSessionModel(request.modelId, request.timeoutMs); + return { + type: "set_model_result", + requestId: request.requestId, + modelId: request.modelId, + }; + }, + }); + return; + } + if (request.type === "set_config_option") { this.handleControlRequest({ socket, diff --git a/src/queue-ipc.ts b/src/queue-ipc.ts index f645a580..ddf20462 100644 --- a/src/queue-ipc.ts +++ b/src/queue-ipc.ts @@ -15,9 +15,11 @@ import { type QueueOwnerCancelResultMessage, type QueueOwnerMessage, type QueueOwnerSetConfigOptionResultMessage, + type QueueOwnerSetModelResultMessage, type QueueOwnerSetModeResultMessage, type QueueRequest, type QueueSetConfigOptionRequest, + type QueueSetModelRequest, type QueueSetModeRequest, type QueueSubmitRequest, } from "./queue-messages.js"; @@ -528,6 +530,36 @@ async function submitSetModeToQueueOwner( return true; } +async function submitSetModelToQueueOwner( + owner: QueueOwnerRecord, + modelId: string, + timeoutMs?: number, +): Promise { + const request: QueueSetModelRequest = { + type: "set_model", + requestId: randomUUID(), + ownerGeneration: owner.ownerGeneration, + modelId, + timeoutMs, + }; + const response = await submitControlToQueueOwner( + owner, + request, + (message): message is QueueOwnerSetModelResultMessage => message.type === "set_model_result", + ); + if (!response) { + return undefined; + } + if (response.requestId !== request.requestId) { + throw new QueueProtocolError("Queue owner returned mismatched set_model response", { + detailCode: "QUEUE_PROTOCOL_MALFORMED_MESSAGE", + origin: "queue", + retryable: true, + }); + } + return true; +} + async function submitSetConfigOptionToQueueOwner( owner: QueueOwnerRecord, configId: string, @@ -678,6 +710,42 @@ export async function trySetModeOnRunningOwner( ); } +export async function trySetModelOnRunningOwner( + sessionId: string, + modelId: string, + timeoutMs: number | undefined, + verbose: boolean | undefined, +): Promise { + const owner = await readQueueOwnerRecord(sessionId); + if (!owner) { + return undefined; + } + + const submitted = await submitSetModelToQueueOwner(owner, modelId, timeoutMs); + if (submitted) { + if (verbose) { + process.stderr.write( + `[acpx] requested session/set_model on owner pid ${owner.pid} for session ${sessionId}\n`, + ); + } + return true; + } + + const health = await probeQueueOwnerHealth(sessionId); + if (!health.hasLease) { + return undefined; + } + + throw new QueueConnectionError( + "Session queue owner is running but not accepting set_model requests", + { + detailCode: "QUEUE_NOT_ACCEPTING_REQUESTS", + origin: "queue", + retryable: true, + }, + ); +} + export async function trySetConfigOptionOnRunningOwner( sessionId: string, configId: string, diff --git a/src/queue-messages.ts b/src/queue-messages.ts index 6da07d09..ae6f885d 100644 --- a/src/queue-messages.ts +++ b/src/queue-messages.ts @@ -45,6 +45,14 @@ export type QueueSetModeRequest = { timeoutMs?: number; }; +export type QueueSetModelRequest = { + type: "set_model"; + requestId: string; + ownerGeneration?: number; + modelId: string; + timeoutMs?: number; +}; + export type QueueSetConfigOptionRequest = { type: "set_config_option"; requestId: string; @@ -58,6 +66,7 @@ export type QueueRequest = | QueueSubmitRequest | QueueCancelRequest | QueueSetModeRequest + | QueueSetModelRequest | QueueSetConfigOptionRequest; export type QueueOwnerAcceptedMessage = { @@ -94,6 +103,13 @@ export type QueueOwnerSetModeResultMessage = { modeId: string; }; +export type QueueOwnerSetModelResultMessage = { + type: "set_model_result"; + requestId: string; + ownerGeneration?: number; + modelId: string; +}; + export type QueueOwnerSetConfigOptionResultMessage = { type: "set_config_option_result"; requestId: string; @@ -120,6 +136,7 @@ export type QueueOwnerMessage = | QueueOwnerResultMessage | QueueOwnerCancelResultMessage | QueueOwnerSetModeResultMessage + | QueueOwnerSetModelResultMessage | QueueOwnerSetConfigOptionResultMessage | QueueOwnerErrorMessage; @@ -269,6 +286,19 @@ export function parseQueueRequest(raw: unknown): QueueRequest | null { }; } + if (request.type === "set_model") { + if (typeof request.modelId !== "string" || request.modelId.trim().length === 0) { + return null; + } + return { + type: "set_model", + requestId: request.requestId, + ownerGeneration, + modelId: request.modelId, + timeoutMs, + }; + } + if (request.type === "set_config_option") { if ( typeof request.configId !== "string" || @@ -412,6 +442,18 @@ export function parseQueueOwnerMessage(raw: unknown): QueueOwnerMessage | null { }; } + if (message.type === "set_model_result") { + if (typeof message.modelId !== "string") { + return null; + } + return { + type: "set_model_result", + requestId: message.requestId, + ownerGeneration, + modelId: message.modelId, + }; + } + if (message.type === "set_config_option_result") { const response = asRecord(message.response); if (!response || !Array.isArray(response.configOptions)) { diff --git a/src/queue-owner-turn-controller.ts b/src/queue-owner-turn-controller.ts index e99c49bd..36678fc9 100644 --- a/src/queue-owner-turn-controller.ts +++ b/src/queue-owner-turn-controller.ts @@ -7,6 +7,7 @@ export type QueueOwnerActiveSessionController = { hasActivePrompt: () => boolean; requestCancelActivePrompt: () => Promise; setSessionMode: (modeId: string) => Promise; + setSessionModel: (modelId: string) => Promise; setSessionConfigOption: ( configId: string, value: string, @@ -16,6 +17,7 @@ export type QueueOwnerActiveSessionController = { type QueueOwnerTurnControllerOptions = { withTimeout: (run: () => Promise, timeoutMs?: number) => Promise; setSessionModeFallback: (modeId: string, timeoutMs?: number) => Promise; + setSessionModelFallback: (modelId: string, timeoutMs?: number) => Promise; setSessionConfigOptionFallback: ( configId: string, value: string, @@ -126,6 +128,20 @@ export class QueueOwnerTurnController { await this.options.setSessionModeFallback(modeId, timeoutMs); } + async setSessionModel(modelId: string, timeoutMs?: number): Promise { + this.assertCanHandleControlRequest(); + const activeController = this.activeController; + if (activeController) { + await this.options.withTimeout( + async () => await activeController.setSessionModel(modelId), + timeoutMs, + ); + return; + } + + await this.options.setSessionModelFallback(modelId, timeoutMs); + } + async setSessionConfigOption( configId: string, value: string, diff --git a/src/session-conversation-model.ts b/src/session-conversation-model.ts index 8d46837c..23a3583f 100644 --- a/src/session-conversation-model.ts +++ b/src/session-conversation-model.ts @@ -454,6 +454,8 @@ export function cloneSessionAcpxState( return { current_mode_id: state.current_mode_id, desired_mode_id: state.desired_mode_id, + current_model_id: state.current_model_id, + available_models: state.available_models ? [...state.available_models] : undefined, available_commands: state.available_commands ? [...state.available_commands] : undefined, config_options: state.config_options ? deepClone(state.config_options) : undefined, session_options: state.session_options diff --git a/src/session-mode-preference.ts b/src/session-mode-preference.ts index 3397f1e3..35465fa3 100644 --- a/src/session-mode-preference.ts +++ b/src/session-mode-preference.ts @@ -1,3 +1,4 @@ +import type { SessionModelState } from "@agentclientprotocol/sdk"; import type { SessionAcpxState, SessionRecord } from "./types.js"; function ensureAcpxState(state: SessionAcpxState | undefined): SessionAcpxState { @@ -12,6 +13,14 @@ export function normalizeModeId(modeId: string | undefined): string | undefined return trimmed.length > 0 ? trimmed : undefined; } +function normalizeModelId(modelId: string | undefined): string | undefined { + if (typeof modelId !== "string") { + return undefined; + } + const trimmed = modelId.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + export function getDesiredModeId(state: SessionAcpxState | undefined): string | undefined { return normalizeModeId(state?.desired_mode_id); } @@ -28,3 +37,58 @@ export function setDesiredModeId(record: SessionRecord, modeId: string | undefin record.acpx = acpx; } + +export function getDesiredModelId(state: SessionAcpxState | undefined): string | undefined { + return normalizeModelId(state?.session_options?.model); +} + +export function setDesiredModelId(record: SessionRecord, modelId: string | undefined): void { + const acpx = ensureAcpxState(record.acpx); + const normalized = normalizeModelId(modelId); + const sessionOptions = { ...acpx.session_options }; + + if (normalized) { + sessionOptions.model = normalized; + } else { + delete sessionOptions.model; + } + + if ( + typeof sessionOptions.model === "string" || + Array.isArray(sessionOptions.allowed_tools) || + typeof sessionOptions.max_turns === "number" + ) { + acpx.session_options = sessionOptions; + } else { + delete acpx.session_options; + } + + record.acpx = acpx; +} + +export function setCurrentModelId(record: SessionRecord, modelId: string | undefined): void { + const acpx = ensureAcpxState(record.acpx); + const normalized = normalizeModelId(modelId); + + if (normalized) { + acpx.current_model_id = normalized; + } else { + delete acpx.current_model_id; + } + + record.acpx = acpx; +} + +export function syncAdvertisedModelState( + record: SessionRecord, + models: SessionModelState | undefined, +): void { + if (!models) { + return; + } + + const acpx = ensureAcpxState(record.acpx); + acpx.current_model_id = models.currentModelId; + acpx.available_models = models.availableModels.map((model) => model.modelId); + record.acpx = acpx; +} diff --git a/src/session-persistence/parse.ts b/src/session-persistence/parse.ts index 96fcdde9..9d09ecef 100644 --- a/src/session-persistence/parse.ts +++ b/src/session-persistence/parse.ts @@ -283,6 +283,14 @@ function parseAcpxState(raw: unknown): SessionAcpxState | undefined { state.desired_mode_id = record.desired_mode_id; } + if (typeof record.current_model_id === "string") { + state.current_model_id = record.current_model_id; + } + + if (isStringArray(record.available_models)) { + state.available_models = [...record.available_models]; + } + if (isStringArray(record.available_commands)) { state.available_commands = [...record.available_commands]; } diff --git a/src/session-runtime.ts b/src/session-runtime.ts index baed3d5a..ad9d6393 100644 --- a/src/session-runtime.ts +++ b/src/session-runtime.ts @@ -34,6 +34,7 @@ import { tryAcquireQueueOwnerLease, tryCancelOnRunningOwner, trySetConfigOptionOnRunningOwner, + trySetModelOnRunningOwner, trySetModeOnRunningOwner, trySubmitToRunningOwner, waitMs, @@ -43,11 +44,17 @@ import { type QueueOwnerActiveSessionController, } from "./queue-owner-turn-controller.js"; import { normalizeRuntimeSessionId } from "./runtime-session-id.js"; -import { setDesiredModeId } from "./session-mode-preference.js"; +import { + setCurrentModelId, + setDesiredModeId, + setDesiredModelId, + syncAdvertisedModelState, +} from "./session-mode-preference.js"; import { connectAndLoadSession } from "./session-runtime/connect-load.js"; import { applyConversation, applyLifecycleSnapshotToRecord } from "./session-runtime/lifecycle.js"; import { runSessionSetConfigOptionDirect, + runSessionSetModelDirect, runSessionSetModeDirect, } from "./session-runtime/prompt-runner.js"; import { @@ -90,6 +97,7 @@ import { type SessionEnsureResult, type SessionRecord, type SessionSetConfigOptionResult, + type SessionSetModelResult, type SessionSetModeResult, type SessionSendOutcome, type SessionSendResult, @@ -257,6 +265,16 @@ export type SessionSetModeOptions = { verbose?: boolean; } & TimedRunOptions; +export type SessionSetModelOptions = { + sessionId: string; + modelId: string; + mcpServers?: McpServer[]; + nonInteractivePermissions?: NonInteractivePermissionPolicy; + authCredentials?: Record; + authPolicy?: AuthPolicy; + verbose?: boolean; +} & TimedRunOptions; + export type SessionSetConfigOptionOptions = { sessionId: string; configId: string; @@ -280,6 +298,29 @@ function toPromptResult( }; } +async function applyRequestedModelIfAdvertised(params: { + client: AcpClient; + sessionId: string; + requestedModel: string | undefined; + models: import("./client.js").SessionCreateResult["models"]; + timeoutMs?: number; +}): Promise { + const requestedModel = + typeof params.requestedModel === "string" ? params.requestedModel.trim() : ""; + if (!requestedModel || !params.models) { + return false; + } + if (params.models.currentModelId === requestedModel) { + return true; + } + + await withTimeout( + params.client.setSessionModel(params.sessionId, requestedModel), + params.timeoutMs, + ); + return true; +} + type RunSessionPromptOptions = { sessionRecordId: string; prompt: PromptInput; @@ -670,6 +711,9 @@ async function runSessionPrompt(options: RunSessionPromptOptions): Promise { await client.setSessionMode(activeSessionIdForControl, modeId); }, + setSessionModel: async (modelId: string) => { + await client.setSessionModel(activeSessionIdForControl, modelId); + }, setSessionConfigOption: async (configId: string, value: string) => { return await client.setSessionConfigOption(activeSessionIdForControl, configId, value); }, @@ -927,6 +971,13 @@ export async function runOnce(options: RunOnceOptions): Promise ); }); const sessionId = createdSession.sessionId; + await applyRequestedModelIfAdvertised({ + client, + sessionId, + requestedModel: options.sessionOptions?.model, + models: createdSession.models, + timeoutMs: options.timeoutMs, + }); output.setContext({ sessionId, @@ -988,6 +1039,8 @@ async function createSessionRecordWithClient( }); let sessionId: string; let agentSessionId: string | undefined; + let sessionModels: import("./client.js").SessionCreateResult["models"]; + let requestedModelApplied = false; if (options.resumeSessionId) { if (!client.supportsLoadSession()) { @@ -1003,6 +1056,14 @@ async function createSessionRecordWithClient( ); sessionId = options.resumeSessionId; agentSessionId = normalizeRuntimeSessionId(loadedSession.agentSessionId); + sessionModels = loadedSession.models; + requestedModelApplied = await applyRequestedModelIfAdvertised({ + client, + sessionId, + requestedModel: options.sessionOptions?.model, + models: loadedSession.models, + timeoutMs: options.timeoutMs, + }); } catch (error) { throw new Error( `Failed to resume ACP session ${options.resumeSessionId}: ${formatErrorMessage(error)}`, @@ -1017,6 +1078,14 @@ async function createSessionRecordWithClient( }); sessionId = createdSession.sessionId; agentSessionId = normalizeRuntimeSessionId(createdSession.agentSessionId); + sessionModels = createdSession.models; + requestedModelApplied = await applyRequestedModelIfAdvertised({ + client, + sessionId, + requestedModel: options.sessionOptions?.model, + models: createdSession.models, + timeoutMs: options.timeoutMs, + }); } const lifecycle = client.getAgentLifecycleSnapshot(); @@ -1045,6 +1114,10 @@ async function createSessionRecordWithClient( }; persistSessionOptions(record, options.sessionOptions); + syncAdvertisedModelState(record, sessionModels); + if (requestedModelApplied) { + setCurrentModelId(record, options.sessionOptions?.model); + } await writeSessionRecord(record); return record; @@ -1105,6 +1178,20 @@ export async function ensureSession(options: SessionEnsureOptions): Promise { + await runSessionSetModelDirect({ + sessionRecordId: options.sessionId, + modelId, + mcpServers: options.mcpServers, + nonInteractivePermissions: options.nonInteractivePermissions, + authCredentials: options.authCredentials, + authPolicy: options.authPolicy, + timeoutMs, + verbose: options.verbose, + }); + }, setSessionConfigOptionFallback: async (configId: string, value: string, timeoutMs?: number) => { const result = await runSessionSetConfigOptionDirect({ sessionRecordId: options.sessionId, @@ -1254,6 +1353,9 @@ export async function runSessionQueueOwner(options: QueueOwnerRuntimeOptions): P setSessionMode: async (modeId: string, timeoutMs?: number) => { await turnController.setSessionMode(modeId, timeoutMs); }, + setSessionModel: async (modelId: string, timeoutMs?: number) => { + await turnController.setSessionModel(modelId, timeoutMs); + }, setSessionConfigOption: async (configId: string, value: string, timeoutMs?: number) => { return await turnController.setSessionConfigOption(configId, value, timeoutMs); }, @@ -1424,6 +1526,38 @@ export async function setSessionMode( }); } +export async function setSessionModel( + options: SessionSetModelOptions, +): Promise { + const submittedToOwner = await trySetModelOnRunningOwner( + options.sessionId, + options.modelId, + options.timeoutMs, + options.verbose, + ); + if (submittedToOwner) { + const record = await resolveSessionRecord(options.sessionId); + setDesiredModelId(record, options.modelId); + setCurrentModelId(record, options.modelId); + await writeSessionRecord(record); + return { + record, + resumed: false, + }; + } + + return await runSessionSetModelDirect({ + sessionRecordId: options.sessionId, + modelId: options.modelId, + mcpServers: options.mcpServers, + nonInteractivePermissions: options.nonInteractivePermissions, + authCredentials: options.authCredentials, + authPolicy: options.authPolicy, + timeoutMs: options.timeoutMs, + verbose: options.verbose, + }); +} + export async function setSessionConfigOption( options: SessionSetConfigOptionOptions, ): Promise { diff --git a/src/session-runtime/connect-load.ts b/src/session-runtime/connect-load.ts index 6bf77484..bcdce132 100644 --- a/src/session-runtime/connect-load.ts +++ b/src/session-runtime/connect-load.ts @@ -5,11 +5,20 @@ import { isAcpQueryClosedBeforeResponseError, isAcpResourceNotFoundError, } from "../error-normalization.js"; -import { SessionModeReplayError, SessionResumeRequiredError } from "../errors.js"; +import { + SessionModeReplayError, + SessionModelReplayError, + SessionResumeRequiredError, +} from "../errors.js"; import { incrementPerfCounter } from "../perf-metrics.js"; import { isProcessAlive } from "../queue-ipc.js"; import type { QueueOwnerActiveSessionController } from "../queue-owner-turn-controller.js"; -import { getDesiredModeId } from "../session-mode-preference.js"; +import { + getDesiredModeId, + getDesiredModelId, + setCurrentModelId, + syncAdvertisedModelState, +} from "../session-mode-preference.js"; import { InterruptedError, TimeoutError, withTimeout } from "../session-runtime-helpers.js"; import type { SessionRecord, SessionResumePolicy } from "../types.js"; import { @@ -96,6 +105,7 @@ export async function connectAndLoadSession( const originalSessionId = record.acpSessionId; const originalAgentSessionId = record.agentSessionId; const desiredModeId = getDesiredModeId(record.acpx); + const desiredModelId = getDesiredModelId(record.acpx); const storedProcessAlive = isProcessAlive(record.pid); const shouldReconnect = Boolean(record.pid) && !storedProcessAlive; @@ -128,6 +138,7 @@ export async function connectAndLoadSession( let sessionId = record.acpSessionId; let createdFreshSession = false; let pendingAgentSessionId = record.agentSessionId; + let sessionModels: import("../client.js").SessionLoadResult["models"]; if (reusingLoadedSession) { resumed = true; @@ -140,6 +151,7 @@ export async function connectAndLoadSession( options.timeoutMs, ); reconcileAgentSessionId(record, loadResult.agentSessionId); + sessionModels = loadResult.models; resumed = true; } catch (error) { loadError = formatErrorMessage(error); @@ -157,6 +169,7 @@ export async function connectAndLoadSession( sessionId = createdSession.sessionId; createdFreshSession = true; pendingAgentSessionId = createdSession.agentSessionId; + sessionModels = createdSession.models; } } else { if (sameSessionOnly) { @@ -169,6 +182,7 @@ export async function connectAndLoadSession( sessionId = createdSession.sessionId; createdFreshSession = true; pendingAgentSessionId = createdSession.agentSessionId; + sessionModels = createdSession.models; } if (createdFreshSession && desiredModeId) { @@ -195,11 +209,46 @@ export async function connectAndLoadSession( } } + if ( + createdFreshSession && + desiredModelId && + sessionModels && + desiredModelId !== sessionModels.currentModelId + ) { + try { + await withTimeout(client.setSessionModel(sessionId, desiredModelId), options.timeoutMs); + setCurrentModelId(record, desiredModelId); + if (options.verbose) { + process.stderr.write( + `[acpx] replayed desired model ${desiredModelId} on fresh ACP session ${sessionId} (previous ${originalSessionId})\n`, + ); + } + } catch (error) { + const message = + `Failed to replay saved session model ${desiredModelId} on fresh ACP session ${sessionId}: ` + + formatErrorMessage(error); + record.acpSessionId = originalSessionId; + record.agentSessionId = originalAgentSessionId; + if (options.verbose) { + process.stderr.write(`[acpx] ${message}\n`); + } + throw new SessionModelReplayError(message, { + cause: error instanceof Error ? error : undefined, + retryable: true, + }); + } + } + if (createdFreshSession) { record.acpSessionId = sessionId; reconcileAgentSessionId(record, pendingAgentSessionId); } + syncAdvertisedModelState(record, sessionModels); + if (createdFreshSession && desiredModelId && sessionModels) { + setCurrentModelId(record, desiredModelId); + } + options.onSessionIdResolved?.(sessionId); return { diff --git a/src/session-runtime/prompt-runner.ts b/src/session-runtime/prompt-runner.ts index 4b2ca281..9967a87c 100644 --- a/src/session-runtime/prompt-runner.ts +++ b/src/session-runtime/prompt-runner.ts @@ -1,6 +1,10 @@ import { AcpClient } from "../client.js"; import type { QueueOwnerActiveSessionController } from "../queue-owner-turn-controller.js"; -import { setDesiredModeId } from "../session-mode-preference.js"; +import { + setCurrentModelId, + setDesiredModeId, + setDesiredModelId, +} from "../session-mode-preference.js"; import { absolutePath, isoNow, @@ -15,6 +19,7 @@ import type { PermissionMode, SessionRecord, SessionSetConfigOptionResult, + SessionSetModelResult, SessionSetModeResult, } from "../types.js"; import { connectAndLoadSession } from "./connect-load.js"; @@ -97,6 +102,9 @@ async function withConnectedSession( setSessionMode: async (modeId: string) => { await client.setSessionMode(activeSessionIdForControl, modeId); }, + setSessionModel: async (modelId: string) => { + await client.setSessionModel(activeSessionIdForControl, modelId); + }, setSessionConfigOption: async (configId: string, value: string) => { return await client.setSessionConfigOption(activeSessionIdForControl, configId, value); }, @@ -191,6 +199,19 @@ export type RunSessionSetConfigOptionDirectOptions = { onClientClosed?: () => void; }; +export type RunSessionSetModelDirectOptions = { + sessionRecordId: string; + modelId: string; + mcpServers?: McpServer[]; + nonInteractivePermissions?: NonInteractivePermissionPolicy; + authCredentials?: Record; + authPolicy?: AuthPolicy; + timeoutMs?: number; + verbose?: boolean; + onClientAvailable?: (controller: ActiveSessionController) => void; + onClientClosed?: () => void; +}; + export async function runSessionSetModeDirect( options: RunSessionSetModeDirectOptions, ): Promise { @@ -217,6 +238,33 @@ export async function runSessionSetModeDirect( }; } +export async function runSessionSetModelDirect( + options: RunSessionSetModelDirectOptions, +): Promise { + const result = await withConnectedSession({ + sessionRecordId: options.sessionRecordId, + mcpServers: options.mcpServers, + nonInteractivePermissions: options.nonInteractivePermissions, + authCredentials: options.authCredentials, + authPolicy: options.authPolicy, + timeoutMs: options.timeoutMs, + verbose: options.verbose, + onClientAvailable: options.onClientAvailable, + onClientClosed: options.onClientClosed, + run: async (client, sessionId, record) => { + await withTimeout(client.setSessionModel(sessionId, options.modelId), options.timeoutMs); + setDesiredModelId(record, options.modelId); + setCurrentModelId(record, options.modelId); + }, + }); + + return { + record: result.record, + resumed: result.resumed, + loadError: result.loadError, + }; +} + export async function runSessionSetConfigOptionDirect( options: RunSessionSetConfigOptionDirectOptions, ): Promise { diff --git a/src/types.ts b/src/types.ts index e9514b1c..19e9ee2e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -283,6 +283,8 @@ export type SessionConversation = { export type SessionAcpxState = { current_mode_id?: string; desired_mode_id?: string; + current_model_id?: string; + available_models?: string[]; available_commands?: string[]; config_options?: SessionConfigOption[]; session_options?: { @@ -349,6 +351,12 @@ export type SessionSetConfigOptionResult = { loadError?: string; }; +export type SessionSetModelResult = { + record: SessionRecord; + resumed: boolean; + loadError?: string; +}; + export type SessionEnsureResult = { record: SessionRecord; created: boolean; diff --git a/test/cli.test.ts b/test/cli.test.ts index 6ecaadb5..9aec3e64 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -876,16 +876,10 @@ test("codex set model passes the requested model through unchanged", async () => const payload = JSON.parse(result.stdout.trim()) as { action?: string; - configId?: string; - value?: string; - configOptions?: Array<{ id?: string; currentValue?: string; category?: string }>; + modelId?: string; }; - assert.equal(payload.action, "config_set"); - assert.equal(payload.configId, "model"); - assert.equal(payload.value, "GPT-5-2"); - const model = payload.configOptions?.find((option) => option.id === "model"); - assert.equal(model?.currentValue, "GPT-5-2"); - assert.equal(model?.category, "model"); + assert.equal(payload.action, "model_set"); + assert.equal(payload.modelId, "GPT-5-2"); }); }); diff --git a/test/client.test.ts b/test/client.test.ts index 4458739f..dd55696d 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -409,7 +409,7 @@ test("AcpClient createSession forwards claudeCode options in _meta", async () => }); }); -test("AcpClient createSession applies codex model via session/set_config_option", async () => { +test("AcpClient createSession forwards codex model metadata without setting it explicitly", async () => { const client = makeClient({ agentCommand: "npx @zed-industries/codex-acp", sessionOptions: { @@ -418,24 +418,14 @@ test("AcpClient createSession applies codex model via session/set_config_option" }); let capturedNewSessionParams: Record | undefined; - let capturedSetConfigParams: - | { - sessionId: string; - configId: string; - value: string; - } - | undefined; + let setConfigCalled = false; asInternals(client).connection = { newSession: async (params: Record) => { capturedNewSessionParams = params; return { sessionId: "session-456" }; }, - setSessionConfigOption: async (params: { - sessionId: string; - configId: string; - value: string; - }) => { - capturedSetConfigParams = params; + setSessionConfigOption: async () => { + setConfigCalled = true; return { configOptions: [] }; }, }; @@ -453,10 +443,28 @@ test("AcpClient createSession applies codex model via session/set_config_option" }, }, }); - assert.deepEqual(capturedSetConfigParams, { + assert.equal(setConfigCalled, false); +}); + +test("AcpClient setSessionModel uses session/set_model", async () => { + const client = makeClient(); + + let capturedSetModelParams: + | { + sessionId: string; + modelId: string; + } + | undefined; + asInternals(client).connection = { + unstable_setSessionModel: async (params: { sessionId: string; modelId: string }) => { + capturedSetModelParams = params; + }, + }; + + await client.setSessionModel("session-456", "GPT-5-2"); + assert.deepEqual(capturedSetModelParams, { sessionId: "session-456", - configId: "model", - value: "GPT-5-2", + modelId: "GPT-5-2", }); }); diff --git a/test/connect-load.test.ts b/test/connect-load.test.ts index a3f6f1a1..3dbf9214 100644 --- a/test/connect-load.test.ts +++ b/test/connect-load.test.ts @@ -3,7 +3,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import test from "node:test"; -import type { SetSessionConfigOptionResponse } from "@agentclientprotocol/sdk"; +import type { SessionModelState, SetSessionConfigOptionResponse } from "@agentclientprotocol/sdk"; import type { QueueOwnerActiveSessionController } from "../src/queue-owner-turn-controller.js"; import { connectAndLoadSession } from "../src/session-runtime/connect-load.js"; import type { SessionRecord } from "../src/types.js"; @@ -27,21 +27,37 @@ type FakeClient = { sessionId: string, cwd: string, options: { suppressReplayUpdates: boolean }, - ) => Promise<{ agentSessionId?: string }>; - createSession: (cwd: string) => Promise<{ sessionId: string; agentSessionId?: string }>; + ) => Promise<{ agentSessionId?: string; models?: SessionModelState }>; + createSession: (cwd: string) => Promise<{ + sessionId: string; + agentSessionId?: string; + models?: SessionModelState; + }>; setSessionMode: (sessionId: string, modeId: string) => Promise; + setSessionModel: (sessionId: string, modelId: string) => Promise; }; const ACTIVE_CONTROLLER: QueueOwnerActiveSessionController = { hasActivePrompt: () => false, requestCancelActivePrompt: async () => false, setSessionMode: async () => {}, + setSessionModel: async () => {}, setSessionConfigOption: async () => ({ configOptions: [], }) as SetSessionConfigOptionResponse, }; +function buildModelsState(currentModelId: string): SessionModelState { + return { + currentModelId, + availableModels: [ + { modelId: "default-model", name: "default-model" }, + { modelId: "gpt-5.4", name: "gpt-5.4" }, + ], + }; +} + test("connectAndLoadSession resumes an existing load-capable session", async () => { await withTempHome(async (homeDir) => { const cwd = path.join(homeDir, "workspace"); @@ -78,6 +94,7 @@ test("connectAndLoadSession resumes an existing load-capable session", async () throw new Error("createSession should not be called"); }, setSessionMode: async () => {}, + setSessionModel: async () => {}, }; const result = await connectAndLoadSession({ @@ -149,6 +166,7 @@ test("connectAndLoadSession falls back to createSession when load returns resour }; }, setSessionMode: async () => {}, + setSessionModel: async () => {}, }; const result = await connectAndLoadSession({ @@ -198,6 +216,7 @@ test("connectAndLoadSession fails instead of creating a fresh session when resum throw new Error("createSession should not be called"); }, setSessionMode: async () => {}, + setSessionModel: async () => {}, }; await assert.rejects( @@ -249,6 +268,7 @@ test("connectAndLoadSession falls back to createSession for empty sessions on ad agentSessionId: "created-runtime", }), setSessionMode: async () => {}, + setSessionModel: async () => {}, }; const result = await connectAndLoadSession({ @@ -290,6 +310,7 @@ test("connectAndLoadSession fails clearly when same-session resume is required b throw new Error("createSession should not be called"); }, setSessionMode: async () => {}, + setSessionModel: async () => {}, }; await assert.rejects( @@ -346,6 +367,7 @@ test("connectAndLoadSession falls back to session/new on -32602 Invalid params", agentSessionId: "fallback-runtime", }), setSessionMode: async () => {}, + setSessionModel: async () => {}, }; const result = await connectAndLoadSession({ @@ -400,6 +422,7 @@ test("connectAndLoadSession falls back to session/new on -32601 Method not found agentSessionId: "fallback-runtime", }), setSessionMode: async () => {}, + setSessionModel: async () => {}, }; const result = await connectAndLoadSession({ @@ -453,6 +476,7 @@ test("connectAndLoadSession rethrows load failures that should not create a new sessionId: "unexpected", }), setSessionMode: async () => {}, + setSessionModel: async () => {}, }; await assert.rejects( @@ -514,6 +538,7 @@ test("connectAndLoadSession fails when desired mode replay cannot be restored on assert.equal(modeId, "plan"); throw new Error("mode restore rejected"); }, + setSessionModel: async () => {}, }; await assert.rejects( @@ -534,6 +559,67 @@ test("connectAndLoadSession fails when desired mode replay cannot be restored on }); }); +test("connectAndLoadSession replays desired model on a fresh session", async () => { + await withTempHome(async (homeDir) => { + const cwd = path.join(homeDir, "workspace"); + await fs.mkdir(cwd, { recursive: true }); + + const record = makeSessionRecord({ + acpxRecordId: "model-replay-record", + acpSessionId: "stale-session", + agentCommand: "agent", + cwd, + acpx: { + session_options: { + model: "gpt-5.4", + }, + }, + }); + + let setModelCalls = 0; + const client: FakeClient = { + hasReusableSession: () => false, + start: async () => {}, + getAgentLifecycleSnapshot: () => ({ + running: true, + }), + supportsLoadSession: () => true, + loadSessionWithOptions: async () => { + throw { + error: { + code: -32002, + message: "session not found", + }, + }; + }, + createSession: async () => ({ + sessionId: "fresh-session", + agentSessionId: "fresh-runtime", + models: buildModelsState("default-model"), + }), + setSessionMode: async () => {}, + setSessionModel: async (sessionId, modelId) => { + setModelCalls += 1; + assert.equal(sessionId, "fresh-session"); + assert.equal(modelId, "gpt-5.4"); + }, + }; + + const result = await connectAndLoadSession({ + client: client as never, + record, + activeController: ACTIVE_CONTROLLER, + }); + + assert.equal(result.sessionId, "fresh-session"); + assert.equal(result.resumed, false); + assert.equal(setModelCalls, 1); + assert.equal(record.acpSessionId, "fresh-session"); + assert.equal(record.acpx?.current_model_id, "gpt-5.4"); + assert.deepEqual(record.acpx?.available_models, ["default-model", "gpt-5.4"]); + }); +}); + test("connectAndLoadSession reuses an already loaded client session", async () => { await withTempHome(async (homeDir) => { const cwd = path.join(homeDir, "workspace"); @@ -567,6 +653,7 @@ test("connectAndLoadSession reuses an already loaded client session", async () = throw new Error("createSession should not be called"); }, setSessionMode: async () => {}, + setSessionModel: async () => {}, }; const result = await connectAndLoadSession({ diff --git a/test/integration.test.ts b/test/integration.test.ts index c7dff62e..87d1017b 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -801,6 +801,305 @@ test("integration: exec forwards model, allowed-tools, and max-turns in session/ }); }); +test("integration: exec --model calls session/set_model when agent advertises models", async () => { + await withTempHome(async (homeDir) => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-integration-cwd-")); + const modelAgentCommand = `${MOCK_AGENT_COMMAND} --advertise-models`; + + try { + const result = await runCli( + [ + "--agent", + modelAgentCommand, + "--approve-all", + "--cwd", + cwd, + "--format", + "json", + "--model", + "fast-model", + "exec", + "echo hello", + ], + homeDir, + ); + assert.equal(result.code, 0, result.stderr); + + const payloads = parseJsonRpcOutputLines(result.stdout); + const setModelRequest = payloads.find((payload) => payload.method === "session/set_model") as + | { params?: { modelId?: string } } + | undefined; + assert(setModelRequest, "expected session/set_model request in JSON-RPC output"); + assert.equal(setModelRequest.params?.modelId, "fast-model"); + } finally { + await fs.rm(cwd, { recursive: true, force: true }); + } + }); +}); + +test("integration: exec --model skips session/set_model when agent does not advertise models", async () => { + await withTempHome(async (homeDir) => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-integration-cwd-")); + + try { + const result = await runCli( + [...baseAgentArgs(cwd), "--format", "json", "--model", "sonnet", "exec", "echo hello"], + homeDir, + ); + assert.equal(result.code, 0, result.stderr); + + const payloads = parseJsonRpcOutputLines(result.stdout); + + // _meta.claudeCode.options.model should still be sent + const createRequest = payloads.find((payload) => payload.method === "session/new") as + | { params?: { _meta?: Record } } + | undefined; + assert(createRequest, "expected session/new request"); + assert.deepEqual((createRequest.params?._meta as Record)?.claudeCode, { + options: { model: "sonnet" }, + }); + + // session/set_model should NOT be called + const setModelRequest = payloads.find((payload) => payload.method === "session/set_model"); + assert.equal(setModelRequest, undefined, "session/set_model should not be called"); + } 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-")); + const failModelAgentCommand = `${MOCK_AGENT_COMMAND} --set-session-model-fails`; + + try { + const result = await runCli( + [ + "--agent", + failModelAgentCommand, + "--approve-all", + "--cwd", + cwd, + "--format", + "quiet", + "--model", + "bad-model", + "exec", + "echo hello", + ], + homeDir, + ); + assert.notEqual(result.code, 0, "expected non-zero exit"); + assert.equal(result.stdout, ""); + assert.match(result.stderr, /setSessionModel failed|session\/set_model/i); + } finally { + await fs.rm(cwd, { recursive: true, force: true }); + } + }); +}); + +test("integration: sessions new --model fails when session/set_model fails", async () => { + await withTempHome(async (homeDir) => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-integration-cwd-")); + const failModelAgentCommand = `${MOCK_AGENT_COMMAND} --set-session-model-fails`; + + try { + const result = await runCli( + [ + "--agent", + failModelAgentCommand, + "--approve-all", + "--cwd", + cwd, + "--model", + "bad-model", + "sessions", + "new", + ], + homeDir, + ); + assert.notEqual(result.code, 0, "expected non-zero exit"); + assert.match(result.stderr, /setSessionModel failed|session\/set_model/i); + } finally { + await fs.rm(cwd, { recursive: true, force: true }); + } + }); +}); + +test("integration: set model routes through session/set_model and succeeds", async () => { + await withTempHome(async (homeDir) => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-integration-cwd-")); + const modelAgentCommand = `${MOCK_AGENT_COMMAND} --advertise-models`; + + try { + // Create session + const created = await runCli( + ["--agent", modelAgentCommand, "--approve-all", "--cwd", cwd, "sessions", "new"], + homeDir, + ); + assert.equal(created.code, 0, created.stderr); + + // Switch model mid-session via set command (uses session/set_model internally) + const setResult = await runCli( + [ + "--agent", + modelAgentCommand, + "--approve-all", + "--cwd", + cwd, + "--format", + "json", + "set", + "model", + "gpt-5.4", + ], + homeDir, + ); + assert.equal(setResult.code, 0, setResult.stderr); + const payload = JSON.parse(setResult.stdout.trim()) as { + action?: string; + modelId?: string; + }; + assert.equal(payload.action, "model_set"); + assert.equal(payload.modelId, "gpt-5.4"); + } finally { + await fs.rm(cwd, { recursive: true, force: true }); + } + }); +}); + +test("integration: set model rejects with clear error on ACP invalid params", async () => { + await withTempHome(async (homeDir) => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-integration-cwd-")); + const invalidModelAgentCommand = `${MOCK_AGENT_COMMAND} --set-session-model-invalid-params`; + + try { + // Create session + const created = await runCli( + ["--agent", invalidModelAgentCommand, "--approve-all", "--cwd", cwd, "sessions", "new"], + homeDir, + ); + assert.equal(created.code, 0, created.stderr); + + // Attempt model switch — should fail with enriched error + const setResult = await runCli( + [ + "--agent", + invalidModelAgentCommand, + "--approve-all", + "--cwd", + cwd, + "set", + "model", + "bad-model", + ], + homeDir, + ); + assert.notEqual(setResult.code, 0, "expected non-zero exit"); + assert.match(setResult.stderr, /rejected session\/set_model/i); + } finally { + await fs.rm(cwd, { recursive: true, force: true }); + } + }); +}); + +test("integration: status shows model after session creation with --model", async () => { + await withTempHome(async (homeDir) => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-integration-cwd-")); + const modelAgentCommand = `${MOCK_AGENT_COMMAND} --advertise-models`; + + try { + // Create session with --model + const created = await runCli( + [ + "--agent", + modelAgentCommand, + "--approve-all", + "--cwd", + cwd, + "--model", + "gpt-5.4", + "sessions", + "new", + ], + homeDir, + ); + assert.equal(created.code, 0, created.stderr); + + // Check status JSON + const status = await runCli( + ["--agent", modelAgentCommand, "--approve-all", "--cwd", cwd, "--format", "json", "status"], + homeDir, + ); + assert.equal(status.code, 0, status.stderr); + + const statusPayload = JSON.parse(status.stdout.trim()) as { + model?: string; + mode?: string; + availableModels?: string[]; + }; + assert.equal(statusPayload.model, "gpt-5.4"); + assert(Array.isArray(statusPayload.availableModels), "expected availableModels array"); + + // Check status text + const statusText = await runCli( + ["--agent", modelAgentCommand, "--approve-all", "--cwd", cwd, "status"], + homeDir, + ); + assert.equal(statusText.code, 0, statusText.stderr); + assert.match(statusText.stdout, /model: gpt-5\.4/); + } finally { + await fs.rm(cwd, { recursive: true, force: true }); + } + }); +}); + +test("integration: status shows updated model after set model", async () => { + await withTempHome(async (homeDir) => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-integration-cwd-")); + const modelAgentCommand = `${MOCK_AGENT_COMMAND} --advertise-models`; + + try { + // Create session with --model + const created = await runCli( + [ + "--agent", + modelAgentCommand, + "--approve-all", + "--cwd", + cwd, + "--model", + "fast-model", + "sessions", + "new", + ], + homeDir, + ); + assert.equal(created.code, 0, created.stderr); + + // Switch model + const setResult = await runCli( + ["--agent", modelAgentCommand, "--approve-all", "--cwd", cwd, "set", "model", "gpt-5.4"], + homeDir, + ); + assert.equal(setResult.code, 0, setResult.stderr); + + // Check status JSON — should show updated model + const status = await runCli( + ["--agent", modelAgentCommand, "--approve-all", "--cwd", cwd, "--format", "json", "status"], + homeDir, + ); + assert.equal(status.code, 0, status.stderr); + + const statusPayload = JSON.parse(status.stdout.trim()) as { model?: string }; + assert.equal(statusPayload.model, "gpt-5.4"); + } finally { + await fs.rm(cwd, { recursive: true, force: true }); + } + }); +}); + test("integration: perf metrics capture writes ndjson records for CLI runs", async () => { await withTempHome(async (homeDir) => { const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-integration-cwd-")); diff --git a/test/mock-agent.ts b/test/mock-agent.ts index f0f3c462..86e4dfd7 100644 --- a/test/mock-agent.ts +++ b/test/mock-agent.ts @@ -19,8 +19,11 @@ import { type SessionId, type SetSessionConfigOptionRequest, type SetSessionConfigOptionResponse, + type SetSessionModelRequest, + type SetSessionModelResponse, type SetSessionModeRequest, type SetSessionModeResponse, + type SessionModelState, } from "@agentclientprotocol/sdk"; type ParsedCommand = { @@ -38,6 +41,9 @@ type MockAgentOptions = { setSessionModeFails: boolean; setSessionModeInvalidParams: boolean; setSessionConfigInvalidParams: boolean; + setSessionModelFails: boolean; + setSessionModelInvalidParams: boolean; + advertiseModels: boolean; replayLoadSessionUpdates: boolean; loadReplayText: string; ignoreSigterm: boolean; @@ -49,6 +55,7 @@ type SessionState = { modeId: string; configValues: Record; transientPromptAttempts: Record; + modelId: string; }; class CancelledError extends Error { @@ -294,6 +301,9 @@ function parseMockAgentOptions(argv: string[]): MockAgentOptions { let setSessionModeFails = false; let setSessionModeInvalidParams = false; let setSessionConfigInvalidParams = false; + let setSessionModelFails = false; + let setSessionModelInvalidParams = false; + let advertiseModels = false; let replayLoadSessionUpdates = false; let loadReplayText = "replayed load session update"; let ignoreSigterm = false; @@ -334,6 +344,23 @@ function parseMockAgentOptions(argv: string[]): MockAgentOptions { continue; } + if (token === "--set-session-model-fails") { + setSessionModelFails = true; + advertiseModels = true; + continue; + } + + if (token === "--set-session-model-invalid-params") { + setSessionModelInvalidParams = true; + advertiseModels = true; + continue; + } + + if (token === "--advertise-models") { + advertiseModels = true; + continue; + } + if (token === "--replay-load-session-updates") { supportsLoadSession = true; replayLoadSessionUpdates = true; @@ -386,16 +413,30 @@ function parseMockAgentOptions(argv: string[]): MockAgentOptions { setSessionModeFails, setSessionModeInvalidParams, setSessionConfigInvalidParams, + setSessionModelFails, + setSessionModelInvalidParams, + advertiseModels, replayLoadSessionUpdates, loadReplayText, ignoreSigterm, }; } +const DEFAULT_MODEL_ID = "default-model"; +const AVAILABLE_MODELS = ["default-model", "fast-model", "smart-model"]; + +function buildModelsState(currentModelId: string): SessionModelState { + return { + currentModelId, + availableModels: AVAILABLE_MODELS.map((id) => ({ modelId: id, name: id })), + }; +} + function createSessionState(hasCompletedPrompt = false): SessionState { return { hasCompletedPrompt, modeId: "auto", + modelId: DEFAULT_MODEL_ID, configValues: { reasoning_effort: "medium", }, @@ -408,8 +449,6 @@ function buildConfigOptions(state: SessionState): SetSessionConfigOptionResponse typeof state.configValues.reasoning_effort === "string" ? state.configValues.reasoning_effort : "medium"; - const modelId = - typeof state.configValues.model === "string" ? state.configValues.model : "default"; return [ { @@ -431,7 +470,7 @@ function buildConfigOptions(state: SessionState): SetSessionConfigOptionResponse name: "Model", category: "model", type: "select", - currentValue: modelId, + currentValue: state.modelId, options: [ { value: "default", name: "Default" }, { value: "gpt-5.4", name: "gpt-5.4" }, @@ -484,14 +523,17 @@ class MockAgent implements Agent { const sessionId = randomUUID(); this.sessions.set(sessionId, createSessionState(false)); + const response: NewSessionResponse = { sessionId }; + if (this.options.newSessionMeta) { - return { - sessionId, - _meta: { ...this.options.newSessionMeta }, - }; + response._meta = { ...this.options.newSessionMeta }; } - return { sessionId }; + if (this.options.advertiseModels) { + response.models = buildModelsState(DEFAULT_MODEL_ID); + } + + return response; } async loadSession(params: LoadSessionRequest): Promise { @@ -524,13 +566,18 @@ class MockAgent implements Agent { await this.sendAssistantMessage(params.sessionId, this.options.loadReplayText); } + const response: LoadSessionResponse = {}; + if (this.options.loadSessionMeta) { - return { - _meta: { ...this.options.loadSessionMeta }, - }; + response._meta = { ...this.options.loadSessionMeta }; } - return {}; + if (this.options.advertiseModels) { + const session = this.sessions.get(params.sessionId); + response.models = buildModelsState(session?.modelId ?? DEFAULT_MODEL_ID); + } + + return response; } async prompt(params: PromptRequest): Promise { @@ -639,6 +686,30 @@ class MockAgent implements Agent { return {}; } + async unstable_setSessionModel(params: SetSessionModelRequest): Promise { + const session = this.ensureSession(params.sessionId); + if (this.options.setSessionModelInvalidParams) { + const error = new Error("Invalid params") as Error & { + code: number; + data: { + method: string; + modelId: string; + }; + }; + error.code = -32602; + error.data = { + method: "session/set_model", + modelId: params.modelId, + }; + throw error; + } + if (this.options.setSessionModelFails) { + throw new Error("setSessionModel failed"); + } + session.modelId = params.modelId; + return {}; + } + async setSessionConfigOption( params: SetSessionConfigOptionRequest, ): Promise { diff --git a/test/prompt-runner.test.ts b/test/prompt-runner.test.ts index d1204522..3c424565 100644 --- a/test/prompt-runner.test.ts +++ b/test/prompt-runner.test.ts @@ -8,6 +8,7 @@ import { serializeSessionRecordForDisk } from "../src/session-persistence.js"; import { resolveSessionRecord } from "../src/session-persistence/repository.js"; import { runSessionSetConfigOptionDirect, + runSessionSetModelDirect, runSessionSetModeDirect, } from "../src/session-runtime/prompt-runner.js"; import type { SessionRecord } from "../src/types.js"; @@ -126,7 +127,7 @@ test("runSessionSetConfigOptionDirect falls back to createSession and returns up name: "Model", category: "model", type: "select", - currentValue: "default", + currentValue: "default-model", options: [ { value: "default", @@ -176,6 +177,37 @@ test("runSessionSetConfigOptionDirect falls back to createSession and returns up }); }); +test("runSessionSetModelDirect updates current and desired model", async () => { + await withTempHome(async (homeDir) => { + const cwd = path.join(homeDir, "workspace"); + await fs.mkdir(cwd, { recursive: true }); + + const record = makeSessionRecord({ + acpxRecordId: "prompt-runner-model", + acpSessionId: "prompt-runner-model-session", + agentCommand: `node ${JSON.stringify(MOCK_AGENT_PATH)} --supports-load-session --advertise-models`, + cwd, + closed: true, + closedAt: "2026-01-01T00:05:00.000Z", + }); + await writeSessionRecord(homeDir, record); + + const result = await runSessionSetModelDirect({ + sessionRecordId: record.acpxRecordId, + modelId: "gpt-5.4", + timeoutMs: 5_000, + }); + + assert.equal(result.resumed, true); + assert.equal(result.record.acpx?.current_model_id, "gpt-5.4"); + assert.equal(result.record.acpx?.session_options?.model, "gpt-5.4"); + + const persisted = await resolveSessionRecord(record.acpxRecordId); + assert.equal(persisted.acpx?.current_model_id, "gpt-5.4"); + assert.equal(persisted.acpx?.session_options?.model, "gpt-5.4"); + }); +}); + function makeSessionRecord( overrides: Partial & { acpxRecordId: string; diff --git a/test/queue-ipc-errors.test.ts b/test/queue-ipc-errors.test.ts index 688a0f46..3e2a56c2 100644 --- a/test/queue-ipc-errors.test.ts +++ b/test/queue-ipc-errors.test.ts @@ -516,6 +516,9 @@ test("SessionQueueOwner emits typed invalid request payload errors", async () => setSessionMode: async () => { // no-op }, + setSessionModel: async () => { + // no-op + }, setSessionConfigOption: async () => ({ configOptions: [], @@ -560,6 +563,9 @@ test("SessionQueueOwner emits typed shutdown errors for pending prompts", async setSessionMode: async () => { // no-op }, + setSessionModel: async () => { + // no-op + }, setSessionConfigOption: async () => ({ configOptions: [], @@ -625,6 +631,9 @@ test("SessionQueueOwner rejects prompts when queue depth exceeds the configured setSessionMode: async () => { // no-op }, + setSessionModel: async () => { + // no-op + }, setSessionConfigOption: async () => ({ configOptions: [], diff --git a/test/queue-ipc-server.test.ts b/test/queue-ipc-server.test.ts index 72d602c1..08a2c2ae 100644 --- a/test/queue-ipc-server.test.ts +++ b/test/queue-ipc-server.test.ts @@ -26,6 +26,9 @@ test("SessionQueueOwner handles control requests and nextTask timeouts", async ( setSessionMode: async (modeId) => { modes.push(modeId); }, + setSessionModel: async () => { + // no-op + }, setSessionConfigOption: async (configId, value) => { configRequests.push({ id: configId, value }); return { @@ -125,6 +128,9 @@ test("SessionQueueOwner enqueues fire-and-forget prompts and rejects invalid own setSessionMode: async () => { // no-op }, + setSessionModel: async () => { + // no-op + }, setSessionConfigOption: async () => ({ configOptions: [], diff --git a/test/turn-controller.test.ts b/test/turn-controller.test.ts index 2aa63c9b..b12dbe38 100644 --- a/test/turn-controller.test.ts +++ b/test/turn-controller.test.ts @@ -113,6 +113,38 @@ test("QueueOwnerTurnController routes setSessionMode through active controller", assert.deepEqual(fallbackTimeouts, []); }); +test("QueueOwnerTurnController routes setSessionModel through active controller", async () => { + let activeCalls = 0; + let fallbackCalls = 0; + const observedTimeouts: Array = []; + const fallbackTimeouts: Array = []; + + const controller = createQueueOwnerTurnController({ + withTimeout: async (run, timeoutMs) => { + observedTimeouts.push(timeoutMs); + return await run(); + }, + setSessionModelFallback: async (_modelId, timeoutMs) => { + fallbackCalls += 1; + fallbackTimeouts.push(timeoutMs); + }, + }); + + controller.setActiveController( + makeActiveController({ + setSessionModel: async () => { + activeCalls += 1; + }, + }), + ); + + await controller.setSessionModel("gpt-5.4", 1500); + assert.equal(activeCalls, 1); + assert.equal(fallbackCalls, 0); + assert.deepEqual(observedTimeouts, [1500]); + assert.deepEqual(fallbackTimeouts, []); +}); + test("QueueOwnerTurnController routes setSessionConfigOption through fallback when inactive", async () => { let fallbackCalls = 0; const fallbackTimeouts: Array = []; @@ -133,11 +165,15 @@ test("QueueOwnerTurnController routes setSessionConfigOption through fallback wh test("QueueOwnerTurnController rejects control requests while closing", async () => { let setModeFallbackCalls = 0; + let setModelFallbackCalls = 0; let setConfigFallbackCalls = 0; const controller = createQueueOwnerTurnController({ setSessionModeFallback: async () => { setModeFallbackCalls += 1; }, + setSessionModelFallback: async () => { + setModelFallbackCalls += 1; + }, setSessionConfigOptionFallback: async () => { setConfigFallbackCalls += 1; return { configOptions: [] }; @@ -150,17 +186,23 @@ test("QueueOwnerTurnController rejects control requests while closing", async () async () => await controller.setSessionMode("plan"), /Queue owner is closing/, ); + await assert.rejects( + async () => await controller.setSessionModel("gpt-5.4"), + /Queue owner is closing/, + ); await assert.rejects( async () => await controller.setSessionConfigOption("k", "v"), /Queue owner is closing/, ); assert.equal(setModeFallbackCalls, 0); + assert.equal(setModelFallbackCalls, 0); assert.equal(setConfigFallbackCalls, 0); }); type QueueOwnerTurnControllerOverrides = Partial<{ withTimeout: (run: () => Promise, timeoutMs?: number) => Promise; setSessionModeFallback: (modeId: string, timeoutMs?: number) => Promise; + setSessionModelFallback: (modelId: string, timeoutMs?: number) => Promise; setSessionConfigOptionFallback: ( configId: string, value: string, @@ -178,6 +220,11 @@ function createQueueOwnerTurnController( (async (): Promise => { // no-op }); + const setSessionModelFallback = + overrides.setSessionModelFallback ?? + (async (): Promise => { + // no-op + }); const setSessionConfigOptionFallback = overrides.setSessionConfigOptionFallback ?? (async () => ({ @@ -187,6 +234,7 @@ function createQueueOwnerTurnController( return new QueueOwnerTurnController({ withTimeout, setSessionModeFallback, + setSessionModelFallback, setSessionConfigOptionFallback, }); } @@ -204,6 +252,11 @@ function makeActiveController( (async () => { // no-op }), + setSessionModel: + overrides.setSessionModel ?? + (async () => { + // no-op + }), setSessionConfigOption: overrides.setSessionConfigOption ?? (async () => ({ configOptions: [] })), };