mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-17 08:01:03 +00:00
feat: add configuration for model, mode, and thought level
This commit is contained in:
parent
c3a95c3611
commit
6d319a1c3e
16 changed files with 1419 additions and 67 deletions
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
96
docs/agent-capabilities.mdx
Normal file
96
docs/agent-capabilities.mdx
Normal 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>
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
50
docs/cli.mdx
50
docs/cli.mdx
|
|
@ -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
|
||||||
|
```
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,7 @@
|
||||||
{
|
{
|
||||||
"group": "Reference",
|
"group": "Reference",
|
||||||
"pages": [
|
"pages": [
|
||||||
|
"agent-capabilities",
|
||||||
"cli",
|
"cli",
|
||||||
"inspector",
|
"inspector",
|
||||||
"opencode-compatibility",
|
"opencode-compatibility",
|
||||||
|
|
|
||||||
|
|
@ -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";
|
|
||||||
```
|
|
||||||
|
|
|
||||||
|
|
@ -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" },
|
||||||
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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("");
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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())?;
|
||||||
|
|
|
||||||
|
|
@ -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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue