Skip to content

Commit 88b95b2

Browse files
ironerumiclaudeosolmaz
authored
feat: generic model selection via ACP session/set_model (#150)
* feat: generic model selection via ACP session/set_model Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: cover explicit codex model control --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Onur Solmaz <2453968+osolmaz@users.noreply.github.com>
1 parent fb392bb commit 88b95b2

26 files changed

Lines changed: 1209 additions & 76 deletions

docs/CLI.md

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -102,20 +102,21 @@ or close a PR if you run it against a live repository.
102102
103103
All global options:
104104
105-
| Option | Description | Details |
106-
| ---------------------------------------- | ---------------------------------------------- | ------------------------------------------------------------------- |
107-
| `--agent <command>` | Raw ACP agent command (escape hatch) | Do not combine with positional agent token. |
108-
| `--cwd <dir>` | Working directory | Defaults to current directory. Stored as absolute path for scoping. |
109-
| `--approve-all` | Auto-approve all permissions | Permission mode `approve-all`. |
110-
| `--approve-reads` | Auto-approve reads/searches, prompt for others | Default permission mode. |
111-
| `--deny-all` | Deny all permissions | Permission mode `deny-all`. |
112-
| `--format <fmt>` | Output format | `text` (default), `json`, `quiet`. |
113-
| `--suppress-reads` | Suppress read file contents | Replaces raw read payloads with `[read output suppressed]`. |
114-
| `--json-strict` | Strict JSON mode | Requires `--format json`; suppresses non-JSON stderr output. |
115-
| `--non-interactive-permissions <policy>` | Non-TTY prompt policy | `deny` (default) or `fail` when approval prompt cannot be shown. |
116-
| `--timeout <seconds>` | Max wait time for agent response | Must be positive. Decimal seconds allowed. |
117-
| `--ttl <seconds>` | Queue owner idle TTL before shutdown | Default `300`. `0` disables TTL. |
118-
| `--verbose` | Enable verbose logs | Prints ACP/debug details to stderr. |
105+
| Option | Description | Details |
106+
| ---------------------------------------- | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
107+
| `--agent <command>` | Raw ACP agent command (escape hatch) | Do not combine with positional agent token. |
108+
| `--cwd <dir>` | Working directory | Defaults to current directory. Stored as absolute path for scoping. |
109+
| `--approve-all` | Auto-approve all permissions | Permission mode `approve-all`. |
110+
| `--approve-reads` | Auto-approve reads/searches, prompt for others | Default permission mode. |
111+
| `--deny-all` | Deny all permissions | Permission mode `deny-all`. |
112+
| `--format <fmt>` | Output format | `text` (default), `json`, `quiet`. |
113+
| `--suppress-reads` | Suppress read file contents | Replaces raw read payloads with `[read output suppressed]`. |
114+
| `--json-strict` | Strict JSON mode | Requires `--format json`; suppresses non-JSON stderr output. |
115+
| `--non-interactive-permissions <policy>` | Non-TTY prompt policy | `deny` (default) or `fail` when approval prompt cannot be shown. |
116+
| `--timeout <seconds>` | Max wait time for agent response | Must be positive. Decimal seconds allowed. |
117+
| `--ttl <seconds>` | Queue owner idle TTL before shutdown | Default `300`. `0` disables TTL. |
118+
| `--model <id>` | 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`. |
119+
| `--verbose` | Enable verbose logs | Prints ACP/debug details to stderr. |
119120
120121
Permission flags are mutually exclusive. Using more than one of `--approve-all`, `--approve-reads`, `--deny-all` is a usage error.
121122
@@ -287,6 +288,7 @@ Behavior:
287288
- Calls ACP `session/set_config_option`.
288289
- Routes through queue-owner IPC when an owner is active.
289290
- Falls back to a direct client reconnect when no owner is running.
291+
- **`set model <id>`**: 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.
290292
291293
## `sessions` subcommand
292294

skills/acpx/SKILL.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,12 +139,13 @@ Behavior:
139139
- Runs a single prompt in a temporary ACP session
140140
- Does not reuse or save persistent session state
141141
142-
### Cancel / Mode / Config
142+
### Cancel / Mode / Config / Model
143143
144144
```bash
145145
acpx codex cancel
146146
acpx codex set-mode auto
147147
acpx codex set thought_level high
148+
acpx codex set model gpt-5.4
148149
```
149150
150151
Behavior:
@@ -153,8 +154,9 @@ Behavior:
153154
- `set-mode`: calls ACP `session/set_mode`.
154155
- `set-mode` mode ids are adapter-defined; unsupported values are rejected by the adapter (often `Invalid params`).
155156
- `set`: calls ACP `session/set_config_option`.
156-
- For codex, `--model <id>` is applied after session creation via `session/set_config_option`.
157157
- For codex, `thought_level` is accepted as a compatibility alias for codex-acp `reasoning_effort`.
158+
- `--model <id>`: passed through to agent-specific session creation metadata when applicable; if the agent advertises models, `acpx` also applies it via `session/set_model`.
159+
- `set model <id>`: calls `session/set_model`. This is the generic ACP method for mid-session model switching.
158160
- `set-mode`/`set` route through queue-owner IPC when active, otherwise reconnect directly.
159161
160162
### Sessions
@@ -200,6 +202,7 @@ Behavior:
200202
- `--suppress-reads`: suppress raw read-file contents while preserving the selected format
201203
- `--timeout <seconds>`: max wait time (positive number)
202204
- `--ttl <seconds>`: queue owner idle TTL before shutdown (default `300`, `0` disables TTL)
205+
- `--model <id>`: request an agent model during session creation; when the agent advertises models, `acpx` also applies it via `session/set_model`
203206
- `--verbose`: verbose ACP/debug logs to stderr
204207
205208
Permission flags are mutually exclusive.

src/cli-core.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,32 @@ function printSetModeResultByFormat(
421421
process.stdout.write(`mode set: ${modeId}\n`);
422422
}
423423

424+
function printSetModelResultByFormat(
425+
modelId: string,
426+
result: { record: SessionRecord; resumed: boolean },
427+
format: OutputFormat,
428+
): void {
429+
if (
430+
emitJsonResult(format, {
431+
action: "model_set",
432+
modelId,
433+
resumed: result.resumed,
434+
acpxRecordId: result.record.acpxRecordId,
435+
acpxSessionId: result.record.acpSessionId,
436+
agentSessionId: result.record.agentSessionId,
437+
})
438+
) {
439+
return;
440+
}
441+
442+
if (format === "quiet") {
443+
process.stdout.write(`${modelId}\n`);
444+
return;
445+
}
446+
447+
process.stdout.write(`model set: ${modelId}\n`);
448+
}
449+
424450
function printSetConfigOptionResultByFormat(
425451
configId: string,
426452
value: string,
@@ -526,6 +552,40 @@ async function handleSetMode(
526552
printSetModeResultByFormat(modeId, result, globalFlags.format);
527553
}
528554

555+
async function handleSetModel(
556+
explicitAgentName: string | undefined,
557+
modelId: string,
558+
flags: StatusFlags,
559+
command: Command,
560+
config: ResolvedAcpxConfig,
561+
): Promise<void> {
562+
const globalFlags = resolveGlobalFlags(command, config);
563+
const agent = resolveAgentInvocation(explicitAgentName, globalFlags, config);
564+
const { setSessionModel } = await loadSessionModule();
565+
const record = await findRoutedSessionOrThrow(
566+
agent.agentCommand,
567+
agent.agentName,
568+
agent.cwd,
569+
resolveSessionNameFromFlags(flags, command),
570+
);
571+
const result = await setSessionModel({
572+
sessionId: record.acpxRecordId,
573+
modelId,
574+
mcpServers: config.mcpServers,
575+
nonInteractivePermissions: globalFlags.nonInteractivePermissions,
576+
authCredentials: config.auth,
577+
authPolicy: globalFlags.authPolicy,
578+
timeoutMs: globalFlags.timeout,
579+
verbose: globalFlags.verbose,
580+
});
581+
582+
if (globalFlags.verbose && result.loadError) {
583+
process.stderr.write(`[acpx] loadSession failed, started fresh session: ${result.loadError}\n`);
584+
}
585+
586+
printSetModelResultByFormat(modelId, result, globalFlags.format);
587+
}
588+
529589
async function handleSetConfigOption(
530590
explicitAgentName: string | undefined,
531591
configId: string,
@@ -536,6 +596,10 @@ async function handleSetConfigOption(
536596
): Promise<void> {
537597
const globalFlags = resolveGlobalFlags(command, config);
538598
const agent = resolveAgentInvocation(explicitAgentName, globalFlags, config);
599+
if (configId === "model") {
600+
await handleSetModel(explicitAgentName, value, flags, command, config);
601+
return;
602+
}
539603
const resolvedConfigId = resolveCompatibleConfigId(agent, configId);
540604
const { setSessionConfigOption } = await loadSessionModule();
541605
const record = await findRoutedSessionOrThrow(

src/cli/status-command.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ export async function handleStatus(
6666
process.stdout.write(`agent: ${agent.agentCommand}\n`);
6767
process.stdout.write("pid: -\n");
6868
process.stdout.write("status: no-session\n");
69+
process.stdout.write("model: -\n");
70+
process.stdout.write("mode: -\n");
6971
process.stdout.write("uptime: -\n");
7072
process.stdout.write("lastPromptTime: -\n");
7173
return;
@@ -78,6 +80,9 @@ export async function handleStatus(
7880
agentCommand: record.agentCommand,
7981
pid: health.pid ?? record.pid ?? null,
8082
status: running ? "running" : "dead",
83+
model: record.acpx?.current_model_id ?? null,
84+
mode: record.acpx?.current_mode_id ?? null,
85+
availableModels: record.acpx?.available_models ?? null,
8186
uptime: running ? (formatUptime(record.agentStartedAt) ?? null) : null,
8287
lastPromptTime: record.lastPromptAt ?? null,
8388
exitCode: running ? null : (record.lastAgentExitCode ?? null),
@@ -91,6 +96,9 @@ export async function handleStatus(
9196
status: running ? "alive" : "dead",
9297
pid: payload.pid ?? undefined,
9398
summary: running ? "queue owner healthy" : "queue owner unavailable",
99+
model: payload.model ?? undefined,
100+
mode: payload.mode ?? undefined,
101+
availableModels: payload.availableModels ?? undefined,
94102
uptime: payload.uptime ?? undefined,
95103
lastPromptTime: payload.lastPromptTime ?? undefined,
96104
exitCode: payload.exitCode ?? undefined,
@@ -115,6 +123,8 @@ export async function handleStatus(
115123
process.stdout.write(`agent: ${payload.agentCommand}\n`);
116124
process.stdout.write(`pid: ${payload.pid ?? "-"}\n`);
117125
process.stdout.write(`status: ${payload.status}\n`);
126+
process.stdout.write(`model: ${payload.model ?? "-"}\n`);
127+
process.stdout.write(`mode: ${payload.mode ?? "-"}\n`);
118128
process.stdout.write(`uptime: ${payload.uptime ?? "-"}\n`);
119129
process.stdout.write(`lastPromptTime: ${payload.lastPromptTime ?? "-"}\n`);
120130
if (payload.status === "dead") {

src/client.ts

Lines changed: 42 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,10 @@ import {
2727
type WaitForTerminalExitResponse,
2828
type WriteTextFileRequest,
2929
type WriteTextFileResponse,
30+
type SessionModelState,
3031
} from "@agentclientprotocol/sdk";
3132
import { extractAcpError } from "./acp-error-shapes.js";
3233
import { isSessionUpdateNotification } from "./acp-jsonrpc.js";
33-
import { isCodexAcpCommand } from "./codex-compat.js";
3434
import {
3535
AgentDisconnectedError,
3636
AgentSpawnError,
@@ -86,10 +86,12 @@ type LoadSessionOptions = {
8686
export type SessionCreateResult = {
8787
sessionId: string;
8888
agentSessionId?: string;
89+
models?: SessionModelState;
8990
};
9091

9192
export type SessionLoadResult = {
9293
agentSessionId?: string;
94+
models?: SessionModelState;
9395
};
9496

9597
type AgentDisconnectReason = "process_exit" | "process_close" | "pipe_close" | "connection_close";
@@ -762,7 +764,7 @@ function formatSessionControlAcpSummary(acp: {
762764
}
763765

764766
function maybeWrapSessionControlError(
765-
method: "session/set_mode" | "session/set_config_option",
767+
method: "session/set_mode" | "session/set_config_option" | "session/set_model",
766768
error: unknown,
767769
context?: string,
768770
): unknown {
@@ -1190,7 +1192,6 @@ export class AcpClient {
11901192
const connection = this.getConnection();
11911193
const { command, args } = splitCommandLine(this.options.agentCommand);
11921194
const claudeAcp = isClaudeAcpCommand(command, args);
1193-
const codexAcp = isCodexAcpCommand(command, args);
11941195

11951196
let result: Awaited<ReturnType<typeof connection.newSession>>;
11961197
try {
@@ -1215,21 +1216,11 @@ export class AcpClient {
12151216
}
12161217

12171218
this.loadedSessionId = result.sessionId;
1218-
if (
1219-
codexAcp &&
1220-
typeof this.options.sessionOptions?.model === "string" &&
1221-
this.options.sessionOptions.model.trim().length > 0
1222-
) {
1223-
await this.setSessionConfigOption(
1224-
result.sessionId,
1225-
"model",
1226-
this.options.sessionOptions.model,
1227-
);
1228-
}
12291219

12301220
return {
12311221
sessionId: result.sessionId,
12321222
agentSessionId: extractRuntimeSessionId(result._meta),
1223+
models: result.models ?? undefined,
12331224
};
12341225
}
12351226

@@ -1271,8 +1262,10 @@ export class AcpClient {
12711262
}
12721263

12731264
this.loadedSessionId = sessionId;
1265+
12741266
return {
12751267
agentSessionId: extractRuntimeSessionId(response?._meta),
1268+
models: response?.models ?? undefined,
12761269
};
12771270
}
12781271

@@ -1360,6 +1353,41 @@ export class AcpClient {
13601353
}
13611354
}
13621355

1356+
async setSessionModel(sessionId: string, modelId: string): Promise<void> {
1357+
const connection = this.getConnection();
1358+
try {
1359+
await this.runConnectionRequest(() =>
1360+
connection.unstable_setSessionModel({
1361+
sessionId,
1362+
modelId,
1363+
}),
1364+
);
1365+
} catch (error) {
1366+
const wrapped = maybeWrapSessionControlError(
1367+
"session/set_model",
1368+
error,
1369+
`for model "${modelId}"`,
1370+
);
1371+
if (wrapped !== error) {
1372+
throw wrapped;
1373+
}
1374+
const acp = extractAcpError(error);
1375+
const summary = acp
1376+
? formatSessionControlAcpSummary(acp)
1377+
: error instanceof Error
1378+
? error.message
1379+
: String(error);
1380+
if (error instanceof Error) {
1381+
throw new Error(`Failed session/set_model for model "${modelId}": ${summary}`, {
1382+
cause: error,
1383+
});
1384+
}
1385+
throw new Error(`Failed session/set_model for model "${modelId}": ${summary}`, {
1386+
cause: error,
1387+
});
1388+
}
1389+
}
1390+
13631391
async cancel(sessionId: string): Promise<void> {
13641392
const connection = this.getConnection();
13651393
this.cancellingSessionIds.add(sessionId);

src/errors.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,17 @@ export class SessionModeReplayError extends AcpxOperationalError {
111111
}
112112
}
113113

114+
export class SessionModelReplayError extends AcpxOperationalError {
115+
constructor(message: string, options?: AcpxErrorOptions) {
116+
super(message, {
117+
outputCode: "RUNTIME",
118+
detailCode: "SESSION_MODEL_REPLAY_FAILED",
119+
origin: "acp",
120+
...options,
121+
});
122+
}
123+
}
124+
114125
export class ClaudeAcpSessionCreateTimeoutError extends AcpxOperationalError {
115126
constructor(message: string, options?: AcpxErrorOptions) {
116127
super(message, {

src/queue-ipc-server.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ export type QueueTask = {
9292
export type QueueOwnerControlHandlers = {
9393
cancelPrompt: () => Promise<boolean>;
9494
setSessionMode: (modeId: string, timeoutMs?: number) => Promise<void>;
95+
setSessionModel: (modelId: string, timeoutMs?: number) => Promise<void>;
9596
setSessionConfigOption: (
9697
configId: string,
9798
value: string,
@@ -408,6 +409,22 @@ export class SessionQueueOwner {
408409
return;
409410
}
410411

412+
if (request.type === "set_model") {
413+
this.handleControlRequest({
414+
socket,
415+
requestId: request.requestId,
416+
run: async () => {
417+
await this.controlHandlers.setSessionModel(request.modelId, request.timeoutMs);
418+
return {
419+
type: "set_model_result",
420+
requestId: request.requestId,
421+
modelId: request.modelId,
422+
};
423+
},
424+
});
425+
return;
426+
}
427+
411428
if (request.type === "set_config_option") {
412429
this.handleControlRequest({
413430
socket,

0 commit comments

Comments
 (0)