feat: add configuration for model, mode, and thought level

This commit is contained in:
Nathan Flurry 2026-03-03 14:43:55 -08:00
parent c3a95c3611
commit 6d319a1c3e
16 changed files with 1419 additions and 67 deletions

View file

@ -86,6 +86,8 @@
- Regenerate `docs/openapi.json` when HTTP contracts change. - Regenerate `docs/openapi.json` when HTTP contracts change.
- Keep `docs/inspector.mdx` and `docs/sdks/typescript.mdx` aligned with implementation. - Keep `docs/inspector.mdx` and `docs/sdks/typescript.mdx` aligned with implementation.
- Append blockers/decisions to `research/acp/friction.md` during ACP work. - Append blockers/decisions to `research/acp/friction.md` during ACP work.
- `docs/agent-capabilities.mdx` lists models/modes/thought levels per agent. Update it when adding a new agent or changing `fallback_config_options`. If its "Last updated" date is >2 weeks old, re-run `cd scripts/agent-configs && npx tsx dump.ts` and update the doc to match. Source data: `scripts/agent-configs/resources/*.json` and hardcoded entries in `server/packages/sandbox-agent/src/router/support.rs` (`fallback_config_options`).
- Some agent models are gated by subscription (e.g. Claude `opus`). The live report only shows models available to the current credentials. The static doc and JSON resource files should list all known models regardless of subscription tier.
- TypeScript SDK tests should run against a real running server/runtime over real `/v1` HTTP APIs, typically using the real `mock` agent for deterministic behavior. - TypeScript SDK tests should run against a real running server/runtime over real `/v1` HTTP APIs, typically using the real `mock` agent for deterministic behavior.
- Do not use Vitest fetch/transport mocks to simulate server functionality in TypeScript SDK tests. - Do not use Vitest fetch/transport mocks to simulate server functionality in TypeScript SDK tests.

View file

@ -0,0 +1,96 @@
---
title: "Agent Capabilities"
description: "Models, modes, and thought levels supported by each agent."
---
Capabilities are subject to change as the agents are updated. See [Agent Sessions](/agent-sessions) for full session configuration API details.
<Info>
_Last updated: March 3rd, 2026. See [Generating a live report](#generating-a-live-report) for up-to-date reference._
</Info>
## Claude
| Category | Values |
|----------|--------|
| **Models** | `default`, `sonnet`, `opus`, `haiku` |
| **Modes** | `default`, `acceptEdits`, `plan`, `dontAsk`, `bypassPermissions` |
| **Thought levels** | Unsupported |
## Codex
| Category | Values |
|----------|--------|
| **Models** | `gpt-5.3-codex` (default), `gpt-5.3-codex-spark`, `gpt-5.2-codex`, `gpt-5.1-codex-max`, `gpt-5.2`, `gpt-5.1-codex-mini` |
| **Modes** | `read-only` (default), `auto`, `full-access` |
| **Thought levels** | `low`, `medium`, `high` (default), `xhigh` |
## OpenCode
| Category | Values |
|----------|--------|
| **Models** | See below |
| **Modes** | `build` (default), `plan` |
| **Thought levels** | Unsupported |
<Accordion title="See all models">
| Provider | Models |
|----------|--------|
| **Anthropic** | `anthropic/claude-3-5-haiku-20241022`, `anthropic/claude-3-5-haiku-latest`, `anthropic/claude-3-5-sonnet-20240620`, `anthropic/claude-3-5-sonnet-20241022`, `anthropic/claude-3-7-sonnet-20250219`, `anthropic/claude-3-7-sonnet-latest`, `anthropic/claude-3-haiku-20240307`, `anthropic/claude-3-opus-20240229`, `anthropic/claude-3-sonnet-20240229`, `anthropic/claude-haiku-4-5`, `anthropic/claude-haiku-4-5-20251001`, `anthropic/claude-opus-4-0`, `anthropic/claude-opus-4-1`, `anthropic/claude-opus-4-1-20250805`, `anthropic/claude-opus-4-20250514`, `anthropic/claude-opus-4-5`, `anthropic/claude-opus-4-5-20251101`, `anthropic/claude-opus-4-6`, `anthropic/claude-sonnet-4-0`, `anthropic/claude-sonnet-4-20250514`, `anthropic/claude-sonnet-4-5`, `anthropic/claude-sonnet-4-5-20250929` |
| **OpenAI** | `openai/gpt-5.1-codex`, `openai/gpt-5.1-codex-max`, `openai/gpt-5.1-codex-mini`, `openai/gpt-5.2`, `openai/gpt-5.2-codex`, `openai/gpt-5.3-codex` |
| **Cerebras** | `cerebras/gpt-oss-120b`, `cerebras/qwen-3-235b-a22b-instruct-2507`, `cerebras/zai-glm-4.7` |
| **OpenCode Zen** | `opencode/big-pickle`, `opencode/claude-3-5-haiku`, `opencode/claude-haiku-4-5`, `opencode/claude-opus-4-1`, `opencode/claude-opus-4-5`, `opencode/claude-opus-4-6`, `opencode/claude-sonnet-4`, `opencode/claude-sonnet-4-5`, `opencode/gemini-3-flash`, `opencode/gemini-3-pro` (default), `opencode/glm-4.6`, `opencode/glm-4.7`, `opencode/gpt-5`, `opencode/gpt-5-codex`, `opencode/gpt-5-nano`, `opencode/gpt-5.1`, `opencode/gpt-5.1-codex`, `opencode/gpt-5.1-codex-max`, `opencode/gpt-5.1-codex-mini`, `opencode/gpt-5.2`, `opencode/gpt-5.2-codex`, `opencode/kimi-k2`, `opencode/kimi-k2-thinking`, `opencode/kimi-k2.5`, `opencode/kimi-k2.5-free`, `opencode/minimax-m2.1`, `opencode/minimax-m2.1-free`, `opencode/trinity-large-preview-free` |
</Accordion>
## Cursor
| Category | Values |
|----------|--------|
| **Models** | See below |
| **Modes** | Unsupported |
| **Thought levels** | Unsupported |
<Accordion title="See all models">
| Group | Models |
|-------|--------|
| **Auto** | `auto` |
| **Composer** | `composer-1.5`, `composer-1` |
| **GPT-5.3 Codex** | `gpt-5.3-codex`, `gpt-5.3-codex-low`, `gpt-5.3-codex-high`, `gpt-5.3-codex-xhigh`, `gpt-5.3-codex-fast`, `gpt-5.3-codex-low-fast`, `gpt-5.3-codex-high-fast`, `gpt-5.3-codex-xhigh-fast` |
| **GPT-5.2** | `gpt-5.2`, `gpt-5.2-high`, `gpt-5.2-codex`, `gpt-5.2-codex-low`, `gpt-5.2-codex-high`, `gpt-5.2-codex-xhigh`, `gpt-5.2-codex-fast`, `gpt-5.2-codex-low-fast`, `gpt-5.2-codex-high-fast`, `gpt-5.2-codex-xhigh-fast` |
| **GPT-5.1** | `gpt-5.1-high`, `gpt-5.1-codex-max`, `gpt-5.1-codex-max-high` |
| **Claude** | `opus-4.6-thinking` (default), `opus-4.6`, `opus-4.5`, `opus-4.5-thinking`, `sonnet-4.5`, `sonnet-4.5-thinking` |
| **Other** | `gemini-3-pro`, `gemini-3-flash`, `grok` |
</Accordion>
## Amp
| Category | Values |
|----------|--------|
| **Models** | `amp-default` |
| **Modes** | `default`, `bypass` |
| **Thought levels** | Unsupported |
## Pi
| Category | Values |
|----------|--------|
| **Models** | `default` |
| **Modes** | Unsupported |
| **Thought levels** | Unsupported |
## Generating a live report
Requires a running Sandbox Agent server. `--endpoint` defaults to `http://127.0.0.1:2468`.
```bash
sandbox-agent api agents report
```
<Note>
The live report reflects what the agent's ACP adapter returns for the current credentials. Some models may be gated by subscription (e.g. Claude's `opus` requires a paid plan) and will not appear in the report if the credentials don't have access.
</Note>

View file

@ -82,6 +82,49 @@ if (sessions.items.length > 0) {
} }
``` ```
## Configure model, mode, and thought level
Set the model, mode, or thought level on a session at creation time or after:
```ts
// At creation time
const session = await sdk.createSession({
agent: "codex",
model: "gpt-5.3-codex",
mode: "plan",
thoughtLevel: "high",
});
```
```ts
// After creation
await session.setModel("gpt-5.2-codex");
await session.setMode("build");
await session.setThoughtLevel("medium");
```
Query available modes:
```ts
const modes = await session.getModes();
console.log(modes?.currentModeId, modes?.availableModes);
```
### Advanced config options
For config options beyond model, mode, and thought level, use `getConfigOptions` to discover what the agent supports and `setConfigOption` to set any option by ID:
```ts
const options = await session.getConfigOptions();
for (const opt of options) {
console.log(opt.id, opt.category, opt.type);
}
```
```ts
await session.setConfigOption("some-agent-option", "value");
```
## Destroy a session ## Destroy a session
```ts ```ts

View file

@ -167,6 +167,56 @@ Shared option:
```bash ```bash
sandbox-agent api agents list [--endpoint <URL>] sandbox-agent api agents list [--endpoint <URL>]
sandbox-agent api agents report [--endpoint <URL>]
sandbox-agent api agents install <AGENT> [--reinstall] [--endpoint <URL>] sandbox-agent api agents install <AGENT> [--reinstall] [--endpoint <URL>]
``` ```
#### api agents list
List all agents and their install status.
```bash
sandbox-agent api agents list
```
#### api agents report
Emit a JSON report of available models, modes, and thought levels for every agent. Calls `GET /v1/agents?config=true` and groups each agent's config options by category.
```bash
sandbox-agent api agents report --endpoint http://127.0.0.1:2468 | jq .
```
Example output:
```json
{
"generatedAtMs": 1740000000000,
"endpoint": "http://127.0.0.1:2468",
"agents": [
{
"id": "claude",
"installed": true,
"models": {
"currentValue": "default",
"values": [
{ "value": "default", "name": "Default (recommended)" },
{ "value": "opus", "name": "Opus" },
{ "value": "sonnet", "name": "Sonnet" },
{ "value": "haiku", "name": "Haiku" }
]
},
"modes": { "values": [] },
"thoughtLevels": { "values": [] }
}
]
}
```
See [Agent Capabilities](/agent-capabilities) for a full reference of supported models, modes, and thought levels per agent.
#### api agents install
```bash
sandbox-agent api agents install codex --reinstall
```

View file

@ -94,6 +94,7 @@
{ {
"group": "Reference", "group": "Reference",
"pages": [ "pages": [
"agent-capabilities",
"cli", "cli",
"inspector", "inspector",
"opencode-compatibility", "opencode-compatibility",

View file

@ -100,6 +100,25 @@ await restored.prompt([{ type: "text", text: "Continue from previous context." }
await sdk.destroySession(restored.id); await sdk.destroySession(restored.id);
``` ```
## Session configuration
Set model, mode, or thought level at creation or on an existing session:
```ts
const session = await sdk.createSession({
agent: "codex",
model: "gpt-5.3-codex",
});
await session.setModel("gpt-5.2-codex");
await session.setMode("plan");
const options = await session.getConfigOptions();
const modes = await session.getModes();
```
See [Agent Sessions](/agent-sessions) for full details on config options and error handling.
## Events ## Events
Subscribe to live events: Subscribe to live events:
@ -171,13 +190,3 @@ Parameters:
- `headers` (optional): Additional request headers - `headers` (optional): Additional request headers
- `fetch` (optional): Custom fetch implementation used by SDK HTTP and ACP calls - `fetch` (optional): Custom fetch implementation used by SDK HTTP and ACP calls
## Types
```ts
import type {
AgentInfo,
HealthResponse,
SessionEvent,
SessionRecord,
} from "sandbox-agent";
```

View file

@ -1,5 +1,6 @@
/** /**
* Fetches model/mode lists from agent backends and writes them to resources/. * Fetches model/mode/thought-level lists from agent backends and writes them
* to resources/.
* *
* Usage: * Usage:
* npx tsx dump.ts # Dump all agents * npx tsx dump.ts # Dump all agents
@ -10,11 +11,24 @@
* Claude Anthropic API (GET /v1/models?beta=true). Extracts API key from * Claude Anthropic API (GET /v1/models?beta=true). Extracts API key from
* ANTHROPIC_API_KEY env. Falls back to aliases (default, sonnet, opus, haiku) * ANTHROPIC_API_KEY env. Falls back to aliases (default, sonnet, opus, haiku)
* on 401/403 or missing credentials. * on 401/403 or missing credentials.
* Modes are hardcoded (discovered by ACP session/set_mode probing).
* Claude does not implement session/set_config_option at all.
* Codex Codex app-server JSON-RPC (model/list over stdio, paginated). * Codex Codex app-server JSON-RPC (model/list over stdio, paginated).
* Modes and thought levels are hardcoded (discovered from Codex's
* ACP session/new configOptions response).
* OpenCode OpenCode HTTP server (GET {base_url}/config/providers, fallback /provider). * OpenCode OpenCode HTTP server (GET {base_url}/config/providers, fallback /provider).
* Model IDs formatted as {provider_id}/{model_id}. * Model IDs formatted as {provider_id}/{model_id}. Modes hardcoded.
* Cursor `cursor-agent models` CLI command. Parses the text output. * Cursor `cursor-agent models` CLI command. Parses the text output.
* *
* Derivation of hardcoded values:
* When agents don't expose modes/thought levels through their model listing
* APIs, we discover them by ACP probing against a running sandbox-agent server:
* 1. Create an ACP session via session/new and inspect the configOptions and
* modes fields in the response.
* 2. Test session/set_mode with candidate mode IDs.
* 3. Test session/set_config_option with candidate config IDs and values.
* See /tmp/probe-agents.sh or /tmp/probe-agents.ts for example probe scripts.
*
* Output goes to resources/ alongside this script. These JSON files are committed * Output goes to resources/ alongside this script. These JSON files are committed
* to the repo and included in the sandbox-agent binary at compile time via include_str!. * to the repo and included in the sandbox-agent binary at compile time via include_str!.
*/ */
@ -37,11 +51,19 @@ interface ModeEntry {
description?: string; description?: string;
} }
interface ThoughtLevelEntry {
id: string;
name: string;
description?: string;
}
interface AgentModelList { interface AgentModelList {
defaultModel: string; defaultModel: string;
models: ModelEntry[]; models: ModelEntry[];
defaultMode?: string; defaultMode?: string;
modes?: ModeEntry[]; modes?: ModeEntry[];
defaultThoughtLevel?: string;
thoughtLevels?: ThoughtLevelEntry[];
} }
// ─── CLI ────────────────────────────────────────────────────────────────────── // ─── CLI ──────────────────────────────────────────────────────────────────────
@ -100,8 +122,13 @@ function writeList(agent: string, list: AgentModelList) {
const filePath = path.join(RESOURCES_DIR, `${agent}.json`); const filePath = path.join(RESOURCES_DIR, `${agent}.json`);
fs.writeFileSync(filePath, JSON.stringify(list, null, 2) + "\n"); fs.writeFileSync(filePath, JSON.stringify(list, null, 2) + "\n");
const modeCount = list.modes?.length ?? 0; const modeCount = list.modes?.length ?? 0;
const thoughtCount = list.thoughtLevels?.length ?? 0;
const extras = [
modeCount ? `${modeCount} modes` : null,
thoughtCount ? `${thoughtCount} thought levels` : null,
].filter(Boolean).join(", ");
console.log( console.log(
` Wrote ${list.models.length} models${modeCount ? `, ${modeCount} modes` : ""} to ${filePath} (default: ${list.defaultModel})` ` Wrote ${list.models.length} models${extras ? `, ${extras}` : ""} to ${filePath} (default: ${list.defaultModel})`
); );
} }
@ -110,14 +137,28 @@ function writeList(agent: string, list: AgentModelList) {
const ANTHROPIC_API_URL = "https://api.anthropic.com/v1/models?beta=true"; const ANTHROPIC_API_URL = "https://api.anthropic.com/v1/models?beta=true";
const ANTHROPIC_VERSION = "2023-06-01"; const ANTHROPIC_VERSION = "2023-06-01";
// Claude v0.20.0 (@zed-industries/claude-agent-acp) returns configOptions and
// modes from session/new. Models and modes below match the ACP adapter source.
// Note: `opus` is gated by subscription — it may not appear in session/new for
// all credentials, but exists in the SDK model list. Thought levels are supported
// by the Claude SDK (effort levels: low/medium/high/max for opus-4-6 and
// sonnet-4-6) but the ACP adapter does not expose them as configOptions yet.
const CLAUDE_FALLBACK: AgentModelList = { const CLAUDE_FALLBACK: AgentModelList = {
defaultModel: "default", defaultModel: "default",
models: [ models: [
{ id: "default", name: "Default (recommended)" }, { id: "default", name: "Default" },
{ id: "opus", name: "Opus" },
{ id: "sonnet", name: "Sonnet" }, { id: "sonnet", name: "Sonnet" },
{ id: "opus", name: "Opus" },
{ id: "haiku", name: "Haiku" }, { id: "haiku", name: "Haiku" },
], ],
defaultMode: "default",
modes: [
{ id: "default", name: "Default" },
{ id: "acceptEdits", name: "Accept Edits" },
{ id: "plan", name: "Plan" },
{ id: "dontAsk", name: "Don't Ask" },
{ id: "bypassPermissions", name: "Bypass Permissions" },
],
}; };
async function dumpClaude() { async function dumpClaude() {
@ -185,6 +226,9 @@ async function dumpClaude() {
writeList("claude", { writeList("claude", {
defaultModel: defaultModel ?? models[0]?.id ?? "default", defaultModel: defaultModel ?? models[0]?.id ?? "default",
models, models,
// Modes from Claude ACP adapter v0.20.0 session/new response.
defaultMode: "default",
modes: CLAUDE_FALLBACK.modes,
}); });
} }
@ -277,9 +321,26 @@ async function dumpCodex() {
models.sort((a, b) => a.id.localeCompare(b.id)); models.sort((a, b) => a.id.localeCompare(b.id));
// Codex modes and thought levels come from its ACP session/new configOptions
// response (category: "mode" and category: "thought_level"). The model/list
// RPC only returns models, so modes/thought levels are hardcoded here based
// on probing Codex's session/new response.
writeList("codex", { writeList("codex", {
defaultModel: defaultModel ?? models[0]?.id ?? "", defaultModel: defaultModel ?? models[0]?.id ?? "",
models, models,
defaultMode: "read-only",
modes: [
{ id: "read-only", name: "Read Only", description: "Codex can read files in the current workspace. Approval is required to edit files or access the internet." },
{ id: "auto", name: "Default", description: "Codex can read and edit files in the current workspace, and run commands. Approval is required to access the internet or edit other files." },
{ id: "full-access", name: "Full Access", description: "Codex can edit files outside this workspace and access the internet without asking for approval." },
],
defaultThoughtLevel: "high",
thoughtLevels: [
{ id: "low", name: "Low", description: "Fast responses with lighter reasoning" },
{ id: "medium", name: "Medium", description: "Balances speed and reasoning depth for everyday tasks" },
{ id: "high", name: "High", description: "Greater reasoning depth for complex problems" },
{ id: "xhigh", name: "Xhigh", description: "Extra high reasoning depth for complex problems" },
],
}); });
} }

View file

@ -3,19 +3,42 @@
"models": [ "models": [
{ {
"id": "default", "id": "default",
"name": "Default (recommended)" "name": "Default"
},
{
"id": "opus",
"name": "Opus"
}, },
{ {
"id": "sonnet", "id": "sonnet",
"name": "Sonnet" "name": "Sonnet"
}, },
{
"id": "opus",
"name": "Opus"
},
{ {
"id": "haiku", "id": "haiku",
"name": "Haiku" "name": "Haiku"
} }
],
"defaultMode": "default",
"modes": [
{
"id": "default",
"name": "Default"
},
{
"id": "acceptEdits",
"name": "Accept Edits"
},
{
"id": "plan",
"name": "Plan"
},
{
"id": "dontAsk",
"name": "Don't Ask"
},
{
"id": "bypassPermissions",
"name": "Bypass Permissions"
}
] ]
} }

View file

@ -2,24 +2,62 @@
"defaultModel": "gpt-5.3-codex", "defaultModel": "gpt-5.3-codex",
"models": [ "models": [
{ {
"id": "gpt-5.1-codex-max", "id": "gpt-5.3-codex",
"name": "gpt-5.1-codex-max" "name": "gpt-5.3-codex"
}, },
{ {
"id": "gpt-5.1-codex-mini", "id": "gpt-5.3-codex-spark",
"name": "gpt-5.1-codex-mini" "name": "GPT-5.3-Codex-Spark"
},
{
"id": "gpt-5.2",
"name": "gpt-5.2"
}, },
{ {
"id": "gpt-5.2-codex", "id": "gpt-5.2-codex",
"name": "gpt-5.2-codex" "name": "gpt-5.2-codex"
}, },
{ {
"id": "gpt-5.3-codex", "id": "gpt-5.1-codex-max",
"name": "gpt-5.3-codex" "name": "gpt-5.1-codex-max"
},
{
"id": "gpt-5.2",
"name": "gpt-5.2"
},
{
"id": "gpt-5.1-codex-mini",
"name": "gpt-5.1-codex-mini"
}
],
"defaultMode": "read-only",
"modes": [
{
"id": "read-only",
"name": "Read Only"
},
{
"id": "auto",
"name": "Default"
},
{
"id": "full-access",
"name": "Full Access"
}
],
"defaultThoughtLevel": "high",
"thoughtLevels": [
{
"id": "low",
"name": "Low"
},
{
"id": "medium",
"name": "Medium"
},
{
"id": "high",
"name": "High"
},
{
"id": "xhigh",
"name": "Xhigh"
} }
] ]
} }

View file

@ -1,5 +1,6 @@
import { import {
AcpHttpClient, AcpHttpClient,
AcpRpcError,
PROTOCOL_VERSION, PROTOCOL_VERSION,
type AcpEnvelopeDirection, type AcpEnvelopeDirection,
type AnyMessage, type AnyMessage,
@ -9,8 +10,12 @@ import {
type NewSessionResponse, type NewSessionResponse,
type PromptRequest, type PromptRequest,
type PromptResponse, type PromptResponse,
type SessionConfigOption,
type SessionNotification, type SessionNotification,
type SessionModeState,
type SetSessionConfigOptionResponse,
type SetSessionConfigOptionRequest, type SetSessionConfigOptionRequest,
type SetSessionModeResponse,
type SetSessionModeRequest, type SetSessionModeRequest,
} from "acp-http-client"; } from "acp-http-client";
import type { SandboxAgentSpawnHandle, SandboxAgentSpawnOptions } from "./spawn.ts"; import type { SandboxAgentSpawnHandle, SandboxAgentSpawnOptions } from "./spawn.ts";
@ -53,6 +58,9 @@ const DEFAULT_BASE_URL = "http://sandbox-agent";
const DEFAULT_REPLAY_MAX_EVENTS = 50; const DEFAULT_REPLAY_MAX_EVENTS = 50;
const DEFAULT_REPLAY_MAX_CHARS = 12_000; const DEFAULT_REPLAY_MAX_CHARS = 12_000;
const EVENT_INDEX_SCAN_EVENTS_LIMIT = 500; const EVENT_INDEX_SCAN_EVENTS_LIMIT = 500;
const SESSION_CANCEL_METHOD = "session/cancel";
const MANUAL_CANCEL_ERROR =
"Manual session/cancel calls are not allowed. Use destroySession(sessionId) instead.";
interface SandboxAgentConnectCommonOptions { interface SandboxAgentConnectCommonOptions {
headers?: HeadersInit; headers?: HeadersInit;
@ -85,12 +93,18 @@ export interface SessionCreateRequest {
id?: string; id?: string;
agent: string; agent: string;
sessionInit?: Omit<NewSessionRequest, "_meta">; sessionInit?: Omit<NewSessionRequest, "_meta">;
model?: string;
mode?: string;
thoughtLevel?: string;
} }
export interface SessionResumeOrCreateRequest { export interface SessionResumeOrCreateRequest {
id: string; id: string;
agent: string; agent: string;
sessionInit?: Omit<NewSessionRequest, "_meta">; sessionInit?: Omit<NewSessionRequest, "_meta">;
model?: string;
mode?: string;
thoughtLevel?: string;
} }
export interface SessionSendOptions { export interface SessionSendOptions {
@ -113,6 +127,64 @@ export class SandboxAgentError extends Error {
} }
} }
export class UnsupportedSessionCategoryError extends Error {
readonly sessionId: string;
readonly category: string;
readonly availableCategories: string[];
constructor(sessionId: string, category: string, availableCategories: string[]) {
super(
`Session '${sessionId}' does not support category '${category}'. Available categories: ${availableCategories.join(", ") || "(none)"}`,
);
this.name = "UnsupportedSessionCategoryError";
this.sessionId = sessionId;
this.category = category;
this.availableCategories = availableCategories;
}
}
export class UnsupportedSessionValueError extends Error {
readonly sessionId: string;
readonly category: string;
readonly configId: string;
readonly requestedValue: string;
readonly allowedValues: string[];
constructor(
sessionId: string,
category: string,
configId: string,
requestedValue: string,
allowedValues: string[],
) {
super(
`Session '${sessionId}' does not support value '${requestedValue}' for category '${category}' (configId='${configId}'). Allowed values: ${allowedValues.join(", ") || "(none)"}`,
);
this.name = "UnsupportedSessionValueError";
this.sessionId = sessionId;
this.category = category;
this.configId = configId;
this.requestedValue = requestedValue;
this.allowedValues = allowedValues;
}
}
export class UnsupportedSessionConfigOptionError extends Error {
readonly sessionId: string;
readonly configId: string;
readonly availableConfigIds: string[];
constructor(sessionId: string, configId: string, availableConfigIds: string[]) {
super(
`Session '${sessionId}' does not expose config option '${configId}'. Available configIds: ${availableConfigIds.join(", ") || "(none)"}`,
);
this.name = "UnsupportedSessionConfigOptionError";
this.sessionId = sessionId;
this.configId = configId;
this.availableConfigIds = availableConfigIds;
}
}
export class Session { export class Session {
private record: SessionRecord; private record: SessionRecord;
private readonly sandbox: SandboxAgent; private readonly sandbox: SandboxAgent;
@ -166,6 +238,38 @@ export class Session {
return response as PromptResponse; return response as PromptResponse;
} }
async setMode(modeId: string): Promise<SetSessionModeResponse | void> {
const updated = await this.sandbox.setSessionMode(this.id, modeId);
this.apply(updated.session.toRecord());
return updated.response;
}
async setConfigOption(configId: string, value: string): Promise<SetSessionConfigOptionResponse> {
const updated = await this.sandbox.setSessionConfigOption(this.id, configId, value);
this.apply(updated.session.toRecord());
return updated.response;
}
async setModel(model: string): Promise<SetSessionConfigOptionResponse> {
const updated = await this.sandbox.setSessionModel(this.id, model);
this.apply(updated.session.toRecord());
return updated.response;
}
async setThoughtLevel(thoughtLevel: string): Promise<SetSessionConfigOptionResponse> {
const updated = await this.sandbox.setSessionThoughtLevel(this.id, thoughtLevel);
this.apply(updated.session.toRecord());
return updated.response;
}
async getConfigOptions(): Promise<SessionConfigOption[]> {
return this.sandbox.getSessionConfigOptions(this.id);
}
async getModes(): Promise<SessionModeState | null> {
return this.sandbox.getSessionModes(this.id);
}
onEvent(listener: SessionEventListener): () => void { onEvent(listener: SessionEventListener): () => void {
return this.sandbox.onSessionEvent(this.id, listener); return this.sandbox.onSessionEvent(this.id, listener);
} }
@ -566,12 +670,26 @@ export class SandboxAgent {
lastConnectionId: live.connectionId, lastConnectionId: live.connectionId,
createdAt: nowMs(), createdAt: nowMs(),
sessionInit, sessionInit,
configOptions: cloneConfigOptions(response.configOptions),
modes: cloneModes(response.modes),
}; };
await this.persist.updateSession(record); await this.persist.updateSession(record);
this.nextSessionEventIndexBySession.set(record.id, 1); this.nextSessionEventIndexBySession.set(record.id, 1);
live.bindSession(record.id, record.agentSessionId); live.bindSession(record.id, record.agentSessionId);
return this.upsertSessionHandle(record); let session = this.upsertSessionHandle(record);
if (request.mode) {
session = (await this.setSessionMode(session.id, request.mode)).session;
}
if (request.model) {
session = (await this.setSessionModel(session.id, request.model)).session;
}
if (request.thoughtLevel) {
session = (await this.setSessionThoughtLevel(session.id, request.thoughtLevel)).session;
}
return session;
} }
async resumeSession(id: string): Promise<Session> { async resumeSession(id: string): Promise<Session> {
@ -595,6 +713,8 @@ export class SandboxAgent {
agentSessionId: recreated.sessionId, agentSessionId: recreated.sessionId,
lastConnectionId: live.connectionId, lastConnectionId: live.connectionId,
destroyedAt: undefined, destroyedAt: undefined,
configOptions: cloneConfigOptions(recreated.configOptions),
modes: cloneModes(recreated.modes),
}; };
await this.persist.updateSession(updated); await this.persist.updateSession(updated);
@ -607,16 +727,24 @@ export class SandboxAgent {
async resumeOrCreateSession(request: SessionResumeOrCreateRequest): Promise<Session> { async resumeOrCreateSession(request: SessionResumeOrCreateRequest): Promise<Session> {
const existing = await this.persist.getSession(request.id); const existing = await this.persist.getSession(request.id);
if (existing) { if (existing) {
return this.resumeSession(existing.id); let session = await this.resumeSession(existing.id);
if (request.mode) {
session = (await this.setSessionMode(session.id, request.mode)).session;
}
if (request.model) {
session = (await this.setSessionModel(session.id, request.model)).session;
}
if (request.thoughtLevel) {
session = (await this.setSessionThoughtLevel(session.id, request.thoughtLevel)).session;
}
return session;
} }
return this.createSession(request); return this.createSession(request);
} }
async destroySession(id: string): Promise<Session> { async destroySession(id: string): Promise<Session> {
const existing = await this.persist.getSession(id); await this.sendSessionMethodInternal(id, SESSION_CANCEL_METHOD, {}, {}, true);
if (!existing) { const existing = await this.requireSessionRecord(id);
throw new Error(`session '${id}' not found`);
}
const updated: SessionRecord = { const updated: SessionRecord = {
...existing, ...existing,
@ -627,12 +755,175 @@ export class SandboxAgent {
return this.upsertSessionHandle(updated); return this.upsertSessionHandle(updated);
} }
async setSessionMode(
sessionId: string,
modeId: string,
): Promise<{ session: Session; response: SetSessionModeResponse | void }> {
const mode = modeId.trim();
if (!mode) {
throw new Error("setSessionMode requires a non-empty modeId");
}
const record = await this.requireSessionRecord(sessionId);
const knownModeIds = extractKnownModeIds(record.modes);
if (knownModeIds.length > 0 && !knownModeIds.includes(mode)) {
throw new UnsupportedSessionValueError(sessionId, "mode", "mode", mode, knownModeIds);
}
try {
return (await this.sendSessionMethodInternal(
sessionId,
"session/set_mode",
{ modeId: mode },
{},
false,
)) as { session: Session; response: SetSessionModeResponse | void };
} catch (error) {
if (!(error instanceof AcpRpcError) || error.code !== -32601) {
throw error;
}
return this.setSessionCategoryValue(sessionId, "mode", mode);
}
}
async setSessionConfigOption(
sessionId: string,
configId: string,
value: string,
): Promise<{ session: Session; response: SetSessionConfigOptionResponse }> {
const resolvedConfigId = configId.trim();
if (!resolvedConfigId) {
throw new Error("setSessionConfigOption requires a non-empty configId");
}
const resolvedValue = value.trim();
if (!resolvedValue) {
throw new Error("setSessionConfigOption requires a non-empty value");
}
const options = await this.getSessionConfigOptions(sessionId);
const option = findConfigOptionById(options, resolvedConfigId);
if (!option) {
throw new UnsupportedSessionConfigOptionError(
sessionId,
resolvedConfigId,
options.map((item) => item.id),
);
}
const allowedValues = extractConfigValues(option);
if (allowedValues.length > 0 && !allowedValues.includes(resolvedValue)) {
throw new UnsupportedSessionValueError(
sessionId,
option.category ?? "uncategorized",
option.id,
resolvedValue,
allowedValues,
);
}
return (await this.sendSessionMethodInternal(
sessionId,
"session/set_config_option",
{
configId: resolvedConfigId,
value: resolvedValue,
},
{},
false,
)) as { session: Session; response: SetSessionConfigOptionResponse };
}
async setSessionModel(
sessionId: string,
model: string,
): Promise<{ session: Session; response: SetSessionConfigOptionResponse }> {
return this.setSessionCategoryValue(sessionId, "model", model);
}
async setSessionThoughtLevel(
sessionId: string,
thoughtLevel: string,
): Promise<{ session: Session; response: SetSessionConfigOptionResponse }> {
return this.setSessionCategoryValue(sessionId, "thought_level", thoughtLevel);
}
async getSessionConfigOptions(sessionId: string): Promise<SessionConfigOption[]> {
const record = await this.requireSessionRecord(sessionId);
const hydrated = await this.hydrateSessionConfigOptions(record);
return cloneConfigOptions(hydrated.configOptions) ?? [];
}
async getSessionModes(sessionId: string): Promise<SessionModeState | null> {
const record = await this.requireSessionRecord(sessionId);
return cloneModes(record.modes);
}
private async setSessionCategoryValue(
sessionId: string,
category: string,
value: string,
): Promise<{ session: Session; response: SetSessionConfigOptionResponse }> {
const resolvedValue = value.trim();
if (!resolvedValue) {
throw new Error(`setSession${toTitleCase(category)} requires a non-empty value`);
}
const options = await this.getSessionConfigOptions(sessionId);
const option = findConfigOptionByCategory(options, category);
if (!option) {
const categories = uniqueCategories(options);
throw new UnsupportedSessionCategoryError(sessionId, category, categories);
}
const allowedValues = extractConfigValues(option);
if (allowedValues.length > 0 && !allowedValues.includes(resolvedValue)) {
throw new UnsupportedSessionValueError(
sessionId,
category,
option.id,
resolvedValue,
allowedValues,
);
}
return this.setSessionConfigOption(sessionId, option.id, resolvedValue);
}
private async hydrateSessionConfigOptions(record: SessionRecord): Promise<SessionRecord> {
if (record.configOptions !== undefined) {
return record;
}
const info = await this.getAgent(record.agent, { config: true });
const configOptions = normalizeSessionConfigOptions(info.configOptions) ?? [];
const updated: SessionRecord = {
...record,
configOptions,
};
await this.persist.updateSession(updated);
return updated;
}
async sendSessionMethod( async sendSessionMethod(
sessionId: string, sessionId: string,
method: string, method: string,
params: Record<string, unknown>, params: Record<string, unknown>,
options: SessionSendOptions = {}, options: SessionSendOptions = {},
): Promise<{ session: Session; response: unknown }> { ): Promise<{ session: Session; response: unknown }> {
return this.sendSessionMethodInternal(sessionId, method, params, options, false);
}
private async sendSessionMethodInternal(
sessionId: string,
method: string,
params: Record<string, unknown>,
options: SessionSendOptions,
allowManagedCancel: boolean,
): Promise<{ session: Session; response: unknown }> {
if (method === SESSION_CANCEL_METHOD && !allowManagedCancel) {
throw new Error(MANUAL_CANCEL_ERROR);
}
const record = await this.persist.getSession(sessionId); const record = await this.persist.getSession(sessionId);
if (!record) { if (!record) {
throw new Error(`session '${sessionId}' not found`); throw new Error(`session '${sessionId}' not found`);
@ -642,10 +933,11 @@ export class SandboxAgent {
if (!live.hasBoundSession(record.id, record.agentSessionId)) { if (!live.hasBoundSession(record.id, record.agentSessionId)) {
// The persisted session points at a stale connection; restore lazily. // The persisted session points at a stale connection; restore lazily.
const restored = await this.resumeSession(record.id); const restored = await this.resumeSession(record.id);
return this.sendSessionMethod(restored.id, method, params, options); return this.sendSessionMethodInternal(restored.id, method, params, options, allowManagedCancel);
} }
const response = await live.sendSessionMethod(record.id, method, params, options); const response = await live.sendSessionMethod(record.id, method, params, options);
await this.persistSessionStateFromMethod(record, method, params, response);
const refreshed = await this.requireSessionRecord(record.id); const refreshed = await this.requireSessionRecord(record.id);
return { return {
session: this.upsertSessionHandle(refreshed), session: this.upsertSessionHandle(refreshed),
@ -653,6 +945,54 @@ export class SandboxAgent {
}; };
} }
private async persistSessionStateFromMethod(
record: SessionRecord,
method: string,
params: Record<string, unknown>,
response: unknown,
): Promise<void> {
if (method === "session/set_config_option") {
const configOptions = extractConfigOptionsFromSetResponse(response);
if (configOptions) {
await this.persist.updateSession({
...record,
configOptions: cloneConfigOptions(configOptions),
});
} else if (record.configOptions) {
// Server didn't return configOptions — optimistically update the
// cached currentValue so subsequent getConfigOptions() reflects the
// change without a round-trip.
const configId = typeof params.configId === "string" ? params.configId : null;
const value = typeof params.value === "string" ? params.value : null;
if (configId && value) {
const updated = applyConfigOptionValue(record.configOptions, configId, value);
if (updated) {
await this.persist.updateSession({
...record,
configOptions: updated,
});
}
}
}
return;
}
if (method === "session/set_mode") {
const modeId = typeof params.modeId === "string" ? params.modeId : null;
if (!modeId) {
return;
}
const nextModes = applyCurrentMode(record.modes, modeId);
if (!nextModes) {
return;
}
await this.persist.updateSession({
...record,
modes: nextModes,
});
}
}
onSessionEvent(sessionId: string, listener: SessionEventListener): () => void { onSessionEvent(sessionId: string, listener: SessionEventListener): () => void {
const listeners = this.eventListeners.get(sessionId) ?? new Set<SessionEventListener>(); const listeners = this.eventListeners.get(sessionId) ?? new Set<SessionEventListener>();
listeners.add(listener); listeners.add(listener);
@ -837,6 +1177,7 @@ export class SandboxAgent {
}; };
await this.persist.insertEvent(event); await this.persist.insertEvent(event);
await this.persistSessionStateFromEvent(localSessionId, envelope, direction);
const listeners = this.eventListeners.get(localSessionId); const listeners = this.eventListeners.get(localSessionId);
if (!listeners || listeners.size === 0) { if (!listeners || listeners.size === 0) {
@ -848,6 +1189,56 @@ export class SandboxAgent {
} }
} }
private async persistSessionStateFromEvent(
sessionId: string,
envelope: AnyMessage,
direction: AcpEnvelopeDirection,
): Promise<void> {
if (direction !== "inbound") {
return;
}
if (envelopeMethod(envelope) !== "session/update") {
return;
}
const update = envelopeSessionUpdate(envelope);
if (!update || typeof update.sessionUpdate !== "string") {
return;
}
const record = await this.persist.getSession(sessionId);
if (!record) {
return;
}
if (update.sessionUpdate === "config_option_update") {
const configOptions = normalizeSessionConfigOptions(update.configOptions);
if (configOptions) {
await this.persist.updateSession({
...record,
configOptions,
});
}
return;
}
if (update.sessionUpdate === "current_mode_update") {
const modeId = typeof update.currentModeId === "string" ? update.currentModeId : null;
if (!modeId) {
return;
}
const nextModes = applyCurrentMode(record.modes, modeId);
if (!nextModes) {
return;
}
await this.persist.updateSession({
...record,
modes: nextModes,
});
}
}
private async allocateSessionEventIndex(sessionId: string): Promise<number> { private async allocateSessionEventIndex(sessionId: string): Promise<number> {
await this.ensureSessionEventIndexSeeded(sessionId); await this.ensureSessionEventIndexSeeded(sessionId);
const nextIndex = this.nextSessionEventIndexBySession.get(sessionId) ?? 1; const nextIndex = this.nextSessionEventIndexBySession.get(sessionId) ?? 1;
@ -1230,3 +1621,142 @@ async function readProblem(response: Response): Promise<ProblemDetails | undefin
return undefined; return undefined;
} }
} }
function normalizeSessionConfigOptions(value: unknown): SessionConfigOption[] | undefined {
if (!Array.isArray(value)) {
return undefined;
}
const normalized = value.filter(isSessionConfigOption) as SessionConfigOption[];
return cloneConfigOptions(normalized) ?? [];
}
function extractConfigOptionsFromSetResponse(response: unknown): SessionConfigOption[] | undefined {
if (!isRecord(response)) {
return undefined;
}
return normalizeSessionConfigOptions(response.configOptions);
}
function findConfigOptionByCategory(
options: SessionConfigOption[],
category: string,
): SessionConfigOption | undefined {
return options.find((option) => option.category === category);
}
function findConfigOptionById(
options: SessionConfigOption[],
configId: string,
): SessionConfigOption | undefined {
return options.find((option) => option.id === configId);
}
function uniqueCategories(options: SessionConfigOption[]): string[] {
return [...new Set(options.map((option) => option.category).filter((value): value is string => !!value))].sort();
}
function extractConfigValues(option: SessionConfigOption): string[] {
if (!isRecord(option) || option.type !== "select" || !Array.isArray(option.options)) {
return [];
}
const values: string[] = [];
for (const entry of option.options as unknown[]) {
if (isRecord(entry) && typeof entry.value === "string") {
values.push(entry.value);
continue;
}
if (isRecord(entry) && Array.isArray(entry.options)) {
for (const nested of entry.options) {
if (isRecord(nested) && typeof nested.value === "string") {
values.push(nested.value);
}
}
}
}
return [...new Set(values)];
}
function extractKnownModeIds(modes: SessionModeState | null | undefined): string[] {
if (!modes || !Array.isArray(modes.availableModes)) {
return [];
}
return modes.availableModes
.map((mode) => (typeof mode.id === "string" ? mode.id : null))
.filter((value): value is string => !!value);
}
function applyCurrentMode(
modes: SessionModeState | null | undefined,
currentModeId: string,
): SessionModeState | null {
if (modes && Array.isArray(modes.availableModes)) {
return {
...modes,
currentModeId,
};
}
return {
currentModeId,
availableModes: [],
};
}
function applyConfigOptionValue(
configOptions: SessionConfigOption[],
configId: string,
value: string,
): SessionConfigOption[] | null {
const idx = configOptions.findIndex((o) => o.id === configId);
if (idx === -1) {
return null;
}
const updated = cloneConfigOptions(configOptions) ?? [];
updated[idx] = { ...updated[idx]!, currentValue: value };
return updated;
}
function envelopeSessionUpdate(message: AnyMessage): Record<string, unknown> | null {
if (!isRecord(message) || !("params" in message) || !isRecord(message.params)) {
return null;
}
if (!("update" in message.params) || !isRecord(message.params.update)) {
return null;
}
return message.params.update;
}
function cloneConfigOptions(value: SessionConfigOption[] | null | undefined): SessionConfigOption[] | undefined {
if (!value) {
return undefined;
}
return JSON.parse(JSON.stringify(value)) as SessionConfigOption[];
}
function cloneModes(value: SessionModeState | null | undefined): SessionModeState | null {
if (!value) {
return null;
}
return JSON.parse(JSON.stringify(value)) as SessionModeState;
}
function isSessionConfigOption(value: unknown): value is SessionConfigOption {
return (
isRecord(value) &&
typeof value.id === "string" &&
typeof value.name === "string" &&
typeof value.type === "string"
);
}
function toTitleCase(input: string): string {
if (!input) {
return "";
}
return input
.split(/[_\s-]+/)
.filter(Boolean)
.map((part) => part[0]!.toUpperCase() + part.slice(1))
.join("");
}

View file

@ -3,6 +3,9 @@ export {
SandboxAgent, SandboxAgent,
SandboxAgentError, SandboxAgentError,
Session, Session,
UnsupportedSessionCategoryError,
UnsupportedSessionConfigOptionError,
UnsupportedSessionValueError,
} from "./client.ts"; } from "./client.ts";
export { AcpRpcError } from "acp-http-client"; export { AcpRpcError } from "acp-http-client";

View file

@ -1,4 +1,9 @@
import type { AnyMessage, NewSessionRequest } from "acp-http-client"; import type {
AnyMessage,
NewSessionRequest,
SessionConfigOption,
SessionModeState,
} from "acp-http-client";
import type { components, operations } from "./generated/openapi.ts"; import type { components, operations } from "./generated/openapi.ts";
export type ProblemDetails = components["schemas"]["ProblemDetails"]; export type ProblemDetails = components["schemas"]["ProblemDetails"];
@ -39,6 +44,8 @@ export interface SessionRecord {
createdAt: number; createdAt: number;
destroyedAt?: number; destroyedAt?: number;
sessionInit?: Omit<NewSessionRequest, "_meta">; sessionInit?: Omit<NewSessionRequest, "_meta">;
configOptions?: SessionConfigOption[];
modes?: SessionModeState | null;
} }
export type SessionEventSender = "client" | "agent"; export type SessionEventSender = "client" | "agent";
@ -178,6 +185,12 @@ function cloneSessionRecord(session: SessionRecord): SessionRecord {
sessionInit: session.sessionInit sessionInit: session.sessionInit
? (JSON.parse(JSON.stringify(session.sessionInit)) as SessionRecord["sessionInit"]) ? (JSON.parse(JSON.stringify(session.sessionInit)) as SessionRecord["sessionInit"])
: undefined, : undefined,
configOptions: session.configOptions
? (JSON.parse(JSON.stringify(session.configOptions)) as SessionRecord["configOptions"])
: undefined,
modes: session.modes
? (JSON.parse(JSON.stringify(session.modes)) as SessionRecord["modes"])
: session.modes,
}; };
} }

View file

@ -254,6 +254,127 @@ describe("Integration: TypeScript SDK flat session API", () => {
await sdk.dispose(); await sdk.dispose();
}); });
it("blocks manual session/cancel and requires destroySession", async () => {
const sdk = await SandboxAgent.connect({
baseUrl,
token,
});
const session = await sdk.createSession({ agent: "mock" });
await expect(session.send("session/cancel")).rejects.toThrow(
"Use destroySession(sessionId) instead.",
);
await expect(sdk.sendSessionMethod(session.id, "session/cancel", {})).rejects.toThrow(
"Use destroySession(sessionId) instead.",
);
const destroyed = await sdk.destroySession(session.id);
expect(destroyed.destroyedAt).toBeDefined();
const reloaded = await sdk.getSession(session.id);
expect(reloaded?.destroyedAt).toBeDefined();
await sdk.dispose();
});
it("supports typed config helpers and createSession preconfiguration", async () => {
const sdk = await SandboxAgent.connect({
baseUrl,
token,
});
const session = await sdk.createSession({
agent: "mock",
model: "mock",
});
const options = await session.getConfigOptions();
expect(options.some((option) => option.category === "model")).toBe(true);
await expect(session.setModel("unknown-model")).rejects.toThrow("does not support value");
await sdk.dispose();
});
it("setModel happy path switches to a valid model", async () => {
const sdk = await SandboxAgent.connect({
baseUrl,
token,
});
const session = await sdk.createSession({ agent: "mock" });
await session.setModel("mock-fast");
const options = await session.getConfigOptions();
const modelOption = options.find((o) => o.category === "model");
expect(modelOption?.currentValue).toBe("mock-fast");
await sdk.dispose();
});
it("setMode happy path switches to a valid mode", async () => {
const sdk = await SandboxAgent.connect({
baseUrl,
token,
});
const session = await sdk.createSession({ agent: "mock" });
await session.setMode("plan");
const modes = await session.getModes();
expect(modes?.currentModeId).toBe("plan");
await sdk.dispose();
});
it("setThoughtLevel happy path switches to a valid thought level", async () => {
const sdk = await SandboxAgent.connect({
baseUrl,
token,
});
const session = await sdk.createSession({ agent: "mock" });
await session.setThoughtLevel("high");
const options = await session.getConfigOptions();
const thoughtOption = options.find((o) => o.category === "thought_level");
expect(thoughtOption?.currentValue).toBe("high");
await sdk.dispose();
});
it("setModel/setMode/setThoughtLevel can be changed multiple times", async () => {
const sdk = await SandboxAgent.connect({
baseUrl,
token,
});
const session = await sdk.createSession({ agent: "mock" });
// Model: mock → mock-fast → mock
await session.setModel("mock-fast");
expect((await session.getConfigOptions()).find((o) => o.category === "model")?.currentValue).toBe("mock-fast");
await session.setModel("mock");
expect((await session.getConfigOptions()).find((o) => o.category === "model")?.currentValue).toBe("mock");
// Mode: normal → plan → normal
await session.setMode("plan");
expect((await session.getModes())?.currentModeId).toBe("plan");
await session.setMode("normal");
expect((await session.getModes())?.currentModeId).toBe("normal");
// Thought level: low → high → medium → low
await session.setThoughtLevel("high");
expect((await session.getConfigOptions()).find((o) => o.category === "thought_level")?.currentValue).toBe("high");
await session.setThoughtLevel("medium");
expect((await session.getConfigOptions()).find((o) => o.category === "thought_level")?.currentValue).toBe("medium");
await session.setThoughtLevel("low");
expect((await session.getConfigOptions()).find((o) => o.category === "thought_level")?.currentValue).toBe("low");
await sdk.dispose();
});
it("supports MCP and skills config HTTP helpers", async () => { it("supports MCP and skills config HTTP helpers", async () => {
const sdk = await SandboxAgent.connect({ const sdk = await SandboxAgent.connect({
baseUrl, baseUrl,

View file

@ -78,7 +78,7 @@ impl AgentId {
fn agent_process_registry_id(self) -> Option<&'static str> { fn agent_process_registry_id(self) -> Option<&'static str> {
match self { match self {
AgentId::Claude => Some("claude-code-acp"), AgentId::Claude => Some("claude-acp"),
AgentId::Codex => Some("codex-acp"), AgentId::Codex => Some("codex-acp"),
AgentId::Opencode => Some("opencode"), AgentId::Opencode => Some("opencode"),
AgentId::Amp => Some("amp-acp"), AgentId::Amp => Some("amp-acp"),
@ -90,7 +90,7 @@ impl AgentId {
fn agent_process_binary_hint(self) -> Option<&'static str> { fn agent_process_binary_hint(self) -> Option<&'static str> {
match self { match self {
AgentId::Claude => Some("claude-code-acp"), AgentId::Claude => Some("claude-agent-acp"),
AgentId::Codex => Some("codex-acp"), AgentId::Codex => Some("codex-acp"),
AgentId::Opencode => Some("opencode"), AgentId::Opencode => Some("opencode"),
AgentId::Amp => Some("amp-acp"), AgentId::Amp => Some("amp-acp"),
@ -606,7 +606,7 @@ impl AgentManager {
match agent { match agent {
AgentId::Claude => { AgentId::Claude => {
let package = fallback_npx_package( let package = fallback_npx_package(
"@zed-industries/claude-code-acp", "@zed-industries/claude-agent-acp",
options.agent_process_version.as_deref(), options.agent_process_version.as_deref(),
); );
write_npx_agent_process_launcher(&launcher, &package, &[], &HashMap::new())?; write_npx_agent_process_launcher(&launcher, &package, &[], &HashMap::new())?;

View file

@ -1,4 +1,4 @@
use std::collections::HashMap; use std::collections::{BTreeMap, HashMap};
use std::io::Write; use std::io::Write;
use std::path::PathBuf; use std::path::PathBuf;
use std::process::Command as ProcessCommand; use std::process::Command as ProcessCommand;
@ -24,7 +24,7 @@ use sandbox_agent_agent_credentials::{
ProviderCredentials, ProviderCredentials,
}; };
use sandbox_agent_agent_management::agents::{AgentId, AgentManager, InstallOptions}; use sandbox_agent_agent_management::agents::{AgentId, AgentManager, InstallOptions};
use serde::Serialize; use serde::{Deserialize, Serialize};
use serde_json::{json, Value}; use serde_json::{json, Value};
use thiserror::Error; use thiserror::Error;
use tower_http::cors::{Any, CorsLayer}; use tower_http::cors::{Any, CorsLayer};
@ -220,6 +220,8 @@ pub struct AgentsArgs {
pub enum AgentsCommand { pub enum AgentsCommand {
/// List all agents and install status. /// List all agents and install status.
List(ClientArgs), List(ClientArgs),
/// Emit JSON report of model/mode/thought options for all agents.
Report(ClientArgs),
/// Install or reinstall an agent. /// Install or reinstall an agent.
Install(ApiInstallAgentArgs), Install(ApiInstallAgentArgs),
} }
@ -475,6 +477,7 @@ fn run_agents(command: &AgentsCommand, cli: &CliConfig) -> Result<(), CliError>
let result = call_acp_extension(&ctx, ACP_EXTENSION_AGENT_LIST_METHOD, json!({}))?; let result = call_acp_extension(&ctx, ACP_EXTENSION_AGENT_LIST_METHOD, json!({}))?;
write_stdout_line(&serde_json::to_string_pretty(&result)?) write_stdout_line(&serde_json::to_string_pretty(&result)?)
} }
AgentsCommand::Report(args) => run_agents_report(args, cli),
AgentsCommand::Install(args) => { AgentsCommand::Install(args) => {
let ctx = ClientContext::new(cli, &args.client)?; let ctx = ClientContext::new(cli, &args.client)?;
let mut params = serde_json::Map::new(); let mut params = serde_json::Map::new();
@ -498,6 +501,223 @@ fn run_agents(command: &AgentsCommand, cli: &CliConfig) -> Result<(), CliError>
} }
} }
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct AgentListApiResponse {
agents: Vec<AgentListApiAgent>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct AgentListApiAgent {
id: String,
installed: bool,
#[serde(default)]
config_error: Option<String>,
#[serde(default)]
config_options: Option<Vec<Value>>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RawConfigOption {
#[serde(default)]
id: Option<String>,
#[serde(default)]
category: Option<String>,
#[serde(default)]
current_value: Option<Value>,
#[serde(default)]
options: Vec<RawConfigOptionChoice>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RawConfigOptionChoice {
#[serde(default)]
value: Value,
#[serde(default)]
name: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct AgentConfigReport {
generated_at_ms: u128,
endpoint: String,
agents: Vec<AgentConfigReportEntry>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct AgentConfigReportEntry {
id: String,
installed: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
config_error: Option<String>,
models: AgentConfigCategoryReport,
modes: AgentConfigCategoryReport,
thought_levels: AgentConfigCategoryReport,
}
#[derive(Debug, Serialize, Default, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
struct AgentConfigCategoryReport {
#[serde(default, skip_serializing_if = "Option::is_none")]
current_value: Option<String>,
values: Vec<AgentConfigValueReport>,
}
#[derive(Debug, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
struct AgentConfigValueReport {
value: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
name: Option<String>,
}
#[derive(Clone, Copy)]
enum ConfigReportCategory {
Model,
Mode,
ThoughtLevel,
}
#[derive(Default)]
struct CategoryAccumulator {
current_value: Option<String>,
values: BTreeMap<String, Option<String>>,
}
impl CategoryAccumulator {
fn absorb(&mut self, option: &RawConfigOption) {
if self.current_value.is_none() {
self.current_value = config_value_to_string(option.current_value.as_ref());
}
for candidate in &option.options {
let Some(value) = config_value_to_string(Some(&candidate.value)) else {
continue;
};
let name = candidate
.name
.as_ref()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
let entry = self.values.entry(value).or_insert(None);
if entry.is_none() && name.is_some() {
*entry = name;
}
}
}
fn into_report(mut self) -> AgentConfigCategoryReport {
if let Some(current) = self.current_value.clone() {
self.values.entry(current).or_insert(None);
}
AgentConfigCategoryReport {
current_value: self.current_value,
values: self
.values
.into_iter()
.map(|(value, name)| AgentConfigValueReport { value, name })
.collect(),
}
}
}
fn run_agents_report(args: &ClientArgs, cli: &CliConfig) -> Result<(), CliError> {
let ctx = ClientContext::new(cli, args)?;
let response = ctx.get(&format!("{API_PREFIX}/agents?config=true"))?;
let status = response.status();
let text = response.text()?;
if !status.is_success() {
print_error_body(&text)?;
return Err(CliError::HttpStatus(status));
}
let parsed: AgentListApiResponse = serde_json::from_str(&text)?;
let report = build_agent_config_report(parsed, &ctx.endpoint);
write_stdout_line(&serde_json::to_string_pretty(&report)?)
}
fn build_agent_config_report(input: AgentListApiResponse, endpoint: &str) -> AgentConfigReport {
let generated_at_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis())
.unwrap_or(0);
let agents = input
.agents
.into_iter()
.map(|agent| {
let mut model = CategoryAccumulator::default();
let mut mode = CategoryAccumulator::default();
let mut thought_level = CategoryAccumulator::default();
for option_value in agent.config_options.unwrap_or_default() {
let Ok(option) = serde_json::from_value::<RawConfigOption>(option_value) else {
continue;
};
let Some(category) = option
.category
.as_deref()
.or(option.id.as_deref())
.and_then(classify_report_category)
else {
continue;
};
match category {
ConfigReportCategory::Model => model.absorb(&option),
ConfigReportCategory::Mode => mode.absorb(&option),
ConfigReportCategory::ThoughtLevel => thought_level.absorb(&option),
}
}
AgentConfigReportEntry {
id: agent.id,
installed: agent.installed,
config_error: agent.config_error,
models: model.into_report(),
modes: mode.into_report(),
thought_levels: thought_level.into_report(),
}
})
.collect();
AgentConfigReport {
generated_at_ms,
endpoint: endpoint.to_string(),
agents,
}
}
fn classify_report_category(raw: &str) -> Option<ConfigReportCategory> {
let normalized = raw
.trim()
.to_ascii_lowercase()
.replace('-', "_")
.replace(' ', "_");
match normalized.as_str() {
"model" | "model_id" => Some(ConfigReportCategory::Model),
"mode" | "agent_mode" => Some(ConfigReportCategory::Mode),
"thought" | "thoughtlevel" | "thought_level" | "thinking" | "thinking_level"
| "reasoning" | "reasoning_effort" => Some(ConfigReportCategory::ThoughtLevel),
_ => None,
}
}
fn config_value_to_string(value: Option<&Value>) -> Option<String> {
match value {
Some(Value::String(value)) => Some(value.clone()),
Some(Value::Null) | None => None,
Some(other) => Some(other.to_string()),
}
}
fn call_acp_extension(ctx: &ClientContext, method: &str, params: Value) -> Result<Value, CliError> { fn call_acp_extension(ctx: &ClientContext, method: &str, params: Value) -> Result<Value, CliError> {
let server_id = unique_cli_server_id("cli-ext"); let server_id = unique_cli_server_id("cli-ext");
let initialize_path = build_acp_server_path(&server_id, Some("mock"))?; let initialize_path = build_acp_server_path(&server_id, Some("mock"))?;
@ -1219,4 +1439,96 @@ mod tests {
.expect("build request"); .expect("build request");
assert!(request.headers().get("last-event-id").is_none()); assert!(request.headers().get("last-event-id").is_none());
} }
#[test]
fn classify_report_category_supports_common_aliases() {
assert!(matches!(
classify_report_category("model"),
Some(ConfigReportCategory::Model)
));
assert!(matches!(
classify_report_category("mode"),
Some(ConfigReportCategory::Mode)
));
assert!(matches!(
classify_report_category("thought_level"),
Some(ConfigReportCategory::ThoughtLevel)
));
assert!(matches!(
classify_report_category("reasoning_effort"),
Some(ConfigReportCategory::ThoughtLevel)
));
assert!(classify_report_category("arbitrary").is_none());
}
#[test]
fn build_agent_config_report_extracts_model_mode_and_thought() {
let response = AgentListApiResponse {
agents: vec![AgentListApiAgent {
id: "codex".to_string(),
installed: true,
config_error: None,
config_options: Some(vec![
json!({
"id": "model",
"category": "model",
"currentValue": "gpt-5",
"options": [
{"value": "gpt-5", "name": "GPT-5"},
{"value": "gpt-5-mini", "name": "GPT-5 mini"}
]
}),
json!({
"id": "mode",
"category": "mode",
"currentValue": "default",
"options": [
{"value": "default", "name": "Default"},
{"value": "plan", "name": "Plan"}
]
}),
json!({
"id": "thought",
"category": "thought_level",
"currentValue": "medium",
"options": [
{"value": "low", "name": "Low"},
{"value": "medium", "name": "Medium"},
{"value": "high", "name": "High"}
]
}),
]),
}],
};
let report = build_agent_config_report(response, "http://127.0.0.1:2468");
let agent = report.agents.first().expect("agent report");
assert_eq!(agent.id, "codex");
assert_eq!(agent.models.current_value.as_deref(), Some("gpt-5"));
assert_eq!(agent.modes.current_value.as_deref(), Some("default"));
assert_eq!(
agent.thought_levels.current_value.as_deref(),
Some("medium")
);
let model_values: Vec<&str> = agent
.models
.values
.iter()
.map(|item| item.value.as_str())
.collect();
assert!(model_values.contains(&"gpt-5"));
assert!(model_values.contains(&"gpt-5-mini"));
let thought_values: Vec<&str> = agent
.thought_levels
.values
.iter()
.map(|item| item.value.as_str())
.collect();
assert!(thought_values.contains(&"low"));
assert!(thought_values.contains(&"medium"));
assert!(thought_values.contains(&"high"));
}
} }

View file

@ -90,6 +90,9 @@ pub(super) fn fallback_config_options(agent: AgentId) -> Vec<Value> {
AgentId::Codex => CODEX.clone(), AgentId::Codex => CODEX.clone(),
AgentId::Opencode => OPENCODE.clone(), AgentId::Opencode => OPENCODE.clone(),
AgentId::Cursor => CURSOR.clone(), AgentId::Cursor => CURSOR.clone(),
// Amp returns empty configOptions from session/new but exposes modes via
// the `modes` field. The model is hardcoded. Modes discovered from ACP
// session/new response (amp-acp v0.7.0).
AgentId::Amp => vec![ AgentId::Amp => vec![
json!({ json!({
"id": "model", "id": "model",
@ -106,12 +109,10 @@ pub(super) fn fallback_config_options(agent: AgentId) -> Vec<Value> {
"name": "Mode", "name": "Mode",
"category": "mode", "category": "mode",
"type": "select", "type": "select",
"currentValue": "smart", "currentValue": "default",
"options": [ "options": [
{ "value": "smart", "name": "Smart" }, { "value": "default", "name": "Default" },
{ "value": "deep", "name": "Deep" }, { "value": "bypass", "name": "Bypass" }
{ "value": "free", "name": "Free" },
{ "value": "rush", "name": "Rush" }
] ]
}), }),
], ],
@ -125,41 +126,76 @@ pub(super) fn fallback_config_options(agent: AgentId) -> Vec<Value> {
{ "value": "default", "name": "Default" } { "value": "default", "name": "Default" }
] ]
})], })],
AgentId::Mock => vec![json!({ AgentId::Mock => vec![
json!({
"id": "model", "id": "model",
"name": "Model", "name": "Model",
"category": "model", "category": "model",
"type": "select", "type": "select",
"currentValue": "mock", "currentValue": "mock",
"options": [ "options": [
{ "value": "mock", "name": "Mock" } { "value": "mock", "name": "Mock" },
{ "value": "mock-fast", "name": "Mock Fast" }
] ]
})], }),
json!({
"id": "mode",
"name": "Mode",
"category": "mode",
"type": "select",
"currentValue": "normal",
"options": [
{ "value": "normal", "name": "Normal" },
{ "value": "plan", "name": "Plan" }
]
}),
json!({
"id": "thought_level",
"name": "Thought Level",
"category": "thought_level",
"type": "select",
"currentValue": "low",
"options": [
{ "value": "low", "name": "Low" },
{ "value": "medium", "name": "Medium" },
{ "value": "high", "name": "High" }
]
}),
],
} }
} }
/// Parse an agent config JSON file (from `scripts/agent-configs/resources/`) into /// Parse an agent config JSON file (from `scripts/agent-configs/resources/`) into
/// ACP `SessionConfigOption` values. The JSON format is: /// ACP `SessionConfigOption` values. The JSON format is:
/// ```json /// ```json
/// { "defaultModel": "...", "models": [{id, name}], "defaultMode?": "...", "modes?": [{id, name}] } /// {
/// "defaultModel": "...", "models": [{id, name}],
/// "defaultMode?": "...", "modes?": [{id, name}],
/// "defaultThoughtLevel?": "...", "thoughtLevels?": [{id, name}]
/// }
/// ``` /// ```
///
/// Note: Claude and Codex don't report configOptions from `session/new`, so these
/// JSON resource files are the source of truth for the capabilities report.
/// Claude modes (plan, default) were discovered via manual ACP probing —
/// `session/set_mode` works but `session/set_config_option` is not implemented.
/// Codex modes/thought levels were discovered from its `session/new` response.
fn parse_agent_config(json_str: &str) -> Vec<Value> { fn parse_agent_config(json_str: &str) -> Vec<Value> {
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
struct AgentConfig { struct AgentConfig {
#[serde(rename = "defaultModel")] #[serde(rename = "defaultModel")]
default_model: String, default_model: String,
models: Vec<ModelEntry>, models: Vec<ConfigEntry>,
#[serde(rename = "defaultMode")] #[serde(rename = "defaultMode")]
default_mode: Option<String>, default_mode: Option<String>,
modes: Option<Vec<ModeEntry>>, modes: Option<Vec<ConfigEntry>>,
#[serde(rename = "defaultThoughtLevel")]
default_thought_level: Option<String>,
#[serde(rename = "thoughtLevels")]
thought_levels: Option<Vec<ConfigEntry>>,
} }
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
struct ModelEntry { struct ConfigEntry {
id: String,
name: String,
}
#[derive(serde::Deserialize)]
struct ModeEntry {
id: String, id: String,
name: String, name: String,
} }
@ -193,6 +229,20 @@ fn parse_agent_config(json_str: &str) -> Vec<Value> {
})); }));
} }
if let Some(thought_levels) = config.thought_levels {
options.push(json!({
"id": "thought_level",
"name": "Thought Level",
"category": "thought_level",
"type": "select",
"currentValue": config.default_thought_level.unwrap_or_else(|| thought_levels[0].id.clone()),
"options": thought_levels.iter().map(|t| json!({
"value": t.id,
"name": t.name,
})).collect::<Vec<_>>(),
}));
}
options options
} }