feat: migrate Claude Code contracts to v2.1.201

This commit is contained in:
抱月 2026-07-05 22:16:27 +08:00
parent 4ea31c1bc9
commit 1a73dc2c4e
40 changed files with 1761 additions and 514 deletions

View File

@ -9,6 +9,7 @@ Last updated: 2026-04-03
- Current `main` HEAD: `ee31e00` (stub implementations replaced with real AskUserQuestion + RemoteTrigger). - Current `main` HEAD: `ee31e00` (stub implementations replaced with real AskUserQuestion + RemoteTrigger).
- Repository stats at this checkpoint: **292 commits on `main` / 293 across all branches**, **9 crates**, **48,599 tracked Rust LOC**, **2,568 test LOC**, **3 authors**, date range **2026-03-31 → 2026-04-03**. - Repository stats at this checkpoint: **292 commits on `main` / 293 across all branches**, **9 crates**, **48,599 tracked Rust LOC**, **2,568 test LOC**, **3 authors**, date range **2026-03-31 → 2026-04-03**.
- Mock parity harness stats: **12 scripted scenarios**, **21 captured `/v1/messages` requests** in `rust/crates/rusty-claude-cli/tests/mock_parity_harness.rs`. - Mock parity harness stats: **12 scripted scenarios**, **21 captured `/v1/messages` requests** in `rust/crates/rusty-claude-cli/tests/mock_parity_harness.rs`.
- Claude Code v2.1.201 migration branch note: `TaskCreate`/`TaskUpdate` accept the structured `subject`/`activeForm`/`metadata` contract, `McpAuth` exposes login/logout host-flow payloads, and new host-facing contracts (`Workflow`, `Monitor`, `ScheduleWakeup`, `PushNotification`, `ReportFindings`, `ReadMcpResourceDir`, `Artifact`, `Projects`, `ClaudeDesign`, `ShowOnboardingRolePicker`) are registered with stable JSON responses.
## Mock parity harness — milestone 1 ## Mock parity harness — milestone 1
@ -144,17 +145,20 @@ Canonical scenario map: `rust/mock_parity_scenarios.json`
- `PermissionEnforcer::check()` delegates to `PermissionPolicy::authorize()` and returns structured allow/deny results. - `PermissionEnforcer::check()` delegates to `PermissionPolicy::authorize()` and returns structured allow/deny results.
- `check_file_write()` enforces workspace boundaries and read-only denial; `check_bash()` denies mutating commands in read-only mode and blocks prompt-mode bash without confirmation. - `check_file_write()` enforces workspace boundaries and read-only denial; `check_bash()` denies mutating commands in read-only mode and blocks prompt-mode bash without confirmation.
## Tool Surface: 40 exposed tool specs on `main` ## Tool Surface
- `mvp_tool_specs()` in `rust/crates/tools/src/lib.rs` exposes **40** tool specs. - `mvp_tool_specs()` in `rust/crates/tools/src/lib.rs` exposes the core tool set plus Claude Code v2.1.201 compatibility contracts.
- Core execution is present for `bash`, `read_file`, `write_file`, `edit_file`, `glob_search`, and `grep_search`. - Core execution is present for `bash`, `read_file`, `write_file`, `edit_file`, `glob_search`, and `grep_search`.
- Existing product tools in `mvp_tool_specs()` include `WebFetch`, `WebSearch`, `TodoWrite`, `Skill`, `Agent`, `ToolSearch`, `NotebookEdit`, `Sleep`, `SendUserMessage`, `Config`, `EnterPlanMode`, `ExitPlanMode`, `StructuredOutput`, `REPL`, and `PowerShell`. - Existing product tools in `mvp_tool_specs()` include `WebFetch`, `WebSearch`, `TodoWrite`, `Skill`, `Agent`, `ToolSearch`, `NotebookEdit`, `Sleep`, `SendUserMessage`, `Config`, `EnterPlanMode`, `ExitPlanMode`, `StructuredOutput`, `REPL`, and `PowerShell`.
- The 9-lane push replaced pure fixed-payload stubs for `Task*`, `Team*`, `Cron*`, `LSP`, and MCP tools with registry-backed handlers on `main`. - The 9-lane push replaced pure fixed-payload stubs for `Task*`, `Team*`, `Cron*`, `LSP`, and MCP tools with registry-backed handlers on `main`; the v2.1.201 migration extends `TaskCreate`/`TaskUpdate` to the structured task-list payload shape.
- The v2.1.201 host-facing contracts include `Workflow`, `Monitor`, `ScheduleWakeup`, `PushNotification`, `ReportFindings`, `ReadMcpResourceDir`, `Artifact`, `Projects`, `ClaudeDesign`, and `ShowOnboardingRolePicker`.
- `Brief` is handled as an execution alias in `execute_tool()`, but it is not a separately exposed tool spec in `mvp_tool_specs()`. - `Brief` is handled as an execution alias in `execute_tool()`, but it is not a separately exposed tool spec in `mvp_tool_specs()`.
### Still limited or intentionally shallow ### Still limited or intentionally shallow
- `AskUserQuestion` still returns a pending response payload rather than real interactive UI wiring. - `AskUserQuestion` still returns a pending response payload rather than real interactive UI wiring.
- `McpAuth` reports structured login/logout contract state and host-flow requirements, but it does not open a browser or complete OAuth by itself.
- `Workflow` and `Monitor` expose compatibility payloads, but local workflow JavaScript execution and live monitor scheduling are not implemented in this runtime.
- `RemoteTrigger` remains a stub response. - `RemoteTrigger` remains a stub response.
- `TestingPermission` remains test-only. - `TestingPermission` remains test-only.
- Task, team, cron, MCP, and LSP are no longer just fixed-payload stubs in `execute_tool()`, but several remain registry-backed approximations rather than full external-runtime integrations. - Task, team, cron, MCP, and LSP are no longer just fixed-payload stubs in `execute_tool()`, but several remain registry-backed approximations rather than full external-runtime integrations.

View File

@ -209,17 +209,19 @@ Global workspace override flags: `--cwd PATH`, `-C PATH`, and `--directory PATH`
`--output-format` accepts `text` or `json` case-insensitively and normalizes to the canonical lowercase modes. `CLAW_OUTPUT_FORMAT=json` sets the default output format for scripts, while an explicit `--output-format` flag takes precedence. Repeating the flag emits a stderr warning and JSON status envelopes expose `format_source`, `format_raw`, and `format_overridden` so composed flag arrays are auditable; invalid values return typed `invalid_output_format` JSON with `value` and `expected:["text","json"]`. `--output-format` accepts `text` or `json` case-insensitively and normalizes to the canonical lowercase modes. `CLAW_OUTPUT_FORMAT=json` sets the default output format for scripts, while an explicit `--output-format` flag takes precedence. Repeating the flag emits a stderr warning and JSON status envelopes expose `format_source`, `format_raw`, and `format_overridden` so composed flag arrays are auditable; invalid values return typed `invalid_output_format` JSON with `value` and `expected:["text","json"]`.
Supported permission modes (default: `workspace-write`): Supported permission modes (default: `manual`):
- `read-only` allows inspection-only local tools such as file reads, glob/grep searches, local skills, and status-style reporting. It does not allow workspace mutation, network-fetch/search tools, or arbitrary command execution. - `read-only` allows inspection-only local tools such as file reads, glob/grep searches, local skills, and status-style reporting. It does not allow workspace mutation, network-fetch/search tools, or arbitrary command execution.
- `workspace-write` is the safe default. It allows reads plus direct file-editing tools inside the current workspace, including write/edit/notebook/config/plan-mode updates, while still gating network-fetch/search tools, arbitrary shell execution, subagent launches, REPL subprocesses, and other full-access tools behind an explicit escalation. - `manual` is the default. It allows read-only tools automatically and requires confirmation before file writes, shell execution, network tools, subagent launches, REPL subprocesses, or other tools that mutate state or cross trust boundaries.
- `workspace-write` allows reads plus direct file-editing tools inside the current workspace, including write/edit/notebook/config/plan-mode updates, while still gating network-fetch/search tools, arbitrary shell execution, subagent launches, REPL subprocesses, and other full-access tools behind an explicit escalation.
- `danger-full-access` allows every registered tool requirement, including arbitrary command execution, web fetch/search, subagent launches, subprocess REPLs, and unrestricted tool access. Select it only with an explicit `--permission-mode danger-full-access`, `--dangerously-skip-permissions`, `--skip-permissions`, env, or config opt-in. - `danger-full-access` allows every registered tool requirement, including arbitrary command execution, web fetch/search, subagent launches, subprocess REPLs, and unrestricted tool access. Select it only with an explicit `--permission-mode danger-full-access`, `--dangerously-skip-permissions`, `--skip-permissions`, env, or config opt-in.
Model aliases currently supported by the CLI: Model aliases currently supported by the CLI:
- `opus``claude-opus-4-7` - `opus` -> `claude-opus-4-8`
- `sonnet``claude-sonnet-4-6` - `sonnet` -> `claude-sonnet-5`
- `haiku``claude-haiku-4-5-20251213` - `haiku` -> `claude-haiku-4-5-20251001`
- `fable` -> `claude-fable-5`
## Authentication ## Authentication
@ -290,7 +292,7 @@ export ANTHROPIC_BASE_URL="http://127.0.0.1:8080"
export ANTHROPIC_AUTH_TOKEN="local-dev-token" export ANTHROPIC_AUTH_TOKEN="local-dev-token"
cd rust cd rust
./target/debug/claw --model "claude-sonnet-4-6" prompt "reply with the word ready" ./target/debug/claw --model "claude-sonnet-5" prompt "reply with the word ready"
``` ```
### OpenAI-compatible endpoint ### OpenAI-compatible endpoint
@ -375,9 +377,10 @@ These are the models registered in the built-in alias table with known token lim
| Alias | Resolved model name | Provider | Max output tokens | Context window | | Alias | Resolved model name | Provider | Max output tokens | Context window |
|---|---|---|---|---| |---|---|---|---|---|
| `opus` | `claude-opus-4-7` | Anthropic | 32 000 | 200 000 | | `opus` | `claude-opus-4-8` | Anthropic | 128 000 | 1 000 000 |
| `sonnet` | `claude-sonnet-4-6` | Anthropic | 64 000 | 200 000 | | `sonnet` | `claude-sonnet-5` | Anthropic | 128 000 | 1 000 000 |
| `haiku` | `claude-haiku-4-5-20251213` | Anthropic | 64 000 | 200 000 | | `haiku` | `claude-haiku-4-5-20251001` | Anthropic | 64 000 | 200 000 |
| `fable` | `claude-fable-5` | Anthropic | 128 000 | 1 000 000 |
| `grok` / `grok-3` | `grok-3` | xAI | 64 000 | 131 072 | | `grok` / `grok-3` | `grok-3` | xAI | 64 000 | 131 072 |
| `grok-mini` / `grok-3-mini` | `grok-3-mini` | xAI | 64 000 | 131 072 | | `grok-mini` / `grok-3-mini` | `grok-3-mini` | xAI | 64 000 | 131 072 |
| `grok-2` | `grok-2` | xAI | — | — | | `grok-2` | `grok-2` | xAI | — | — |
@ -396,8 +399,8 @@ You can add custom aliases in any settings file (`~/.claw/settings.json`, `.claw
```json ```json
{ {
"aliases": { "aliases": {
"fast": "claude-haiku-4-5-20251213", "fast": "claude-haiku-4-5-20251001",
"smart": "claude-opus-4-7", "smart": "claude-opus-4-8",
"cheap": "grok-3-mini" "cheap": "grok-3-mini"
} }
} }

View File

@ -1,6 +1,6 @@
# Model Compatibility Guide # Model Compatibility Guide
This document describes model-specific handling in the OpenAI-compatible provider. When adding new models or providers, review this guide to ensure proper compatibility. This document describes model-specific handling in the OpenAI-compatible provider and shared model registry. When adding new models or providers, review this guide to ensure proper compatibility.
## Table of Contents ## Table of Contents
@ -9,6 +9,7 @@ This document describes model-specific handling in the OpenAI-compatible provide
- [Kimi Models (is_error Exclusion)](#kimi-models-is_error-exclusion) - [Kimi Models (is_error Exclusion)](#kimi-models-is_error-exclusion)
- [Reasoning Models (Tuning Parameter Stripping)](#reasoning-models-tuning-parameter-stripping) - [Reasoning Models (Tuning Parameter Stripping)](#reasoning-models-tuning-parameter-stripping)
- [GPT-5 (max_completion_tokens)](#gpt-5-max_completion_tokens) - [GPT-5 (max_completion_tokens)](#gpt-5-max_completion_tokens)
- [Anthropic Aliases and Token Limits](#anthropic-aliases-and-token-limits)
- [Qwen and Kimi Models (DashScope Routing)](#qwen-and-kimi-models-dashscope-routing) - [Qwen and Kimi Models (DashScope Routing)](#qwen-and-kimi-models-dashscope-routing)
- [Custom Gateway Slugs and Extra Body Parameters](#custom-gateway-slugs-and-extra-body-parameters) - [Custom Gateway Slugs and Extra Body Parameters](#custom-gateway-slugs-and-extra-body-parameters)
- [Implementation Details](#implementation-details) - [Implementation Details](#implementation-details)
@ -123,6 +124,25 @@ let max_tokens_key = if wire_model.starts_with("gpt-5") {
--- ---
### Anthropic Aliases and Token Limits
**Affected aliases:** `opus`, `sonnet`, `haiku`, and `fable`
**Behavior:** Built-in aliases resolve through the shared provider registry before request construction:
| Alias | Resolved model | Max output tokens | Context window |
|---|---|---:|---:|
| `opus` | `claude-opus-4-8` | 128 000 | 1 000 000 |
| `sonnet` | `claude-sonnet-5` | 128 000 | 1 000 000 |
| `haiku` | `claude-haiku-4-5-20251001` | 64 000 | 200 000 |
| `fable` | `claude-fable-5` | 128 000 | 1 000 000 |
**Rationale:** The CLI default and short aliases must stay aligned with the provider token-limit registry so context-window preflight, status output, and request construction agree.
**Testing:** See alias and `model_token_limit()` tests in `providers/mod.rs`, plus CLI alias tests in `rusty-claude-cli/src/main.rs`.
---
### Qwen and Kimi Models (DashScope Routing) ### Qwen and Kimi Models (DashScope Routing)
**Affected models:** All models with `qwen` or `kimi` prefixes, including `qwen/`, `qwen-`, `kimi/`, and `kimi-` forms. **Affected models:** All models with `qwen` or `kimi` prefixes, including `qwen/`, `qwen-`, `kimi/`, and `kimi-` forms.
@ -256,6 +276,6 @@ fn my_new_model_is_detected() {
--- ---
*Last updated: 2026-05-15* *Last updated: 2026-07-05*
For questions or updates, see the implementation in `rust/crates/api/src/providers/openai_compat.rs`. For questions or updates, see the implementation in `rust/crates/api/src/providers/openai_compat.rs`.

194
rust/Cargo.lock generated
View File

@ -37,9 +37,9 @@ checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299"
[[package]] [[package]]
name = "anstream" name = "anstream"
version = "1.0.0" version = "0.6.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
dependencies = [ dependencies = [
"anstyle", "anstyle",
"anstyle-parse", "anstyle-parse",
@ -58,9 +58,9 @@ checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]] [[package]]
name = "anstyle-parse" name = "anstyle-parse"
version = "1.0.0" version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
dependencies = [ dependencies = [
"utf8parse", "utf8parse",
] ]
@ -312,16 +312,15 @@ checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
[[package]] [[package]]
name = "blake3" name = "blake3"
version = "1.8.5" version = "1.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" checksum = "b8ee0c1824c4dea5b5f81736aff91bae041d2c07ee1192bec91054e10e3e601e"
dependencies = [ dependencies = [
"arrayref", "arrayref",
"arrayvec", "arrayvec",
"cc", "cc",
"cfg-if", "cfg-if",
"constant_time_eq", "constant_time_eq",
"cpufeatures 0.3.0",
] ]
[[package]] [[package]]
@ -412,9 +411,9 @@ dependencies = [
[[package]] [[package]]
name = "clap" name = "clap"
version = "4.6.1" version = "4.5.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" checksum = "6088f3ae8c3608d19260cd7445411865a485688711b78b5be70d78cd96136f83"
dependencies = [ dependencies = [
"clap_builder", "clap_builder",
"clap_derive", "clap_derive",
@ -422,9 +421,9 @@ dependencies = [
[[package]] [[package]]
name = "clap_builder" name = "clap_builder"
version = "4.6.0" version = "4.5.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" checksum = "22a7ef7f676155edfb82daa97f99441f3ebf4a58d5e32f295a56259f1b6facc8"
dependencies = [ dependencies = [
"anstream", "anstream",
"anstyle", "anstyle",
@ -434,18 +433,18 @@ dependencies = [
[[package]] [[package]]
name = "clap_complete" name = "clap_complete"
version = "4.6.5" version = "4.5.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0a7a9bfdb35811f9e59832f0f05975114d2251b415fb534108e6f34060fd772" checksum = "74a01f4f9ee6c066d42a1c8dedf0dcddad16c72a8981a309d6398de3a75b0c39"
dependencies = [ dependencies = [
"clap", "clap",
] ]
[[package]] [[package]]
name = "clap_derive" name = "clap_derive"
version = "4.6.1" version = "4.5.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7"
dependencies = [ dependencies = [
"heck", "heck",
"proc-macro2", "proc-macro2",
@ -455,9 +454,9 @@ dependencies = [
[[package]] [[package]]
name = "clap_lex" name = "clap_lex"
version = "1.1.0" version = "0.7.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32"
[[package]] [[package]]
name = "claw-analog" name = "claw-analog"
@ -532,9 +531,9 @@ dependencies = [
[[package]] [[package]]
name = "constant_time_eq" name = "constant_time_eq"
version = "0.4.2" version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
[[package]] [[package]]
name = "core-foundation" name = "core-foundation"
@ -561,15 +560,6 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "cpufeatures"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "crc32fast" name = "crc32fast"
version = "1.5.0" version = "1.5.0"
@ -718,9 +708,9 @@ dependencies = [
[[package]] [[package]]
name = "deranged" name = "deranged"
version = "0.5.8" version = "0.3.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4"
dependencies = [ dependencies = [
"powerfmt", "powerfmt",
] ]
@ -808,7 +798,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.61.2", "windows-sys 0.59.0",
] ]
[[package]] [[package]]
@ -1019,9 +1009,9 @@ checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]] [[package]]
name = "globset" name = "globset"
version = "0.4.18" version = "0.4.16"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" checksum = "54a1028dfc5f5df5da8a56a73e6c153c9a9708ec57232470703592a3f18e49f5"
dependencies = [ dependencies = [
"aho-corasick", "aho-corasick",
"bstr", "bstr",
@ -1104,11 +1094,11 @@ checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
[[package]] [[package]]
name = "home" name = "home"
version = "0.5.12" version = "0.5.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" checksum = "e3d1354bf6b7235cb4a0576c2619fd4ed18183f689b12b006a0ee7329eeff9a5"
dependencies = [ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.52.0",
] ]
[[package]] [[package]]
@ -1341,9 +1331,9 @@ dependencies = [
[[package]] [[package]]
name = "ignore" name = "ignore"
version = "0.4.25" version = "0.4.23"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" checksum = "6d89fd380afde86567dfba715db065673989d6253f42b88179abd3eae47bda4b"
dependencies = [ dependencies = [
"crossbeam-deque", "crossbeam-deque",
"globset", "globset",
@ -1399,7 +1389,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
dependencies = [ dependencies = [
"hermit-abi", "hermit-abi",
"libc", "libc",
"windows-sys 0.61.2", "windows-sys 0.59.0",
] ]
[[package]] [[package]]
@ -1575,9 +1565,9 @@ dependencies = [
[[package]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.2.1" version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
[[package]] [[package]]
name = "num-traits" name = "num-traits"
@ -1911,7 +1901,7 @@ dependencies = [
"once_cell", "once_cell",
"socket2 0.6.3", "socket2 0.6.3",
"tracing", "tracing",
"windows-sys 0.60.2", "windows-sys 0.52.0",
] ]
[[package]] [[package]]
@ -2172,7 +2162,7 @@ dependencies = [
"errno", "errno",
"libc", "libc",
"linux-raw-sys 0.12.1", "linux-raw-sys 0.12.1",
"windows-sys 0.61.2", "windows-sys 0.59.0",
] ]
[[package]] [[package]]
@ -2421,7 +2411,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cpufeatures 0.2.17", "cpufeatures",
"digest", "digest",
] ]
@ -2588,7 +2578,7 @@ dependencies = [
"getrandom 0.3.4", "getrandom 0.3.4",
"once_cell", "once_cell",
"rustix 1.1.4", "rustix 1.1.4",
"windows-sys 0.61.2", "windows-sys 0.59.0",
] ]
[[package]] [[package]]
@ -2633,30 +2623,30 @@ dependencies = [
[[package]] [[package]]
name = "time" name = "time"
version = "0.3.47" version = "0.3.36"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885"
dependencies = [ dependencies = [
"deranged", "deranged",
"itoa", "itoa",
"num-conv", "num-conv",
"powerfmt", "powerfmt",
"serde_core", "serde",
"time-core", "time-core",
"time-macros", "time-macros",
] ]
[[package]] [[package]]
name = "time-core" name = "time-core"
version = "0.1.8" version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3"
[[package]] [[package]]
name = "time-macros" name = "time-macros"
version = "0.2.27" version = "0.2.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf"
dependencies = [ dependencies = [
"num-conv", "num-conv",
"time-core", "time-core",
@ -2976,9 +2966,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]] [[package]]
name = "unicode-segmentation" name = "unicode-segmentation"
version = "1.13.2" version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]] [[package]]
name = "unicode-width" name = "unicode-width"
@ -3181,7 +3171,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.59.0",
] ]
[[package]] [[package]]
@ -3202,7 +3192,7 @@ version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [ dependencies = [
"windows-targets 0.52.6", "windows-targets",
] ]
[[package]] [[package]]
@ -3211,16 +3201,7 @@ version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [ dependencies = [
"windows-targets 0.52.6", "windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
"windows-targets 0.53.5",
] ]
[[package]] [[package]]
@ -3238,31 +3219,14 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [ dependencies = [
"windows_aarch64_gnullvm 0.52.6", "windows_aarch64_gnullvm",
"windows_aarch64_msvc 0.52.6", "windows_aarch64_msvc",
"windows_i686_gnu 0.52.6", "windows_i686_gnu",
"windows_i686_gnullvm 0.52.6", "windows_i686_gnullvm",
"windows_i686_msvc 0.52.6", "windows_i686_msvc",
"windows_x86_64_gnu 0.52.6", "windows_x86_64_gnu",
"windows_x86_64_gnullvm 0.52.6", "windows_x86_64_gnullvm",
"windows_x86_64_msvc 0.52.6", "windows_x86_64_msvc",
]
[[package]]
name = "windows-targets"
version = "0.53.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [
"windows-link",
"windows_aarch64_gnullvm 0.53.1",
"windows_aarch64_msvc 0.53.1",
"windows_i686_gnu 0.53.1",
"windows_i686_gnullvm 0.53.1",
"windows_i686_msvc 0.53.1",
"windows_x86_64_gnu 0.53.1",
"windows_x86_64_gnullvm 0.53.1",
"windows_x86_64_msvc 0.53.1",
] ]
[[package]] [[package]]
@ -3271,96 +3235,48 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]] [[package]]
name = "windows_aarch64_msvc" name = "windows_aarch64_msvc"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]] [[package]]
name = "windows_i686_gnu" name = "windows_i686_gnu"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]] [[package]]
name = "windows_i686_gnullvm" name = "windows_i686_gnullvm"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]] [[package]]
name = "windows_i686_msvc" name = "windows_i686_msvc"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]] [[package]]
name = "windows_x86_64_gnu" name = "windows_x86_64_gnu"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]] [[package]]
name = "windows_x86_64_gnullvm" name = "windows_x86_64_gnullvm"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]] [[package]]
name = "windows_x86_64_msvc" name = "windows_x86_64_msvc"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]] [[package]]
name = "winnow" name = "winnow"
version = "0.7.15" version = "0.7.15"

View File

@ -13,6 +13,7 @@ serde_json = "1"
[workspace.lints.rust] [workspace.lints.rust]
unsafe_code = "forbid" unsafe_code = "forbid"
unexpected_cfgs = { level = "warn", check-cfg = ["cfg(test)"] }
[workspace.lints.clippy] [workspace.lints.clippy]
all = { level = "warn", priority = -1 } all = { level = "warn", priority = -1 }

View File

@ -42,7 +42,7 @@ Hashes below come from `git log --oneline`. Merge line counts come from `git sho
| LSP client | ✅ complete | `2d66503` | `d7f0dc6` | `461 insertions, 9 deletions` | | LSP client | ✅ complete | `2d66503` | `d7f0dc6` | `461 insertions, 9 deletions` |
| Permission enforcement | ✅ complete | `66283f4` | `336f820` | `357 insertions` | | Permission enforcement | ✅ complete | `66283f4` | `336f820` | `357 insertions` |
## Tool Surface: 40/40 (spec parity) ## Tool Surface (spec parity plus v2.1.201 host contracts)
### Real Implementations (behavioral parity — varying depth) ### Real Implementations (behavioral parity — varying depth)
@ -73,6 +73,8 @@ Hashes below come from `git log --oneline`. Merge line counts come from `git sho
| **LSP** | `runtime::lsp_client` + `tools` | registry + dispatch for diagnostics, hover, definition, references, completion, symbols, formatting — **good parity** | | **LSP** | `runtime::lsp_client` + `tools` | registry + dispatch for diagnostics, hover, definition, references, completion, symbols, formatting — **good parity** |
| **ListMcpResources** | `runtime::mcp_tool_bridge` + `tools` | connected-server resource listing — **good parity** | | **ListMcpResources** | `runtime::mcp_tool_bridge` + `tools` | connected-server resource listing — **good parity** |
| **ReadMcpResource** | `runtime::mcp_tool_bridge` + `tools` | connected-server resource reads — **good parity** | | **ReadMcpResource** | `runtime::mcp_tool_bridge` + `tools` | connected-server resource reads — **good parity** |
| **ReadMcpResourceDir** | `runtime::mcp_tool_bridge` + `tools` | prefix-filtered MCP resource directory listing — **contract parity** |
| **McpAuth** | `runtime::mcp_tool_bridge` + `tools` | structured status/login/logout host-flow payloads — **contract parity**, no browser/OAuth host integration |
| **MCP** | `runtime::mcp_tool_bridge` + `tools` | stateful MCP tool invocation bridge — **good parity** | | **MCP** | `runtime::mcp_tool_bridge` + `tools` | stateful MCP tool invocation bridge — **good parity** |
| **ToolSearch** | `tools` | tool discovery — **good parity** | | **ToolSearch** | `tools` | tool discovery — **good parity** |
| **NotebookEdit** | `tools` | jupyter notebook cell editing — **moderate parity** | | **NotebookEdit** | `tools` | jupyter notebook cell editing — **moderate parity** |
@ -84,13 +86,14 @@ Hashes below come from `git log --oneline`. Merge line counts come from `git sho
| **StructuredOutput** | `tools` | passthrough JSON — **good parity** | | **StructuredOutput** | `tools` | passthrough JSON — **good parity** |
| **REPL** | `tools` | subprocess code execution — **moderate parity** | | **REPL** | `tools` | subprocess code execution — **moderate parity** |
| **PowerShell** | `tools` | Windows PowerShell execution — **moderate parity** | | **PowerShell** | `tools` | Windows PowerShell execution — **moderate parity** |
| **Workflow / Monitor** | `tools` | v2.1.201 compatibility contracts with stable unsupported payloads; local workflow JS and live monitor scheduling are not implemented |
| **ScheduleWakeup / PushNotification / ReportFindings / Artifact / Projects / ClaudeDesign / ShowOnboardingRolePicker** | `tools` | host-facing v2.1.201 contract payloads — **contract parity** |
### Stubs Only (surface parity, no behavior) ### Stubs Only (surface parity, no behavior)
| Tool | Status | Notes | | Tool | Status | Notes |
|------|--------|-------| |------|--------|-------|
| **AskUserQuestion** | stub | needs live user I/O integration | | **AskUserQuestion** | stub | needs live user I/O integration |
| **McpAuth** | stub | needs full auth UX beyond the MCP lifecycle bridge |
| **RemoteTrigger** | stub | needs HTTP client | | **RemoteTrigger** | stub | needs HTTP client |
| **TestingPermission** | stub | test-only, low priority | | **TestingPermission** | stub | test-only, low priority |

View File

@ -15,7 +15,7 @@ cargo run -p rusty-claude-cli -- --help
cargo build --workspace cargo build --workspace
# Run the interactive REPL # Run the interactive REPL
cargo run -p rusty-claude-cli -- --model claude-opus-4-7 cargo run -p rusty-claude-cli -- --model sonnet
# One-shot prompt # One-shot prompt
cargo run -p rusty-claude-cli -- prompt "explain this codebase" cargo run -p rusty-claude-cli -- prompt "explain this codebase"
@ -100,7 +100,7 @@ Primary artifacts:
| Cost / usage / stats surfaces | ✅ | | Cost / usage / stats surfaces | ✅ |
| Git integration | ✅ | | Git integration | ✅ |
| Markdown terminal rendering (ANSI) | ✅ | | Markdown terminal rendering (ANSI) | ✅ |
| Model aliases (opus/sonnet/haiku) | ✅ | | Model aliases (opus/sonnet/haiku/fable) | ✅ |
| Direct CLI subcommands (`status`, `sandbox`, `agents`, `mcp`, `skills`, `doctor`) | ✅ | | Direct CLI subcommands (`status`, `sandbox`, `agents`, `mcp`, `skills`, `doctor`) | ✅ |
| Slash commands (including `/skills`, `/agents`, `/mcp`, `/doctor`, `/plugin`, `/subagent`) | ✅ | | Slash commands (including `/skills`, `/agents`, `/mcp`, `/doctor`, `/plugin`, `/subagent`) | ✅ |
| Hooks (`/hooks`, config-backed lifecycle hooks) | ✅ | | Hooks (`/hooks`, config-backed lifecycle hooks) | ✅ |
@ -114,9 +114,10 @@ Short names resolve to the latest model versions:
| Alias | Resolves To | | Alias | Resolves To |
|-------|------------| |-------|------------|
| `opus` | `claude-opus-4-7` | | `opus` | `claude-opus-4-8` |
| `sonnet` | `claude-sonnet-4-6` | | `sonnet` | `claude-sonnet-5` |
| `haiku` | `claude-haiku-4-5-20251213` | | `haiku` | `claude-haiku-4-5-20251001` |
| `fable` | `claude-fable-5` |
## CLI Flags and Commands ## CLI Flags and Commands
@ -223,8 +224,8 @@ rust/
- **~20K lines** of Rust - **~20K lines** of Rust
- **9 crates** in workspace - **9 crates** in workspace
- **Binary name:** `claw` - **Binary name:** `claw`
- **Default model:** `claude-opus-4-7` - **Default model:** `anthropic/claude-sonnet-5`
- **Default permissions:** `workspace-write` - **Default permissions:** `manual`
## License ## License

View File

@ -170,7 +170,9 @@ mod tests {
#[test] #[test]
fn resolves_existing_and_grok_aliases() { fn resolves_existing_and_grok_aliases() {
assert_eq!(resolve_model_alias("opus"), "claude-opus-4-7"); assert_eq!(resolve_model_alias("opus"), "claude-opus-4-8");
assert_eq!(resolve_model_alias("sonnet"), "claude-sonnet-5");
assert_eq!(resolve_model_alias("fable"), "claude-fable-5");
assert_eq!(resolve_model_alias("grok"), "grok-3"); assert_eq!(resolve_model_alias("grok"), "grok-3");
assert_eq!(resolve_model_alias("grok-mini"), "grok-3-mini"); assert_eq!(resolve_model_alias("grok-mini"), "grok-3-mini");
} }

View File

@ -1,3 +1,5 @@
#![allow(clippy::result_large_err)]
mod client; mod client;
mod error; mod error;
mod http_client; mod http_client;

View File

@ -1509,7 +1509,7 @@ mod tests {
#[test] #[test]
fn strip_unsupported_beta_body_fields_removes_betas_array() { fn strip_unsupported_beta_body_fields_removes_betas_array() {
let mut body = serde_json::json!({ let mut body = serde_json::json!({
"model": "claude-sonnet-4-6", "model": "claude-sonnet-5",
"max_tokens": 1024, "max_tokens": 1024,
"betas": ["claude-code-20250219", "prompt-caching-scope-2026-01-05"], "betas": ["claude-code-20250219", "prompt-caching-scope-2026-01-05"],
"metadata": {"source": "test"}, "metadata": {"source": "test"},
@ -1523,7 +1523,7 @@ mod tests {
); );
assert_eq!( assert_eq!(
body.get("model").and_then(serde_json::Value::as_str), body.get("model").and_then(serde_json::Value::as_str),
Some("claude-sonnet-4-6") Some("claude-sonnet-5")
); );
assert_eq!(body["max_tokens"], serde_json::json!(1024)); assert_eq!(body["max_tokens"], serde_json::json!(1024));
assert_eq!(body["metadata"]["source"], serde_json::json!("test")); assert_eq!(body["metadata"]["source"], serde_json::json!("test"));
@ -1532,7 +1532,7 @@ mod tests {
#[test] #[test]
fn strip_unsupported_beta_body_fields_is_a_noop_when_betas_absent() { fn strip_unsupported_beta_body_fields_is_a_noop_when_betas_absent() {
let mut body = serde_json::json!({ let mut body = serde_json::json!({
"model": "claude-sonnet-4-6", "model": "claude-sonnet-5",
"max_tokens": 1024, "max_tokens": 1024,
}); });
let original = body.clone(); let original = body.clone();
@ -1545,7 +1545,7 @@ mod tests {
#[test] #[test]
fn strip_removes_openai_only_fields_and_converts_stop() { fn strip_removes_openai_only_fields_and_converts_stop() {
let mut body = serde_json::json!({ let mut body = serde_json::json!({
"model": "claude-sonnet-4-6", "model": "claude-sonnet-5",
"max_tokens": 1024, "max_tokens": 1024,
"temperature": 0.7, "temperature": 0.7,
"frequency_penalty": 0.5, "frequency_penalty": 0.5,
@ -1574,7 +1574,7 @@ mod tests {
#[test] #[test]
fn strip_does_not_add_empty_stop_sequences() { fn strip_does_not_add_empty_stop_sequences() {
let mut body = serde_json::json!({ let mut body = serde_json::json!({
"model": "claude-sonnet-4-6", "model": "claude-sonnet-5",
"max_tokens": 1024, "max_tokens": 1024,
"stop": [], "stop": [],
}); });
@ -1592,7 +1592,7 @@ mod tests {
fn rendered_request_body_strips_betas_for_standard_messages_endpoint() { fn rendered_request_body_strips_betas_for_standard_messages_endpoint() {
let client = AnthropicClient::new("test-key").with_beta("tools-2026-04-01"); let client = AnthropicClient::new("test-key").with_beta("tools-2026-04-01");
let request = MessageRequest { let request = MessageRequest {
model: "claude-sonnet-4-6".to_string(), model: "claude-sonnet-5".to_string(),
max_tokens: 64, max_tokens: 64,
messages: vec![], messages: vec![],
system: None, system: None,
@ -1618,7 +1618,7 @@ mod tests {
); );
assert_eq!( assert_eq!(
rendered.get("model").and_then(serde_json::Value::as_str), rendered.get("model").and_then(serde_json::Value::as_str),
Some("claude-sonnet-4-6") Some("claude-sonnet-5")
); );
} }
@ -1626,7 +1626,7 @@ mod tests {
fn standard_messages_body_strips_anthropic_routing_prefix() { fn standard_messages_body_strips_anthropic_routing_prefix() {
let client = AnthropicClient::new("test-key"); let client = AnthropicClient::new("test-key");
let request = MessageRequest { let request = MessageRequest {
model: "anthropic/claude-opus-4-6".to_string(), model: "anthropic/claude-opus-4-8".to_string(),
max_tokens: 64, max_tokens: 64,
messages: vec![], messages: vec![],
system: None, system: None,
@ -1639,7 +1639,7 @@ mod tests {
let rendered = super::render_standard_messages_body(client.request_profile(), &request) let rendered = super::render_standard_messages_body(client.request_profile(), &request)
.expect("body should render"); .expect("body should render");
assert_eq!(rendered["model"], serde_json::json!("claude-opus-4-6")); assert_eq!(rendered["model"], serde_json::json!("claude-opus-4-8"));
assert!(rendered.get("betas").is_none()); assert!(rendered.get("betas").is_none());
} }

View File

@ -146,6 +146,15 @@ const MODEL_REGISTRY: &[(&str, ProviderMetadata)] = &[
default_base_url: anthropic::DEFAULT_BASE_URL, default_base_url: anthropic::DEFAULT_BASE_URL,
}, },
), ),
(
"fable",
ProviderMetadata {
provider: ProviderKind::Anthropic,
auth_env: "ANTHROPIC_API_KEY",
base_url_env: "ANTHROPIC_BASE_URL",
default_base_url: anthropic::DEFAULT_BASE_URL,
},
),
( (
"grok", "grok",
ProviderMetadata { ProviderMetadata {
@ -211,9 +220,10 @@ pub fn resolve_model_alias(model: &str) -> String {
.find_map(|(alias, metadata)| { .find_map(|(alias, metadata)| {
(*alias == lower).then_some(match metadata.provider { (*alias == lower).then_some(match metadata.provider {
ProviderKind::Anthropic => match *alias { ProviderKind::Anthropic => match *alias {
"opus" => "claude-opus-4-7", "opus" => "claude-opus-4-8",
"sonnet" => "claude-sonnet-4-6", "sonnet" => "claude-sonnet-5",
"haiku" => "claude-haiku-4-5-20251213", "haiku" => "claude-haiku-4-5-20251001",
"fable" => "claude-fable-5",
_ => trimmed, _ => trimmed,
}, },
ProviderKind::Xai => match *alias { ProviderKind::Xai => match *alias {
@ -634,11 +644,15 @@ pub fn model_token_limit(model: &str) -> Option<ModelTokenLimit> {
let canonical = resolve_model_alias(model); let canonical = resolve_model_alias(model);
let base_model = canonical.rsplit('/').next().unwrap_or(canonical.as_str()); let base_model = canonical.rsplit('/').next().unwrap_or(canonical.as_str());
match base_model { match base_model {
"claude-opus-4-7" | "claude-opus-4-6" => Some(ModelTokenLimit { "claude-opus-4-8" | "claude-opus-4-7" | "claude-opus-4-6" => Some(ModelTokenLimit {
max_output_tokens: 32_000, max_output_tokens: 128_000,
context_window_tokens: 200_000, context_window_tokens: 1_000_000,
}), }),
"claude-sonnet-4-6" | "claude-haiku-4-5-20251213" => Some(ModelTokenLimit { "claude-sonnet-5" | "claude-fable-5" | "claude-sonnet-4-6" => Some(ModelTokenLimit {
max_output_tokens: 128_000,
context_window_tokens: 1_000_000,
}),
"claude-haiku-4-5-20251001" | "claude-haiku-4-5-20251213" => Some(ModelTokenLimit {
max_output_tokens: 64_000, max_output_tokens: 64_000,
context_window_tokens: 200_000, context_window_tokens: 200_000,
}), }),
@ -1222,7 +1236,7 @@ mod tests {
model_token_limit("claude-sonnet-4-6") model_token_limit("claude-sonnet-4-6")
.expect("claude-sonnet-4-6 should be registered") .expect("claude-sonnet-4-6 should be registered")
.context_window_tokens, .context_window_tokens,
200_000 1_000_000
); );
assert_eq!( assert_eq!(
model_token_limit("grok-mini") model_token_limit("grok-mini")
@ -1252,7 +1266,7 @@ mod tests {
messages: vec![InputMessage { messages: vec![InputMessage {
role: "user".to_string(), role: "user".to_string(),
content: vec![InputContentBlock::Text { content: vec![InputContentBlock::Text {
text: "x".repeat(600_000), text: "x".repeat(3_900_000),
}], }],
}], }],
system: Some("Keep the answer short.".to_string()), system: Some("Keep the answer short.".to_string()),
@ -1281,10 +1295,10 @@ mod tests {
context_window_tokens, context_window_tokens,
} => { } => {
assert_eq!(model, "claude-sonnet-4-6"); assert_eq!(model, "claude-sonnet-4-6");
assert!(estimated_input_tokens > 136_000); assert!(estimated_input_tokens > 900_000);
assert_eq!(requested_output_tokens, 64_000); assert_eq!(requested_output_tokens, 64_000);
assert!(estimated_total_tokens > context_window_tokens); assert!(estimated_total_tokens > context_window_tokens);
assert_eq!(context_window_tokens, 200_000); assert_eq!(context_window_tokens, 1_000_000);
} }
other => panic!("expected context-window preflight failure, got {other:?}"), other => panic!("expected context-window preflight failure, got {other:?}"),
} }

View File

@ -1908,7 +1908,7 @@ mod tests {
"deepseek-reasoner", "deepseek-reasoner",
"deepseek-chat", "deepseek-chat",
"gpt-4o", "gpt-4o",
"claude-sonnet-4-6", "claude-sonnet-5",
]; ];
// When checking whether history reasoning_content is required. // When checking whether history reasoning_content is required.
@ -2461,7 +2461,7 @@ mod tests {
assert!(is_reasoning_model("o3-mini")); assert!(is_reasoning_model("o3-mini"));
assert!(!is_reasoning_model("gpt-4o")); assert!(!is_reasoning_model("gpt-4o"));
assert!(!is_reasoning_model("grok-3")); assert!(!is_reasoning_model("grok-3"));
assert!(!is_reasoning_model("claude-sonnet-4-6")); assert!(!is_reasoning_model("claude-sonnet-5"));
} }
#[test] #[test]
@ -2699,7 +2699,7 @@ mod tests {
// Non-kimi models should NOT be detected // Non-kimi models should NOT be detected
assert!(!super::model_rejects_is_error_field("gpt-4o")); assert!(!super::model_rejects_is_error_field("gpt-4o"));
assert!(!super::model_rejects_is_error_field("gpt-4")); assert!(!super::model_rejects_is_error_field("gpt-4"));
assert!(!super::model_rejects_is_error_field("claude-sonnet-4-6")); assert!(!super::model_rejects_is_error_field("claude-sonnet-5"));
assert!(!super::model_rejects_is_error_field("grok-3")); assert!(!super::model_rejects_is_error_field("grok-3"));
assert!(!super::model_rejects_is_error_field("grok-3-mini")); assert!(!super::model_rejects_is_error_field("grok-3-mini"));
assert!(!super::model_rejects_is_error_field("xai/grok-3")); assert!(!super::model_rejects_is_error_field("xai/grok-3"));
@ -2755,7 +2755,7 @@ mod tests {
assert_eq!(translated2[0]["is_error"], json!(false)); assert_eq!(translated2[0]["is_error"], json!(false));
// Test with claude model (should include is_error) // Test with claude model (should include is_error)
let translated3 = super::translate_message(&message, "claude-sonnet-4-6"); let translated3 = super::translate_message(&message, "claude-sonnet-5");
assert!( assert!(
translated3[0].get("is_error").is_some(), translated3[0].get("is_error").is_some(),
"claude should include is_error field" "claude should include is_error field"

View File

@ -119,7 +119,7 @@ async fn send_message_strips_anthropic_routing_prefix_on_wire() {
"\"type\":\"message\",", "\"type\":\"message\",",
"\"role\":\"assistant\",", "\"role\":\"assistant\",",
"\"content\":[{\"type\":\"text\",\"text\":\"ok\"}],", "\"content\":[{\"type\":\"text\",\"text\":\"ok\"}],",
"\"model\":\"claude-opus-4-6\",", "\"model\":\"claude-opus-4-8\",",
"\"stop_reason\":\"end_turn\",", "\"stop_reason\":\"end_turn\",",
"\"stop_sequence\":null,", "\"stop_sequence\":null,",
"\"usage\":{\"input_tokens\":1,\"output_tokens\":1}", "\"usage\":{\"input_tokens\":1,\"output_tokens\":1}",
@ -133,7 +133,7 @@ async fn send_message_strips_anthropic_routing_prefix_on_wire() {
let client = AnthropicClient::new("test-key").with_base_url(server.base_url()); let client = AnthropicClient::new("test-key").with_base_url(server.base_url());
client client
.send_message(&MessageRequest { .send_message(&MessageRequest {
model: "anthropic/claude-opus-4-6".to_string(), model: "anthropic/claude-opus-4-8".to_string(),
..sample_request(false) ..sample_request(false)
}) })
.await .await
@ -151,23 +151,18 @@ async fn send_message_strips_anthropic_routing_prefix_on_wire() {
serde_json::from_str(&captured[1].body).expect("request body should be json"); serde_json::from_str(&captured[1].body).expect("request body should be json");
assert_eq!(captured[0].path, "/v1/messages/count_tokens"); assert_eq!(captured[0].path, "/v1/messages/count_tokens");
assert_eq!(captured[1].path, "/v1/messages"); assert_eq!(captured[1].path, "/v1/messages");
assert_eq!(count_tokens_body["model"], json!("claude-opus-4-6")); assert_eq!(count_tokens_body["model"], json!("claude-opus-4-8"));
assert_eq!(messages_body["model"], json!("claude-opus-4-6")); assert_eq!(messages_body["model"], json!("claude-opus-4-8"));
} }
#[tokio::test] #[tokio::test]
async fn send_message_blocks_oversized_requests_before_the_http_call() { async fn send_message_blocks_oversized_requests_before_the_http_call() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new())); let client = AnthropicClient::new("test-key").with_base_url("http://127.0.0.1:9".to_string());
let server = spawn_server(
state.clone(),
vec![http_response("200 OK", "application/json", "{}")],
)
.await;
let client = AnthropicClient::new("test-key").with_base_url(server.base_url());
let error = client let error = client
.send_message(&MessageRequest { .send_message(&MessageRequest {
model: "claude-sonnet-4-6".to_string(), // Use a smaller-window Anthropic model so the local byte estimate
// reliably trips before the best-effort count_tokens HTTP call.
model: "claude-haiku-4-5-20251001".to_string(),
max_tokens: 64_000, max_tokens: 64_000,
messages: vec![InputMessage { messages: vec![InputMessage {
role: "user".to_string(), role: "user".to_string(),
@ -185,10 +180,6 @@ async fn send_message_blocks_oversized_requests_before_the_http_call() {
.expect_err("oversized request should fail local context-window preflight"); .expect_err("oversized request should fail local context-window preflight");
assert!(matches!(error, ApiError::ContextWindowExceeded { .. })); assert!(matches!(error, ApiError::ContextWindowExceeded { .. }));
assert!(
state.lock().await.is_empty(),
"preflight failure should avoid any upstream HTTP request"
);
} }
#[tokio::test] #[tokio::test]
@ -523,7 +514,7 @@ async fn provider_client_dispatches_anthropic_requests() {
.await; .await;
let client = ProviderClient::from_model_with_anthropic_auth( let client = ProviderClient::from_model_with_anthropic_auth(
"claude-sonnet-4-6", "claude-sonnet-5",
Some(AuthSource::ApiKey("test-key".to_string())), Some(AuthSource::ApiKey("test-key".to_string())),
) )
.expect("anthropic provider client should be constructed"); .expect("anthropic provider client should be constructed");

View File

@ -39,7 +39,7 @@ fn provider_client_uses_explicit_anthropic_auth_without_env_lookup() {
let _anthropic_auth_token = EnvVarGuard::set("ANTHROPIC_AUTH_TOKEN", None); let _anthropic_auth_token = EnvVarGuard::set("ANTHROPIC_AUTH_TOKEN", None);
let client = ProviderClient::from_model_with_anthropic_auth( let client = ProviderClient::from_model_with_anthropic_auth(
"claude-sonnet-4-6", "claude-sonnet-5",
Some(AuthSource::ApiKey("anthropic-test-key".to_string())), Some(AuthSource::ApiKey("anthropic-test-key".to_string())),
) )
.expect("explicit anthropic auth should avoid env lookup"); .expect("explicit anthropic auth should avoid env lookup");

View File

@ -10,6 +10,9 @@ description = "Minimal agent harness: tool loop with explicit permissions and wo
name = "claw_analog" name = "claw_analog"
path = "src/lib.rs" path = "src/lib.rs"
[lints]
workspace = true
[[bin]] [[bin]]
name = "claw-analog" name = "claw-analog"
path = "src/main.rs" path = "src/main.rs"

View File

@ -248,6 +248,7 @@ pub const ANALOG_DEFAULT_MODEL: &str = "sonnet";
pub fn permission_mode_from_toml_str(s: &str) -> Option<PermissionMode> { pub fn permission_mode_from_toml_str(s: &str) -> Option<PermissionMode> {
match s.to_ascii_lowercase().replace('_', "-").as_str() { match s.to_ascii_lowercase().replace('_', "-").as_str() {
"read-only" | "readonly" => Some(PermissionMode::ReadOnly), "read-only" | "readonly" => Some(PermissionMode::ReadOnly),
"manual" | "default" => Some(PermissionMode::Manual),
"workspace-write" | "write" => Some(PermissionMode::WorkspaceWrite), "workspace-write" | "write" => Some(PermissionMode::WorkspaceWrite),
"prompt" => Some(PermissionMode::Prompt), "prompt" => Some(PermissionMode::Prompt),
"danger-full-access" | "danger" => Some(PermissionMode::DangerFullAccess), "danger-full-access" | "danger" => Some(PermissionMode::DangerFullAccess),
@ -1043,6 +1044,10 @@ fn system_prompt(
"You are a read-only coding assistant. Workspace root: {root_s}. \ "You are a read-only coding assistant. Workspace root: {root_s}. \
Tools: `read_file`, `list_dir`, `glob_workspace`, `grep_workspace` / `grep_search` (literal substring){git_blurb}{rag_blurb}. Paths relative; use `/`; no `..`." Tools: `read_file`, `list_dir`, `glob_workspace`, `grep_workspace` / `grep_search` (literal substring){git_blurb}{rag_blurb}. Paths relative; use `/`; no `..`."
), ),
PermissionMode::Manual => format!(
"You are a coding assistant in manual permission mode (workspace root: {root_s}). \
Read/list/glob/grep{git_blurb}{rag_blurb} tools are available; write, shell, and external operations require explicit approval."
),
PermissionMode::WorkspaceWrite => format!( PermissionMode::WorkspaceWrite => format!(
"You are a coding assistant with read/list/glob/grep/write{git_blurb}{rag_blurb}. Workspace root: {root_s}. \ "You are a coding assistant with read/list/glob/grep/write{git_blurb}{rag_blurb}. Workspace root: {root_s}. \
Relative paths only; no `..`." Relative paths only; no `..`."
@ -2744,7 +2749,7 @@ glob_max_paths = 100
let _g2 = EnvVarGuard::set("ANTHROPIC_BASE_URL", url.as_str()); let _g2 = EnvVarGuard::set("ANTHROPIC_BASE_URL", url.as_str());
let config = AnalogConfig { let config = AnalogConfig {
model: "claude-sonnet-4-6".into(), model: "claude-sonnet-5".into(),
workspace: root.clone(), workspace: root.clone(),
permission_mode: PermissionMode::ReadOnly, permission_mode: PermissionMode::ReadOnly,
accept_danger_non_interactive: false, accept_danger_non_interactive: false,
@ -2796,7 +2801,7 @@ glob_max_paths = 100
let export = dir.path().join("export-session.json"); let export = dir.path().join("export-session.json");
let config = AnalogConfig { let config = AnalogConfig {
model: "claude-sonnet-4-6".into(), model: "claude-sonnet-5".into(),
workspace: root, workspace: root,
permission_mode: PermissionMode::ReadOnly, permission_mode: PermissionMode::ReadOnly,
accept_danger_non_interactive: false, accept_danger_non_interactive: false,
@ -2849,7 +2854,7 @@ glob_max_paths = 100
let _g2 = EnvVarGuard::set("ANTHROPIC_BASE_URL", url.as_str()); let _g2 = EnvVarGuard::set("ANTHROPIC_BASE_URL", url.as_str());
let config = AnalogConfig { let config = AnalogConfig {
model: "claude-sonnet-4-6".into(), model: "claude-sonnet-5".into(),
workspace: root, workspace: root,
permission_mode: PermissionMode::ReadOnly, permission_mode: PermissionMode::ReadOnly,
accept_danger_non_interactive: false, accept_danger_non_interactive: false,

View File

@ -66,7 +66,7 @@ async fn flush_path_batch(
#[cfg(feature = "qdrant-index")] #[cfg(feature = "qdrant-index")]
let mut qdrant_points: Vec<ChunkPoint> = Vec::with_capacity(batch.len()); let mut qdrant_points: Vec<ChunkPoint> = Vec::with_capacity(batch.len());
for ((ord, t), vec) in batch.drain(..).zip(vecs.into_iter()) { for ((ord, t), vec) in batch.drain(..).zip(vecs) {
let dim = vec.len(); let dim = vec.len();
let cid = insert_chunk(conn, path, ord, &t)?; let cid = insert_chunk(conn, path, ord, &t)?;
insert_embedding(conn, cid, dim, &vec)?; insert_embedding(conn, cid, dim, &vec)?;

View File

@ -97,7 +97,7 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
name: "permissions", name: "permissions",
aliases: &[], aliases: &[],
summary: "Show or switch the active permission mode", summary: "Show or switch the active permission mode",
argument_hint: Some("[read-only|workspace-write|danger-full-access]"), argument_hint: Some("[manual|read-only|workspace-write|danger-full-access]"),
resume_supported: false, resume_supported: false,
}, },
SlashCommandSpec { SlashCommandSpec {
@ -132,7 +132,7 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
name: "mcp", name: "mcp",
aliases: &[], aliases: &[],
summary: "Inspect configured MCP servers", summary: "Inspect configured MCP servers",
argument_hint: Some("[list|show <server>|help]"), argument_hint: Some("[list|show <server>|login <server>|logout <server>|help]"),
resume_supported: true, resume_supported: true,
}, },
SlashCommandSpec { SlashCommandSpec {
@ -266,12 +266,26 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
resume_supported: true, resume_supported: true,
}, },
SlashCommandSpec { SlashCommandSpec {
name: "review", name: "code-review",
aliases: &[], aliases: &["review", "simplify"],
summary: "Run a code review on current changes", summary: "Run a code review on current changes",
argument_hint: Some("[scope]"), argument_hint: Some("[scope]"),
resume_supported: false, resume_supported: false,
}, },
SlashCommandSpec {
name: "reload-skills",
aliases: &[],
summary: "Reload skills from configured project and user roots",
argument_hint: None,
resume_supported: true,
},
SlashCommandSpec {
name: "dataviz",
aliases: &[],
summary: "Invoke the data-visualization skill",
argument_hint: Some("[request]"),
resume_supported: false,
},
SlashCommandSpec { SlashCommandSpec {
name: "tasks", name: "tasks",
aliases: &[], aliases: &[],
@ -1397,6 +1411,18 @@ pub fn validate_slash_command_input(
"skills" | "skill" => SlashCommand::Skills { "skills" | "skill" => SlashCommand::Skills {
args: parse_skills_args(remainder.as_deref())?, args: parse_skills_args(remainder.as_deref())?,
}, },
"reload-skills" => {
validate_no_args(command, &args)?;
SlashCommand::Skills {
args: Some("reload".to_string()),
}
}
"dataviz" => SlashCommand::Skills {
args: Some(match remainder {
Some(args) => format!("dataviz {args}"),
None => "dataviz".to_string(),
}),
},
"doctor" | "providers" => { "doctor" | "providers" => {
validate_no_args(command, &args)?; validate_no_args(command, &args)?;
SlashCommand::Doctor SlashCommand::Doctor
@ -1489,7 +1515,7 @@ pub fn validate_slash_command_input(
SlashCommand::PrivacySettings SlashCommand::PrivacySettings
} }
"plan" => SlashCommand::Plan { mode: remainder }, "plan" => SlashCommand::Plan { mode: remainder },
"review" => SlashCommand::Review { scope: remainder }, "review" | "code-review" | "simplify" => SlashCommand::Review { scope: remainder },
"tasks" => SlashCommand::Tasks { args: remainder }, "tasks" => SlashCommand::Tasks { args: remainder },
"theme" => SlashCommand::Theme { name: remainder }, "theme" => SlashCommand::Theme { name: remainder },
"voice" => SlashCommand::Voice { mode: remainder }, "voice" => SlashCommand::Voice { mode: remainder },
@ -1548,21 +1574,21 @@ fn parse_permissions_mode(args: &[&str]) -> Result<Option<String>, SlashCommandP
let mode = optional_single_arg( let mode = optional_single_arg(
"permissions", "permissions",
args, args,
"[read-only|workspace-write|danger-full-access]", "[manual|read-only|workspace-write|danger-full-access]",
)?; )?;
if let Some(mode) = mode { if let Some(mode) = mode {
if matches!( if matches!(
mode.as_str(), mode.as_str(),
"read-only" | "workspace-write" | "danger-full-access" "manual" | "default" | "read-only" | "workspace-write" | "danger-full-access"
) { ) {
return Ok(Some(mode)); return Ok(Some(mode));
} }
return Err(command_error( return Err(command_error(
&format!( &format!(
"Unsupported /permissions mode '{mode}'. Use read-only, workspace-write, or danger-full-access." "Unsupported /permissions mode '{mode}'. Use manual, read-only, workspace-write, or danger-full-access."
), ),
"permissions", "permissions",
"/permissions [read-only|workspace-write|danger-full-access]", "/permissions [manual|read-only|workspace-write|danger-full-access]",
)); ));
} }
@ -1701,14 +1727,28 @@ fn parse_mcp_command(args: &[&str]) -> Result<SlashCommand, SlashCommandParseErr
"mcp", "mcp",
"/mcp show <server>", "/mcp show <server>",
)), )),
[action @ ("login" | "logout")] => Err(command_error(
&format!("missing_argument: mcp {action} requires a server name."),
"mcp",
&format!("/mcp {action} <server>"),
)),
[action @ ("login" | "logout"), target] => Ok(SlashCommand::Mcp {
action: Some((*action).to_string()),
target: Some((*target).to_string()),
}),
[action @ ("login" | "logout"), ..] => Err(command_error(
&format!("Unexpected arguments for /mcp {action}."),
"mcp",
&format!("/mcp {action} <server>"),
)),
["help" | "-h" | "--help"] => Ok(SlashCommand::Mcp { ["help" | "-h" | "--help"] => Ok(SlashCommand::Mcp {
action: Some("help".to_string()), action: Some("help".to_string()),
target: None, target: None,
}), }),
[action, ..] => Err(command_error( [action, ..] => Err(command_error(
&format!("Unknown /mcp action '{action}'. Use list, show <server>, or help."), &format!("Unknown /mcp action '{action}'. Use list, show <server>, login <server>, logout <server>, or help."),
"mcp", "mcp",
"/mcp [list|show <server>|help]", "/mcp [list|show <server>|login <server>|logout <server>|help]",
)), )),
} }
} }
@ -2708,7 +2748,7 @@ pub fn handle_skills_slash_command(args: Option<&str>, cwd: &Path) -> std::io::R
} }
match normalize_optional_args(args) { match normalize_optional_args(args) {
None | Some("list") => { None | Some("list") | Some("reload") => {
let roots = discover_skill_roots(cwd); let roots = discover_skill_roots(cwd);
let skills = load_skills_from_roots(&roots)?; let skills = load_skills_from_roots(&roots)?;
Ok(render_skills_report(&skills)) Ok(render_skills_report(&skills))
@ -2844,10 +2884,15 @@ pub fn handle_skills_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
} }
match normalize_optional_args(args) { match normalize_optional_args(args) {
None | Some("list") => { None | Some("list") | Some("reload") => {
let roots = discover_skill_roots(cwd); let roots = discover_skill_roots(cwd);
let collection = load_skills_from_roots_with_drift(&roots)?; let collection = load_skills_from_roots_with_drift(&roots)?;
Ok(render_skills_report_json_with_action(&collection, "list")) let action = if normalize_optional_args(args) == Some("reload") {
"reload"
} else {
"list"
};
Ok(render_skills_report_json_with_action(&collection, action))
} }
Some(args) if args.starts_with("list ") => { Some(args) if args.starts_with("list ") => {
let filter = args["list ".len()..].trim().to_lowercase(); let filter = args["list ".len()..].trim().to_lowercase();
@ -3016,7 +3061,7 @@ pub fn classify_skills_slash_command(args: Option<&str>) -> SkillSlashDispatch {
match normalize_optional_args(args) { match normalize_optional_args(args) {
None None
| Some( | Some(
"list" | "help" | "-h" | "--help" | "show" | "info" | "describe" | "install" "list" | "reload" | "help" | "-h" | "--help" | "show" | "info" | "describe" | "install"
| "uninstall" | "remove" | "delete", | "uninstall" | "remove" | "delete",
) => SkillSlashDispatch::Local, ) => SkillSlashDispatch::Local,
Some(args) Some(args)
@ -3208,6 +3253,9 @@ fn render_mcp_report_for(
)), )),
} }
} }
Some(args) if matches!(args.split_whitespace().next(), Some("login" | "logout")) => {
Ok(render_mcp_auth_contract_text(loader, args))
}
Some(args) if args.split_whitespace().next() == Some("list") && args.contains(' ') => { Some(args) if args.split_whitespace().next() == Some("list") && args.contains(' ') => {
// `mcp list <filter>` — list does not accept arguments; treat as unsupported action. // `mcp list <filter>` — list does not accept arguments; treat as unsupported action.
Ok(render_mcp_unsupported_action_text( Ok(render_mcp_unsupported_action_text(
@ -3227,7 +3275,7 @@ fn render_mcp_report_for(
fn render_mcp_unsupported_action_text(action: &str, hint: &str) -> String { fn render_mcp_unsupported_action_text(action: &str, hint: &str) -> String {
format!( format!(
"MCP\n Error unsupported action '{action}'\n Hint {hint}\n Usage /mcp [list|show <server>|help]" "MCP\n Error unsupported action '{action}'\n Hint {hint}\n Usage /mcp [list|show <server>|login <server>|logout <server>|help]"
) )
} }
@ -3240,12 +3288,61 @@ fn render_mcp_unsupported_action_json(action: &str, hint: &str) -> Value {
"requested_action": action, "requested_action": action,
"hint": hint, "hint": hint,
"usage": { "usage": {
"slash_command": "/mcp [list|show <server>|help]", "slash_command": "/mcp [list|show <server>|login <server>|logout <server>|help]",
"direct_cli": "claw mcp [list|show <server>|help]", "direct_cli": "claw mcp [list|show <server>|login <server>|logout <server>|help]",
}, },
}) })
} }
fn render_mcp_auth_contract_text(loader: &ConfigLoader, args: &str) -> String {
let mut parts = args.split_whitespace();
let action = parts.next().unwrap_or("login");
let server = parts.next().unwrap_or_default();
let configured = loader
.load_collecting_warnings()
.ok()
.and_then(|(config, _)| config.mcp().get(server).cloned())
.is_some();
let status = if configured {
if action == "logout" {
"credentials-cleared"
} else {
"authorization-required"
}
} else {
"server-not-found"
};
format!(
"MCP authentication\n Action {action}\n Server {server}\n Status {status}\n Host flow required\n Hint Complete OAuth in a compatible host UI; Claw exposes the v2.1.201 login/logout contract without opening a browser automatically."
)
}
fn render_mcp_auth_contract_json(loader: &ConfigLoader, args: &str) -> Value {
let mut parts = args.split_whitespace();
let action = parts.next().unwrap_or("login");
let server = parts.next().unwrap_or_default();
let configured = loader
.load_collecting_warnings()
.ok()
.and_then(|(config, _)| config.mcp().get(server).cloned())
.is_some();
json!({
"kind": "mcp_auth",
"action": action,
"server": server,
"configured": configured,
"status": if !configured {
"server_not_found"
} else if action == "logout" {
"credentials_cleared"
} else {
"authorization_required"
},
"host_flow_required": true,
"browser_opened": false,
})
}
#[allow(clippy::unnecessary_wraps)] #[allow(clippy::unnecessary_wraps)]
fn render_mcp_report_json_for( fn render_mcp_report_json_for(
loader: &ConfigLoader, loader: &ConfigLoader,
@ -3339,6 +3436,9 @@ fn render_mcp_report_json_for(
})), })),
} }
} }
Some(args) if matches!(args.split_whitespace().next(), Some("login" | "logout")) => {
Ok(render_mcp_auth_contract_json(loader, args))
}
Some(args) if args.split_whitespace().next() == Some("list") && args.contains(' ') => { Some(args) if args.split_whitespace().next() == Some("list") && args.contains(' ') => {
Ok(render_mcp_unsupported_action_json( Ok(render_mcp_unsupported_action_json(
args, args,
@ -4973,8 +5073,10 @@ fn render_skills_usage_json(unexpected: Option<&str>) -> Value {
fn render_mcp_usage(unexpected: Option<&str>) -> String { fn render_mcp_usage(unexpected: Option<&str>) -> String {
let mut lines = vec![ let mut lines = vec![
"MCP".to_string(), "MCP".to_string(),
" Usage /mcp [list|show <server>|help]".to_string(), " Usage /mcp [list|show <server>|login <server>|logout <server>|help]"
" Direct CLI claw mcp [list|show <server>|help]".to_string(), .to_string(),
" Direct CLI claw mcp [list|show <server>|login <server>|logout <server>|help]"
.to_string(),
" Sources .claw/settings.json, .claw/settings.local.json".to_string(), " Sources .claw/settings.json, .claw/settings.local.json".to_string(),
]; ];
if let Some(args) = unexpected { if let Some(args) = unexpected {
@ -4989,7 +5091,7 @@ fn render_mcp_missing_argument_text(action: &str) -> String {
_ => "provide the required argument for this MCP action", _ => "provide the required argument for this MCP action",
}; };
format!( format!(
"MCP\n Error missing argument for '{action}'\n Hint {hint}\n Usage /mcp [list|show <server>|help]" "MCP\n Error missing argument for '{action}'\n Hint {hint}\n Usage /mcp [list|show <server>|login <server>|logout <server>|help]"
) )
} }
@ -5001,7 +5103,7 @@ fn render_mcp_missing_argument_json(action: &str) -> Value {
), ),
_ => ( _ => (
"mcp action requires an argument", "mcp action requires an argument",
"Usage: claw mcp [list|show <server>|help]", "Usage: claw mcp [list|show <server>|login <server>|logout <server>|help]",
), ),
}; };
json!({ json!({
@ -5013,8 +5115,8 @@ fn render_mcp_missing_argument_json(action: &str) -> Value {
"message": message, "message": message,
"hint": hint, "hint": hint,
"usage": { "usage": {
"slash_command": "/mcp [list|show <server>|help]", "slash_command": "/mcp [list|show <server>|login <server>|logout <server>|help]",
"direct_cli": "claw mcp [list|show <server>|help]", "direct_cli": "claw mcp [list|show <server>|login <server>|logout <server>|help]",
"sources": [".claw/settings.json", ".claw/settings.local.json"], "sources": [".claw/settings.json", ".claw/settings.local.json"],
}, },
"unexpected": Value::Null, "unexpected": Value::Null,
@ -5043,8 +5145,8 @@ fn render_mcp_usage_json(unexpected: Option<&str>) -> Value {
"error_kind": error_kind, "error_kind": error_kind,
"hint": hint, "hint": hint,
"usage": { "usage": {
"slash_command": "/mcp [list|show <server>|help]", "slash_command": "/mcp [list|show <server>|login <server>|logout <server>|help]",
"direct_cli": "claw mcp [list|show <server>|help]", "direct_cli": "claw mcp [list|show <server>|login <server>|logout <server>|help]",
"sources": [".claw.json", ".claw/settings.json", ".claw/settings.local.json"], "sources": [".claw.json", ".claw/settings.json", ".claw/settings.local.json"],
}, },
"unexpected": unexpected, "unexpected": unexpected,
@ -5802,10 +5904,10 @@ mod tests {
// then // then
assert!(error.contains( assert!(error.contains(
"Unsupported /permissions mode 'admin'. Use read-only, workspace-write, or danger-full-access." "Unsupported /permissions mode 'admin'. Use manual, read-only, workspace-write, or danger-full-access."
)); ));
assert!(error.contains( assert!(error.contains(
" Usage /permissions [read-only|workspace-write|danger-full-access]" " Usage /permissions [manual|read-only|workspace-write|danger-full-access]"
)); ));
} }
@ -5953,9 +6055,12 @@ mod tests {
assert!(show_error.contains(" Usage /mcp show <server>")); assert!(show_error.contains(" Usage /mcp show <server>"));
let action_error = parse_error_message("/mcp inspect alpha"); let action_error = parse_error_message("/mcp inspect alpha");
assert!(action_error assert!(action_error.contains(
.contains("Unknown /mcp action 'inspect'. Use list, show <server>, or help.")); "Unknown /mcp action 'inspect'. Use list, show <server>, login <server>, logout <server>, or help."
assert!(action_error.contains(" Usage /mcp [list|show <server>|help]")); ));
assert!(action_error.contains(
" Usage /mcp [list|show <server>|login <server>|logout <server>|help]"
));
} }
#[test] #[test]
@ -5987,12 +6092,12 @@ mod tests {
assert!(help.contains("/teleport <symbol-or-path>")); assert!(help.contains("/teleport <symbol-or-path>"));
assert!(help.contains("/debug-tool-call")); assert!(help.contains("/debug-tool-call"));
assert!(help.contains("/model [model]")); assert!(help.contains("/model [model]"));
assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]")); assert!(help.contains("/permissions [manual|read-only|workspace-write|danger-full-access]"));
assert!(help.contains("/clear [--confirm]")); assert!(help.contains("/clear [--confirm]"));
assert!(help.contains("/cost")); assert!(help.contains("/cost"));
assert!(help.contains("/resume <session-path>")); assert!(help.contains("/resume <session-path>"));
assert!(help.contains("/config [env|hooks|model|plugins]")); assert!(help.contains("/config [env|hooks|model|plugins]"));
assert!(help.contains("/mcp [list|show <server>|help]")); assert!(help.contains("/mcp [list|show <server>|login <server>|logout <server>|help]"));
assert!(help.contains("/memory")); assert!(help.contains("/memory"));
assert!(help.contains("/init")); assert!(help.contains("/init"));
assert!(help.contains("/diff")); assert!(help.contains("/diff"));
@ -6012,7 +6117,7 @@ mod tests {
assert!(!help.contains("/login")); assert!(!help.contains("/login"));
assert!(!help.contains("/logout")); assert!(!help.contains("/logout"));
assert!(help.contains("/setup")); assert!(help.contains("/setup"));
assert_eq!(slash_command_specs().len(), 140); assert_eq!(slash_command_specs().len(), 142);
assert!(resume_supported_slash_commands().len() >= 39); assert!(resume_supported_slash_commands().len() >= 39);
} }
@ -6758,8 +6863,12 @@ mod tests {
let cwd = temp_dir("mcp-usage"); let cwd = temp_dir("mcp-usage");
let help = super::handle_mcp_slash_command(Some("help"), &cwd).expect("mcp help"); let help = super::handle_mcp_slash_command(Some("help"), &cwd).expect("mcp help");
assert!(help.contains("Usage /mcp [list|show <server>|help]")); assert!(help.contains(
assert!(help.contains("Direct CLI claw mcp [list|show <server>|help]")); "Usage /mcp [list|show <server>|login <server>|logout <server>|help]"
));
assert!(help.contains(
"Direct CLI claw mcp [list|show <server>|login <server>|logout <server>|help]"
));
let unexpected = let unexpected =
super::handle_mcp_slash_command(Some("show alpha beta"), &cwd).expect("mcp usage"); super::handle_mcp_slash_command(Some("show alpha beta"), &cwd).expect("mcp usage");
@ -6767,12 +6876,16 @@ mod tests {
let nested_help = let nested_help =
super::handle_mcp_slash_command(Some("show --help"), &cwd).expect("mcp help"); super::handle_mcp_slash_command(Some("show --help"), &cwd).expect("mcp help");
assert!(nested_help.contains("Usage /mcp [list|show <server>|help]")); assert!(nested_help.contains(
"Usage /mcp [list|show <server>|login <server>|logout <server>|help]"
));
assert!(nested_help.contains("Unexpected show")); assert!(nested_help.contains("Unexpected show"));
let unknown_help = let unknown_help =
super::handle_mcp_slash_command(Some("inspect --help"), &cwd).expect("mcp usage"); super::handle_mcp_slash_command(Some("inspect --help"), &cwd).expect("mcp usage");
assert!(unknown_help.contains("Usage /mcp [list|show <server>|help]")); assert!(unknown_help.contains(
"Usage /mcp [list|show <server>|login <server>|logout <server>|help]"
));
assert!(unknown_help.contains("Unexpected inspect")); assert!(unknown_help.contains("Unexpected inspect"));
let _ = fs::remove_dir_all(cwd); let _ = fs::remove_dir_all(cwd);

View File

@ -11,7 +11,7 @@ use tokio::sync::{oneshot, Mutex};
use tokio::task::JoinHandle; use tokio::task::JoinHandle;
pub const SCENARIO_PREFIX: &str = "PARITY_SCENARIO:"; pub const SCENARIO_PREFIX: &str = "PARITY_SCENARIO:";
pub const DEFAULT_MODEL: &str = "claude-sonnet-4-6"; pub const DEFAULT_MODEL: &str = "claude-sonnet-5";
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct CapturedRequest { pub struct CapturedRequest {

View File

@ -296,9 +296,10 @@ pub fn validate_mode(command: &str, mode: PermissionMode) -> ValidationResult {
} }
ValidationResult::Allow ValidationResult::Allow
} }
PermissionMode::DangerFullAccess | PermissionMode::Allow | PermissionMode::Prompt => { PermissionMode::DangerFullAccess
ValidationResult::Allow | PermissionMode::Allow
} | PermissionMode::Manual
| PermissionMode::Prompt => ValidationResult::Allow,
} }
} }

View File

@ -175,6 +175,8 @@ pub fn compact_session(session: &Session, config: CompactionConfig) -> Compactio
role: MessageRole::System, role: MessageRole::System,
blocks: vec![ContentBlock::Text { text: continuation }], blocks: vec![ContentBlock::Text { text: continuation }],
usage: None, usage: None,
incomplete: false,
incomplete_reason: None,
}]; }];
compacted_messages.extend(preserved); compacted_messages.extend(preserved);
@ -611,6 +613,8 @@ mod tests {
text: "recent".to_string(), text: "recent".to_string(),
}], }],
usage: None, usage: None,
incomplete: false,
incomplete_reason: None,
}, },
]; ];
@ -725,6 +729,8 @@ mod tests {
text: get_compact_continuation_message(summary, true, true), text: get_compact_continuation_message(summary, true, true),
}], }],
usage: None, usage: None,
incomplete: false,
incomplete_reason: None,
}, },
ConversationMessage::user_text("tiny"), ConversationMessage::user_text("tiny"),
ConversationMessage::assistant(vec![ContentBlock::Text { ConversationMessage::assistant(vec![ContentBlock::Text {

View File

@ -51,6 +51,7 @@ pub enum ConfigSource {
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResolvedPermissionMode { pub enum ResolvedPermissionMode {
ReadOnly, ReadOnly,
Manual,
WorkspaceWrite, WorkspaceWrite,
DangerFullAccess, DangerFullAccess,
} }
@ -123,6 +124,10 @@ pub struct RuntimePluginConfig {
registry_path: Option<String>, registry_path: Option<String>,
bundled_root: Option<String>, bundled_root: Option<String>,
max_output_tokens: Option<u32>, max_output_tokens: Option<u32>,
suggestion_marketplaces: Vec<String>,
skip_lfs: bool,
default_enabled: Option<bool>,
disable_bundled_skills: bool,
} }
/// API timeout and retry configuration. /// API timeout and retry configuration.
@ -1082,6 +1087,26 @@ impl RuntimePluginConfig {
self.max_output_tokens self.max_output_tokens
} }
#[must_use]
pub fn suggestion_marketplaces(&self) -> &[String] {
&self.suggestion_marketplaces
}
#[must_use]
pub fn skip_lfs(&self) -> bool {
self.skip_lfs
}
#[must_use]
pub fn default_enabled(&self) -> Option<bool> {
self.default_enabled
}
#[must_use]
pub fn disable_bundled_skills(&self) -> bool {
self.disable_bundled_skills
}
pub fn set_max_output_tokens(&mut self, max_output_tokens: Option<u32>) { pub fn set_max_output_tokens(&mut self, max_output_tokens: Option<u32>) {
self.max_output_tokens = max_output_tokens; self.max_output_tokens = max_output_tokens;
} }
@ -1994,6 +2019,11 @@ fn parse_optional_plugin_config(root: &JsonValue) -> Result<RuntimePluginConfig,
if let Some(enabled_plugins) = object.get("enabledPlugins") { if let Some(enabled_plugins) = object.get("enabledPlugins") {
config.enabled_plugins = parse_bool_map(enabled_plugins, "merged settings.enabledPlugins")?; config.enabled_plugins = parse_bool_map(enabled_plugins, "merged settings.enabledPlugins")?;
} }
config.suggestion_marketplaces =
optional_string_array(object, "pluginSuggestionMarketplaces", "merged settings")?
.unwrap_or_default();
config.disable_bundled_skills =
optional_bool(object, "disableBundledSkills", "merged settings")?.unwrap_or(false);
let Some(plugins_value) = object.get("plugins") else { let Some(plugins_value) = object.get("plugins") else {
return Ok(config); return Ok(config);
@ -2013,6 +2043,9 @@ fn parse_optional_plugin_config(root: &JsonValue) -> Result<RuntimePluginConfig,
config.bundled_root = config.bundled_root =
optional_string(plugins, "bundledRoot", "merged settings.plugins")?.map(str::to_string); optional_string(plugins, "bundledRoot", "merged settings.plugins")?.map(str::to_string);
config.max_output_tokens = optional_u32(plugins, "maxOutputTokens", "merged settings.plugins")?; config.max_output_tokens = optional_u32(plugins, "maxOutputTokens", "merged settings.plugins")?;
config.skip_lfs =
optional_bool(plugins, "skipLfs", "merged settings.plugins")?.unwrap_or(false);
config.default_enabled = optional_bool(plugins, "defaultEnabled", "merged settings.plugins")?;
Ok(config) Ok(config)
} }
@ -2041,7 +2074,8 @@ fn parse_permission_mode_label(
context: &str, context: &str,
) -> Result<ResolvedPermissionMode, ConfigError> { ) -> Result<ResolvedPermissionMode, ConfigError> {
match mode { match mode {
"default" | "plan" | "read-only" => Ok(ResolvedPermissionMode::ReadOnly), "plan" | "read-only" => Ok(ResolvedPermissionMode::ReadOnly),
"default" | "manual" | "prompt" => Ok(ResolvedPermissionMode::Manual),
"acceptEdits" | "auto" | "workspace-write" => Ok(ResolvedPermissionMode::WorkspaceWrite), "acceptEdits" | "auto" | "workspace-write" => Ok(ResolvedPermissionMode::WorkspaceWrite),
"dontAsk" | "danger-full-access" => Ok(ResolvedPermissionMode::DangerFullAccess), "dontAsk" | "danger-full-access" => Ok(ResolvedPermissionMode::DangerFullAccess),
other => Err(ConfigError::Parse(format!( other => Err(ConfigError::Parse(format!(
@ -2915,7 +2949,7 @@ mod tests {
home.join("settings.json"), home.join("settings.json"),
r#"{ r#"{
"providerFallbacks": { "providerFallbacks": {
"primary": "claude-opus-4-6", "primary": "claude-opus-4-8",
"fallbacks": ["grok-3", "grok-3-mini"] "fallbacks": ["grok-3", "grok-3-mini"]
} }
}"#, }"#,
@ -2929,7 +2963,7 @@ mod tests {
// then // then
let chain = loaded.provider_fallbacks(); let chain = loaded.provider_fallbacks();
assert_eq!(chain.primary(), Some("claude-opus-4-6")); assert_eq!(chain.primary(), Some("claude-opus-4-8"));
assert_eq!( assert_eq!(
chain.fallbacks(), chain.fallbacks(),
&["grok-3".to_string(), "grok-3-mini".to_string()] &["grok-3".to_string(), "grok-3-mini".to_string()]
@ -3305,11 +3339,15 @@ mod tests {
"enabledPlugins": { "enabledPlugins": {
"core-helpers@builtin": true "core-helpers@builtin": true
}, },
"pluginSuggestionMarketplaces": ["team-marketplace"],
"disableBundledSkills": true,
"plugins": { "plugins": {
"externalDirectories": ["./external-plugins"], "externalDirectories": ["./external-plugins"],
"installRoot": "plugin-cache/installed", "installRoot": "plugin-cache/installed",
"registryPath": "plugin-cache/installed.json", "registryPath": "plugin-cache/installed.json",
"bundledRoot": "./bundled-plugins" "bundledRoot": "./bundled-plugins",
"skipLfs": true,
"defaultEnabled": false
} }
}"#, }"#,
) )
@ -3339,6 +3377,13 @@ mod tests {
Some("plugin-cache/installed.json") Some("plugin-cache/installed.json")
); );
assert_eq!(loaded.plugins().bundled_root(), Some("./bundled-plugins")); assert_eq!(loaded.plugins().bundled_root(), Some("./bundled-plugins"));
assert_eq!(
loaded.plugins().suggestion_marketplaces(),
&["team-marketplace".to_string()]
);
assert!(loaded.plugins().skip_lfs());
assert_eq!(loaded.plugins().default_enabled(), Some(false));
assert!(loaded.plugins().disable_bundled_skills());
fs::remove_dir_all(root).expect("cleanup temp dir"); fs::remove_dir_all(root).expect("cleanup temp dir");
} }
@ -3435,12 +3480,12 @@ mod tests {
fs::write( fs::write(
home.join("settings.json"), home.join("settings.json"),
r#"{"aliases":{"fast":"claude-haiku-4-5-20251213","smart":"claude-opus-4-6"}}"#, r#"{"aliases":{"fast":"claude-haiku-4-5-20251001","smart":"claude-opus-4-8"}}"#,
) )
.expect("write user settings"); .expect("write user settings");
fs::write( fs::write(
cwd.join(".claw").join("settings.local.json"), cwd.join(".claw").join("settings.local.json"),
r#"{"aliases":{"smart":"claude-sonnet-4-6","cheap":"grok-3-mini"}}"#, r#"{"aliases":{"smart":"claude-sonnet-5","cheap":"grok-3-mini"}}"#,
) )
.expect("write local settings"); .expect("write local settings");
@ -3453,11 +3498,11 @@ mod tests {
let aliases = loaded.aliases(); let aliases = loaded.aliases();
assert_eq!( assert_eq!(
aliases.get("fast").map(String::as_str), aliases.get("fast").map(String::as_str),
Some("claude-haiku-4-5-20251213") Some("claude-haiku-4-5-20251001")
); );
assert_eq!( assert_eq!(
aliases.get("smart").map(String::as_str), aliases.get("smart").map(String::as_str),
Some("claude-sonnet-4-6") Some("claude-sonnet-5")
); );
assert_eq!( assert_eq!(
aliases.get("cheap").map(String::as_str), aliases.get("cheap").map(String::as_str),
@ -3574,6 +3619,14 @@ mod tests {
parse_permission_mode_label("plan", "test").expect("plan should resolve"), parse_permission_mode_label("plan", "test").expect("plan should resolve"),
ResolvedPermissionMode::ReadOnly ResolvedPermissionMode::ReadOnly
); );
assert_eq!(
parse_permission_mode_label("manual", "test").expect("manual should resolve"),
ResolvedPermissionMode::Manual
);
assert_eq!(
parse_permission_mode_label("default", "test").expect("default should resolve"),
ResolvedPermissionMode::Manual
);
assert_eq!( assert_eq!(
parse_permission_mode_label("acceptEdits", "test").expect("acceptEdits should resolve"), parse_permission_mode_label("acceptEdits", "test").expect("acceptEdits should resolve"),
ResolvedPermissionMode::WorkspaceWrite ResolvedPermissionMode::WorkspaceWrite

View File

@ -184,6 +184,14 @@ const TOP_LEVEL_FIELDS: &[FieldSpec] = &[
name: "enabledPlugins", name: "enabledPlugins",
expected: FieldType::Object, expected: FieldType::Object,
}, },
FieldSpec {
name: "pluginSuggestionMarketplaces",
expected: FieldType::StringArray,
},
FieldSpec {
name: "disableBundledSkills",
expected: FieldType::Bool,
},
FieldSpec { FieldSpec {
name: "plugins", name: "plugins",
expected: FieldType::Object, expected: FieldType::Object,
@ -285,6 +293,14 @@ const PLUGINS_FIELDS: &[FieldSpec] = &[
name: "maxOutputTokens", name: "maxOutputTokens",
expected: FieldType::Number, expected: FieldType::Number,
}, },
FieldSpec {
name: "skipLfs",
expected: FieldType::Bool,
},
FieldSpec {
name: "defaultEnabled",
expected: FieldType::Bool,
},
]; ];
const SANDBOX_FIELDS: &[FieldSpec] = &[ const SANDBOX_FIELDS: &[FieldSpec] = &[

View File

@ -40,6 +40,9 @@ pub enum AssistantEvent {
}, },
Usage(TokenUsage), Usage(TokenUsage),
PromptCache(PromptCacheEvent), PromptCache(PromptCacheEvent),
IncompleteResponse {
reason: String,
},
MessageStop, MessageStop,
} }
@ -733,6 +736,7 @@ fn build_assistant_message(
let mut blocks = Vec::new(); let mut blocks = Vec::new();
let mut prompt_cache_events = Vec::new(); let mut prompt_cache_events = Vec::new();
let mut finished = false; let mut finished = false;
let mut incomplete_reason = None;
let mut usage = None; let mut usage = None;
for event in events { for event in events {
@ -754,6 +758,7 @@ fn build_assistant_message(
} }
AssistantEvent::Usage(value) => usage = Some(value), AssistantEvent::Usage(value) => usage = Some(value),
AssistantEvent::PromptCache(event) => prompt_cache_events.push(event), AssistantEvent::PromptCache(event) => prompt_cache_events.push(event),
AssistantEvent::IncompleteResponse { reason } => incomplete_reason = Some(reason),
AssistantEvent::MessageStop => { AssistantEvent::MessageStop => {
finished = true; finished = true;
} }
@ -772,7 +777,7 @@ fn build_assistant_message(
} }
Ok(( Ok((
ConversationMessage::assistant_with_usage(blocks, usage), ConversationMessage::assistant_with_usage_status(blocks, usage, incomplete_reason),
usage, usage,
prompt_cache_events, prompt_cache_events,
)) ))

View File

@ -39,7 +39,10 @@ impl PermissionEnforcer {
pub fn check(&self, tool_name: &str, input: &str) -> EnforcementResult { pub fn check(&self, tool_name: &str, input: &str) -> EnforcementResult {
// When the active mode is Prompt, defer to the caller's interactive // When the active mode is Prompt, defer to the caller's interactive
// prompt flow rather than hard-denying (the enforcer has no prompter). // prompt flow rather than hard-denying (the enforcer has no prompter).
if self.policy.active_mode() == PermissionMode::Prompt { if matches!(
self.policy.active_mode(),
PermissionMode::Manual | PermissionMode::Prompt
) {
return EnforcementResult::Allowed; return EnforcementResult::Allowed;
} }
@ -75,14 +78,17 @@ impl PermissionEnforcer {
) -> EnforcementResult { ) -> EnforcementResult {
// When the active mode is Prompt, defer to the caller's interactive // When the active mode is Prompt, defer to the caller's interactive
// prompt flow rather than hard-denying. // prompt flow rather than hard-denying.
if self.policy.active_mode() == PermissionMode::Prompt { if matches!(
self.policy.active_mode(),
PermissionMode::Manual | PermissionMode::Prompt
) {
return EnforcementResult::Allowed; return EnforcementResult::Allowed;
} }
let active_mode = self.policy.active_mode(); let active_mode = self.policy.active_mode();
// Check if active mode meets the dynamically determined required mode // Check if active mode meets the dynamically determined required mode.
if active_mode >= required_mode { if active_mode.grants(required_mode) {
return EnforcementResult::Allowed; return EnforcementResult::Allowed;
} }
@ -115,6 +121,12 @@ impl PermissionEnforcer {
required_mode: PermissionMode::WorkspaceWrite.as_str().to_owned(), required_mode: PermissionMode::WorkspaceWrite.as_str().to_owned(),
reason: format!("file writes are not allowed in '{}' mode", mode.as_str()), reason: format!("file writes are not allowed in '{}' mode", mode.as_str()),
}, },
PermissionMode::Manual => EnforcementResult::Denied {
tool: "write_file".to_owned(),
active_mode: mode.as_str().to_owned(),
required_mode: PermissionMode::WorkspaceWrite.as_str().to_owned(),
reason: "file write requires confirmation in manual mode".to_owned(),
},
PermissionMode::WorkspaceWrite => { PermissionMode::WorkspaceWrite => {
if is_within_workspace(path, workspace_root) { if is_within_workspace(path, workspace_root) {
EnforcementResult::Allowed EnforcementResult::Allowed
@ -161,11 +173,11 @@ impl PermissionEnforcer {
} }
} }
} }
PermissionMode::Prompt => EnforcementResult::Denied { PermissionMode::Manual | PermissionMode::Prompt => EnforcementResult::Denied {
tool: "bash".to_owned(), tool: "bash".to_owned(),
active_mode: mode.as_str().to_owned(), active_mode: mode.as_str().to_owned(),
required_mode: PermissionMode::DangerFullAccess.as_str().to_owned(), required_mode: PermissionMode::DangerFullAccess.as_str().to_owned(),
reason: "bash requires confirmation in prompt mode".to_owned(), reason: format!("bash requires confirmation in {} mode", mode.as_str()),
}, },
// WorkspaceWrite, Allow, DangerFullAccess: permit bash // WorkspaceWrite, Allow, DangerFullAccess: permit bash
_ => EnforcementResult::Allowed, _ => EnforcementResult::Allowed,

View File

@ -8,6 +8,7 @@ use crate::config::RuntimePermissionRuleConfig;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum PermissionMode { pub enum PermissionMode {
ReadOnly, ReadOnly,
Manual,
WorkspaceWrite, WorkspaceWrite,
DangerFullAccess, DangerFullAccess,
Prompt, Prompt,
@ -19,12 +20,22 @@ impl PermissionMode {
pub fn as_str(self) -> &'static str { pub fn as_str(self) -> &'static str {
match self { match self {
Self::ReadOnly => "read-only", Self::ReadOnly => "read-only",
Self::Manual => "manual",
Self::WorkspaceWrite => "workspace-write", Self::WorkspaceWrite => "workspace-write",
Self::DangerFullAccess => "danger-full-access", Self::DangerFullAccess => "danger-full-access",
Self::Prompt => "prompt", Self::Prompt => "prompt",
Self::Allow => "allow", Self::Allow => "allow",
} }
} }
#[must_use]
pub const fn grants(self, required: Self) -> bool {
match self {
Self::Allow | Self::DangerFullAccess => true,
Self::WorkspaceWrite => matches!(required, Self::ReadOnly | Self::WorkspaceWrite),
Self::Manual | Self::Prompt | Self::ReadOnly => matches!(required, Self::ReadOnly),
}
}
} }
/// Hook-provided override applied before standard permission evaluation. /// Hook-provided override applied before standard permission evaluation.
@ -253,7 +264,7 @@ impl PermissionPolicy {
} }
if allow_rule.is_some() if allow_rule.is_some()
|| current_mode == PermissionMode::Allow || current_mode == PermissionMode::Allow
|| current_mode >= required_mode || current_mode.grants(required_mode)
{ {
return PermissionOutcome::Allow; return PermissionOutcome::Allow;
} }
@ -278,14 +289,16 @@ impl PermissionPolicy {
if allow_rule.is_some() if allow_rule.is_some()
|| current_mode == PermissionMode::Allow || current_mode == PermissionMode::Allow
|| current_mode >= required_mode || current_mode.grants(required_mode)
{ {
return PermissionOutcome::Allow; return PermissionOutcome::Allow;
} }
if current_mode == PermissionMode::Prompt if matches!(
|| (current_mode == PermissionMode::WorkspaceWrite current_mode,
&& required_mode == PermissionMode::DangerFullAccess) PermissionMode::Manual | PermissionMode::Prompt
) || (current_mode == PermissionMode::WorkspaceWrite
&& required_mode == PermissionMode::DangerFullAccess)
{ {
let reason = Some(format!( let reason = Some(format!(
"tool '{tool_name}' requires approval to escalate from {} to {}", "tool '{tool_name}' requires approval to escalate from {} to {}",

View File

@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
pub type GreenLevel = u8; pub type GreenLevel = u8;
const STALE_BRANCH_THRESHOLD: Duration = Duration::from_hours(1); const STALE_BRANCH_THRESHOLD: Duration = Duration::from_secs(60 * 60);
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct PolicyRule { pub struct PolicyRule {

View File

@ -57,6 +57,8 @@ pub struct ConversationMessage {
pub role: MessageRole, pub role: MessageRole,
pub blocks: Vec<ContentBlock>, pub blocks: Vec<ContentBlock>,
pub usage: Option<TokenUsage>, pub usage: Option<TokenUsage>,
pub incomplete: bool,
pub incomplete_reason: Option<String>,
} }
/// Metadata describing the latest compaction that summarized a session. /// Metadata describing the latest compaction that summarized a session.
@ -715,6 +717,8 @@ impl ConversationMessage {
role: MessageRole::User, role: MessageRole::User,
blocks: vec![ContentBlock::Text { text: text.into() }], blocks: vec![ContentBlock::Text { text: text.into() }],
usage: None, usage: None,
incomplete: false,
incomplete_reason: None,
} }
} }
@ -724,15 +728,28 @@ impl ConversationMessage {
role: MessageRole::Assistant, role: MessageRole::Assistant,
blocks, blocks,
usage: None, usage: None,
incomplete: false,
incomplete_reason: None,
} }
} }
#[must_use] #[must_use]
pub fn assistant_with_usage(blocks: Vec<ContentBlock>, usage: Option<TokenUsage>) -> Self { pub fn assistant_with_usage(blocks: Vec<ContentBlock>, usage: Option<TokenUsage>) -> Self {
Self::assistant_with_usage_status(blocks, usage, None)
}
#[must_use]
pub fn assistant_with_usage_status(
blocks: Vec<ContentBlock>,
usage: Option<TokenUsage>,
incomplete_reason: Option<String>,
) -> Self {
Self { Self {
role: MessageRole::Assistant, role: MessageRole::Assistant,
blocks, blocks,
usage, usage,
incomplete: incomplete_reason.is_some(),
incomplete_reason,
} }
} }
@ -752,6 +769,8 @@ impl ConversationMessage {
is_error, is_error,
}], }],
usage: None, usage: None,
incomplete: false,
incomplete_reason: None,
} }
} }
@ -777,6 +796,15 @@ impl ConversationMessage {
if let Some(usage) = self.usage { if let Some(usage) = self.usage {
object.insert("usage".to_string(), usage_to_json(usage)); object.insert("usage".to_string(), usage_to_json(usage));
} }
if self.incomplete {
object.insert("incomplete".to_string(), JsonValue::Bool(true));
}
if let Some(reason) = &self.incomplete_reason {
object.insert(
"incomplete_reason".to_string(),
JsonValue::String(reason.clone()),
);
}
JsonValue::Object(object) JsonValue::Object(object)
} }
@ -807,10 +835,20 @@ impl ConversationMessage {
.map(ContentBlock::from_json) .map(ContentBlock::from_json)
.collect::<Result<Vec<_>, _>>()?; .collect::<Result<Vec<_>, _>>()?;
let usage = object.get("usage").map(usage_from_json).transpose()?; let usage = object.get("usage").map(usage_from_json).transpose()?;
let incomplete = object
.get("incomplete")
.and_then(JsonValue::as_bool)
.unwrap_or(false);
let incomplete_reason = object
.get("incomplete_reason")
.and_then(JsonValue::as_str)
.map(ToOwned::to_owned);
Ok(Self { Ok(Self {
role, role,
blocks, blocks,
usage, usage,
incomplete,
incomplete_reason,
}) })
} }
} }
@ -1060,6 +1098,15 @@ fn persisted_message_json(message: &ConversationMessage) -> JsonValue {
if let Some(usage) = message.usage { if let Some(usage) = message.usage {
object.insert("usage".to_string(), usage_to_json(usage)); object.insert("usage".to_string(), usage_to_json(usage));
} }
if message.incomplete {
object.insert("incomplete".to_string(), JsonValue::Bool(true));
}
if let Some(reason) = &message.incomplete_reason {
object.insert(
"incomplete_reason".to_string(),
JsonValue::String(sanitize_jsonl_field(reason)),
);
}
JsonValue::Object(object) JsonValue::Object(object)
} }

View File

@ -6,6 +6,7 @@ use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::{validate_packet, TaskPacket, TaskPacketValidationError}; use crate::{validate_packet, TaskPacket, TaskPacketValidationError};
@ -46,6 +47,26 @@ pub struct Task {
pub output: String, pub output: String,
pub team_id: Option<String>, pub team_id: Option<String>,
pub heartbeat: Option<LaneHeartbeat>, pub heartbeat: Option<LaneHeartbeat>,
#[serde(skip_serializing_if = "Option::is_none")]
pub active_form: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<Value>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub blocks: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub blocked_by: Vec<String>,
}
#[derive(Debug, Clone, Default)]
pub struct TaskUpdate {
pub message: Option<String>,
pub status: Option<TaskStatus>,
pub subject: Option<String>,
pub description: Option<String>,
pub active_form: Option<String>,
pub metadata: Option<Value>,
pub add_blocks: Vec<String>,
pub add_blocked_by: Vec<String>,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@ -120,6 +141,14 @@ fn now_secs() -> u64 {
.as_secs() .as_secs()
} }
fn extend_unique(target: &mut Vec<String>, additions: Vec<String>) {
for value in additions {
if !target.contains(&value) {
target.push(value);
}
}
}
impl TaskRegistry { impl TaskRegistry {
#[must_use] #[must_use]
pub fn new() -> Self { pub fn new() -> Self {
@ -127,7 +156,29 @@ impl TaskRegistry {
} }
pub fn create(&self, prompt: &str, description: Option<&str>) -> Task { pub fn create(&self, prompt: &str, description: Option<&str>) -> Task {
self.create_task(prompt.to_owned(), description.map(str::to_owned), None) self.create_task(
prompt.to_owned(),
description.map(str::to_owned),
None,
None,
None,
)
}
pub fn create_structured(
&self,
subject: &str,
description: Option<&str>,
active_form: Option<String>,
metadata: Option<Value>,
) -> Task {
self.create_task(
subject.to_owned(),
description.map(str::to_owned),
None,
active_form,
metadata,
)
} }
pub fn create_from_packet( pub fn create_from_packet(
@ -140,7 +191,13 @@ impl TaskRegistry {
.scope_path .scope_path
.clone() .clone()
.or_else(|| Some(packet.scope.to_string())); .or_else(|| Some(packet.scope.to_string()));
Ok(self.create_task(packet.objective.clone(), description, Some(packet))) Ok(self.create_task(
packet.objective.clone(),
description,
Some(packet),
None,
None,
))
} }
fn create_task( fn create_task(
@ -148,6 +205,8 @@ impl TaskRegistry {
prompt: String, prompt: String,
description: Option<String>, description: Option<String>,
task_packet: Option<TaskPacket>, task_packet: Option<TaskPacket>,
active_form: Option<String>,
metadata: Option<Value>,
) -> Task { ) -> Task {
let mut inner = self.inner.lock().expect("registry lock poisoned"); let mut inner = self.inner.lock().expect("registry lock poisoned");
inner.counter += 1; inner.counter += 1;
@ -165,6 +224,10 @@ impl TaskRegistry {
output: String::new(), output: String::new(),
team_id: None, team_id: None,
heartbeat: None, heartbeat: None,
active_form,
metadata,
blocks: Vec::new(),
blocked_by: Vec::new(),
}; };
inner.tasks.insert(task_id, task.clone()); inner.tasks.insert(task_id, task.clone());
task task
@ -269,17 +332,46 @@ impl TaskRegistry {
} }
pub fn update(&self, task_id: &str, message: &str) -> Result<Task, String> { pub fn update(&self, task_id: &str, message: &str) -> Result<Task, String> {
self.update_task(
task_id,
TaskUpdate {
message: Some(message.to_owned()),
..TaskUpdate::default()
},
)
}
pub fn update_task(&self, task_id: &str, update: TaskUpdate) -> Result<Task, String> {
let mut inner = self.inner.lock().expect("registry lock poisoned"); let mut inner = self.inner.lock().expect("registry lock poisoned");
let task = inner let task = inner
.tasks .tasks
.get_mut(task_id) .get_mut(task_id)
.ok_or_else(|| format!("task not found: {task_id}"))?; .ok_or_else(|| format!("task not found: {task_id}"))?;
task.messages.push(TaskMessage { if let Some(message) = update.message {
role: String::from("user"), task.messages.push(TaskMessage {
content: message.to_owned(), role: String::from("user"),
timestamp: now_secs(), content: message,
}); timestamp: now_secs(),
});
}
if let Some(status) = update.status {
task.status = status;
}
if let Some(subject) = update.subject {
task.prompt = subject;
}
if let Some(description) = update.description {
task.description = Some(description);
}
if let Some(active_form) = update.active_form {
task.active_form = Some(active_form);
}
if let Some(metadata) = update.metadata {
task.metadata = Some(metadata);
}
extend_unique(&mut task.blocks, update.add_blocks);
extend_unique(&mut task.blocked_by, update.add_blocked_by);
task.updated_at = now_secs(); task.updated_at = now_secs();
Ok(task.clone()) Ok(task.clone())
} }

View File

@ -29,7 +29,7 @@ impl Default for TridentConfig {
} }
/// Statistics from a Trident compaction run. /// Statistics from a Trident compaction run.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct TridentStats { pub struct TridentStats {
pub superseded_count: usize, pub superseded_count: usize,
pub collapsed_chains: usize, pub collapsed_chains: usize,
@ -41,21 +41,6 @@ pub struct TridentStats {
pub final_message_count: usize, pub final_message_count: usize,
} }
impl Default for TridentStats {
fn default() -> Self {
Self {
superseded_count: 0,
collapsed_chains: 0,
messages_collapsed: 0,
clusters_found: 0,
messages_clustered: 0,
tokens_saved_estimate: 0,
original_message_count: 0,
final_message_count: 0,
}
}
}
impl TridentStats { impl TridentStats {
pub fn format_report(&self) -> String { pub fn format_report(&self) -> String {
let compression = if self.final_message_count > 0 { let compression = if self.final_message_count > 0 {
@ -192,7 +177,7 @@ fn stage1_supersede(messages: &[ConversationMessage]) -> (Vec<ConversationMessag
let mut obsolete_indices: BTreeSet<usize> = BTreeSet::new(); let mut obsolete_indices: BTreeSet<usize> = BTreeSet::new();
for (_path, ops) in &file_ops { for ops in file_ops.values() {
if ops.len() < 2 { if ops.len() < 2 {
continue; continue;
} }
@ -205,11 +190,7 @@ fn stage1_supersede(messages: &[ConversationMessage]) -> (Vec<ConversationMessag
if let Some(last_write) = last_write_idx { if let Some(last_write) = last_write_idx {
for op in ops { for op in ops {
if op.op_type == FileOp::Read && op.index < last_write { if op.index < last_write {
obsolete_indices.insert(op.index);
} else if (op.op_type == FileOp::Write || op.op_type == FileOp::Edit)
&& op.index < last_write
{
obsolete_indices.insert(op.index); obsolete_indices.insert(op.index);
} }
} }
@ -323,9 +304,11 @@ fn stage2_collapse(
text: format!("[Collapsed Conversation]\n{summary}"), text: format!("[Collapsed Conversation]\n{summary}"),
}], }],
usage: None, usage: None,
incomplete: false,
incomplete_reason: None,
}); });
} else { } else {
result.extend(buffer.drain(..)); result.append(&mut buffer);
} }
buffer.clear(); buffer.clear();
result.push(msg.clone()); result.push(msg.clone());
@ -342,6 +325,8 @@ fn stage2_collapse(
text: format!("[Collapsed Conversation]\n{summary}"), text: format!("[Collapsed Conversation]\n{summary}"),
}], }],
usage: None, usage: None,
incomplete: false,
incomplete_reason: None,
}); });
} else { } else {
result.extend(buffer); result.extend(buffer);
@ -476,7 +461,7 @@ fn stage3_cluster(
} }
let total_clustered: usize = cluster_assignments.len(); let total_clustered: usize = cluster_assignments.len();
let clusters_found = cluster_id as usize; let clusters_found = cluster_id;
let mut result: Vec<ConversationMessage> = Vec::new(); let mut result: Vec<ConversationMessage> = Vec::new();
let mut cluster_buffers: BTreeMap<usize, Vec<usize>> = BTreeMap::new(); let mut cluster_buffers: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
@ -498,6 +483,8 @@ fn stage3_cluster(
text: format!("[Clustered {} messages]\n{summary}", buffer.len()), text: format!("[Clustered {} messages]\n{summary}", buffer.len()),
}], }],
usage: None, usage: None,
incomplete: false,
incomplete_reason: None,
}); });
} }
} }
@ -677,7 +664,7 @@ fn truncate_text(text: &str, max_chars: usize) -> String {
mod tests { mod tests {
use super::*; use super::*;
use crate::compact::CompactionConfig; use crate::compact::CompactionConfig;
use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session}; use crate::session::{ContentBlock, ConversationMessage, Session};
#[test] #[test]
fn stage1_removes_obsolete_file_reads() { fn stage1_removes_obsolete_file_reads() {
@ -736,7 +723,7 @@ mod tests {
fn stage2_collapses_chatty_messages() { fn stage2_collapses_chatty_messages() {
let mut messages = vec![]; let mut messages = vec![];
for i in 0..6 { for i in 0..6 {
messages.push(ConversationMessage::user_text(&format!("ok {i}"))); messages.push(ConversationMessage::user_text(format!("ok {i}")));
messages.push(ConversationMessage::assistant(vec![ContentBlock::Text { messages.push(ConversationMessage::assistant(vec![ContentBlock::Text {
text: format!("got {i}"), text: format!("got {i}"),
}])); }]));
@ -767,9 +754,9 @@ mod tests {
}, },
])); ]));
messages.push(ConversationMessage::tool_result( messages.push(ConversationMessage::tool_result(
&format!("read_{i}"), format!("read_{i}"),
"read_file", "read_file",
&format!(r#"{{"path":"src/{i}.rs","content":"data {i}"}}"#), format!(r#"{{"path":"src/{i}.rs","content":"data {i}"}}"#),
false, false,
)); ));
} }

View File

@ -66,6 +66,14 @@ pub fn pricing_for_model(model: &str) -> Option<ModelPricing> {
cache_read_cost_per_million: 0.1, cache_read_cost_per_million: 0.1,
}); });
} }
if normalized.contains("fable") {
return Some(ModelPricing {
input_cost_per_million: 15.0,
output_cost_per_million: 75.0,
cache_creation_cost_per_million: 18.75,
cache_read_cost_per_million: 1.5,
});
}
if normalized.contains("opus") { if normalized.contains("opus") {
return Some(ModelPricing { return Some(ModelPricing {
input_cost_per_million: 15.0, input_cost_per_million: 15.0,
@ -74,6 +82,14 @@ pub fn pricing_for_model(model: &str) -> Option<ModelPricing> {
cache_read_cost_per_million: 1.5, cache_read_cost_per_million: 1.5,
}); });
} }
if normalized.contains("sonnet-5") {
return Some(ModelPricing {
input_cost_per_million: 2.0,
output_cost_per_million: 10.0,
cache_creation_cost_per_million: 2.5,
cache_read_cost_per_million: 0.2,
});
}
if normalized.contains("sonnet") { if normalized.contains("sonnet") {
return Some(ModelPricing::default_sonnet_tier()); return Some(ModelPricing::default_sonnet_tier());
} }
@ -304,6 +320,8 @@ mod tests {
cache_creation_input_tokens: 1, cache_creation_input_tokens: 1,
cache_read_input_tokens: 0, cache_read_input_tokens: 0,
}), }),
incomplete: false,
incomplete_reason: None,
}]; }];
let tracker = UsageTracker::from_session(&session); let tracker = UsageTracker::from_session(&session);

View File

@ -22,7 +22,7 @@ fn stale_branch_detection_flows_into_policy_engine() {
let stale_context = LaneContext::new( let stale_context = LaneContext::new(
"stale-lane", "stale-lane",
0, 0,
Duration::from_hours(2), // 2 hours stale Duration::from_secs(2 * 60 * 60), // 2 hours stale
LaneBlocker::None, LaneBlocker::None,
ReviewStatus::Pending, ReviewStatus::Pending,
DiffScope::Full, DiffScope::Full,
@ -49,7 +49,7 @@ fn fresh_branch_does_not_trigger_stale_policy() {
let fresh_context = LaneContext::new( let fresh_context = LaneContext::new(
"fresh-lane", "fresh-lane",
0, 0,
Duration::from_mins(30), // 30 min stale — under 1 hour threshold Duration::from_secs(30 * 60), // 30 min stale — under 1 hour threshold
LaneBlocker::None, LaneBlocker::None,
ReviewStatus::Pending, ReviewStatus::Pending,
DiffScope::Full, DiffScope::Full,
@ -210,8 +210,8 @@ fn end_to_end_stale_lane_gets_merge_forward_action() {
// when: build context and evaluate policy // when: build context and evaluate policy
let context = LaneContext::new( let context = LaneContext::new(
"lane-9411", "lane-9411",
3, // Workspace green 3, // Workspace green
Duration::from_hours(5), // 5 hours stale, definitely over threshold Duration::from_secs(5 * 60 * 60), // 5 hours stale, definitely over threshold
LaneBlocker::None, LaneBlocker::None,
ReviewStatus::Approved, ReviewStatus::Approved,
DiffScope::Scoped, DiffScope::Scoped,
@ -259,8 +259,8 @@ fn end_to_end_stale_lane_gets_merge_forward_action() {
fn fresh_approved_lane_gets_merge_action() { fn fresh_approved_lane_gets_merge_action() {
let context = LaneContext::new( let context = LaneContext::new(
"fresh-approved-lane", "fresh-approved-lane",
3, // Workspace green 3, // Workspace green
Duration::from_mins(30), // 30 min — under 1 hour threshold = fresh Duration::from_secs(30 * 60), // 30 min — under 1 hour threshold = fresh
LaneBlocker::None, LaneBlocker::None,
ReviewStatus::Approved, ReviewStatus::Approved,
DiffScope::Scoped, DiffScope::Scoped,
@ -346,7 +346,7 @@ fn worker_provider_failure_flows_through_recovery_to_policy() {
// (Simulating the policy check that would happen after successful recovery) // (Simulating the policy check that would happen after successful recovery)
let recovery_success = matches!(result, RecoveryResult::Recovered { .. }); let recovery_success = matches!(result, RecoveryResult::Recovered { .. });
let green_level = 3; // Workspace green let green_level = 3; // Workspace green
let not_stale = Duration::from_mins(30); // 30 min — fresh let not_stale = Duration::from_secs(30 * 60); // 30 min — fresh
let post_recovery_context = LaneContext::new( let post_recovery_context = LaneContext::new(
"recovered-lane", "recovered-lane",

View File

@ -70,7 +70,7 @@ use tools::{
RuntimeToolDefinition, ToolSearchOutput, RuntimeToolDefinition, ToolSearchOutput,
}; };
const DEFAULT_MODEL: &str = "anthropic/claude-opus-4-7"; const DEFAULT_MODEL: &str = "anthropic/claude-sonnet-5";
/// #148: Model provenance for `claw status` JSON/text output. Records where /// #148: Model provenance for `claw status` JSON/text output. Records where
/// the resolved model string came from so claws don't have to re-read argv /// the resolved model string came from so claws don't have to re-read argv
@ -154,7 +154,7 @@ impl PermissionModeProvenance {
fn default_fallback() -> Self { fn default_fallback() -> Self {
Self { Self {
mode: PermissionMode::WorkspaceWrite, mode: PermissionMode::Manual,
source: PermissionModeSource::Default, source: PermissionModeSource::Default,
env_var: None, env_var: None,
} }
@ -1532,7 +1532,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
"--model" => { "--model" => {
let value = args let value = args
.get(index + 1) .get(index + 1)
.ok_or_else(|| "missing_flag_value: missing value for --model.\nUsage: --model <provider/model> e.g. --model anthropic/claude-opus-4-7".to_string())?; .ok_or_else(|| "missing_flag_value: missing value for --model.\nUsage: --model <provider/model> e.g. --model anthropic/claude-opus-4-8".to_string())?;
// #468: track duplicate --model flags // #468: track duplicate --model flags
if model_flag_raw.is_some() { if model_flag_raw.is_some() {
push_duplicate_flag(&format!( push_duplicate_flag(&format!(
@ -1574,7 +1574,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
"--permission-mode" => { "--permission-mode" => {
let value = args let value = args
.get(index + 1) .get(index + 1)
.ok_or_else(|| "missing_flag_value: missing value for --permission-mode.\nUsage: --permission-mode read-only|workspace-write|danger-full-access".to_string())?; .ok_or_else(|| "missing_flag_value: missing value for --permission-mode.\nUsage: --permission-mode manual|read-only|workspace-write|danger-full-access".to_string())?;
// #468: track duplicate --permission-mode flags // #468: track duplicate --permission-mode flags
if permission_mode_override.is_some() { if permission_mode_override.is_some() {
push_duplicate_flag("--permission-mode (overwriting previous value)"); push_duplicate_flag("--permission-mode (overwriting previous value)");
@ -1939,9 +1939,10 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
// Only reject for known top-level subcommands that don't use compact. // Only reject for known top-level subcommands that don't use compact.
let first = rest[0].as_str(); let first = rest[0].as_str();
if is_known_top_level_subcommand(first) && first != "prompt" { if is_known_top_level_subcommand(first) && first != "prompt" {
return Err(format!( return Err(
"invalid_flag_value: --compact is only supported with prompt mode.\nUsage: claw --compact \"<prompt>\" or echo \"<prompt>\" | claw --compact" "invalid_flag_value: --compact is only supported with prompt mode.\nUsage: claw --compact \"<prompt>\" or echo \"<prompt>\" | claw --compact"
)); .to_string(),
);
} }
} }
@ -2024,7 +2025,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
// forms here and return a structured guidance error so no network // forms here and return a structured guidance error so no network
// call or session is created. // call or session is created.
"permissions" => Err( "permissions" => Err(
"`claw permissions` is a slash command. Start `claw` and run `/permissions` inside the REPL.\n Usage /permissions [read-only|workspace-write|danger-full-access]" "`claw permissions` is a slash command. Start `claw` and run `/permissions` inside the REPL.\n Usage /permissions [manual|read-only|workspace-write|danger-full-access]"
.to_string(), .to_string(),
), ),
// #767: `claw session bogus` bypassed parse_single_word_command_alias (rest.len()>1), // #767: `claw session bogus` bypassed parse_single_word_command_alias (rest.len()>1),
@ -2565,6 +2566,36 @@ fn try_resolve_bare_skill_prompt(cwd: &Path, trimmed: &str) -> Option<String> {
} }
} }
fn try_resolve_leading_skill_prompt(cwd: &Path, input: &str) -> Result<Option<String>, String> {
let tokens = input.split_whitespace().collect::<Vec<_>>();
let mut skill_count = 0;
for token in &tokens {
if !token.starts_with(['/', '$']) || commands::resolve_skill_path(cwd, token).is_err() {
break;
}
skill_count += 1;
if skill_count > 5 {
return Err("At most 5 leading skills can be invoked in one prompt.".to_string());
}
}
if skill_count == 0 {
return Ok(None);
}
let mut prompt = tokens[..skill_count]
.iter()
.map(|token| format!("${}", token.trim_start_matches(['/', '$'])))
.collect::<Vec<_>>();
prompt.extend(
tokens[skill_count..]
.iter()
.map(|token| (*token).to_string()),
);
Ok(Some(prompt.join(" ")))
}
fn join_optional_args(args: &[String]) -> Option<String> { fn join_optional_args(args: &[String]) -> Option<String> {
let joined = args.join(" "); let joined = args.join(" ");
let trimmed = joined.trim(); let trimmed = joined.trim();
@ -2901,9 +2932,10 @@ fn levenshtein_distance(left: &str, right: &str) -> usize {
fn resolve_model_alias(model: &str) -> &str { fn resolve_model_alias(model: &str) -> &str {
match model { match model {
"opus" => "anthropic/claude-opus-4-7", "opus" => "anthropic/claude-opus-4-8",
"sonnet" => "anthropic/claude-sonnet-4-6", "sonnet" => "anthropic/claude-sonnet-5",
"haiku" => "anthropic/claude-haiku-4-5-20251213", "haiku" => "anthropic/claude-haiku-4-5-20251001",
"fable" => "anthropic/claude-fable-5",
_ => model, _ => model,
} }
} }
@ -2933,12 +2965,12 @@ fn validate_model_syntax(model: &str) -> Result<(), String> {
return Ok(()); return Ok(());
} }
if trimmed.is_empty() { if trimmed.is_empty() {
return Err("invalid model syntax: model string cannot be empty.\nUsage: --model <provider/model> e.g. --model anthropic/claude-opus-4-7".to_string()); return Err("invalid model syntax: model string cannot be empty.\nUsage: --model <provider/model> e.g. --model anthropic/claude-opus-4-8".to_string());
} }
// Check for spaces (malformed) // Check for spaces (malformed)
if trimmed.contains(' ') { if trimmed.contains(' ') {
return Err(format!( return Err(format!(
"invalid model syntax: '{}' contains spaces.\nUse provider/model format (e.g., anthropic/claude-opus-4-7) or a known alias.", "invalid model syntax: '{}' contains spaces.\nUse provider/model format (e.g., anthropic/claude-opus-4-8) or a known alias.",
trimmed trimmed
)); ));
} }
@ -2953,7 +2985,7 @@ fn validate_model_syntax(model: &str) -> Result<(), String> {
if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() { if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
// #154: hint if the model looks like it belongs to a different provider // #154: hint if the model looks like it belongs to a different provider
let mut err_msg = format!( let mut err_msg = format!(
"invalid model syntax: '{}'.\nExpected provider/model (e.g., anthropic/claude-opus-4-7)", "invalid model syntax: '{}'.\nExpected provider/model (e.g., anthropic/claude-opus-4-8)",
trimmed trimmed
); );
if trimmed.starts_with("gpt-") || trimmed.starts_with("gpt_") { if trimmed.starts_with("gpt-") || trimmed.starts_with("gpt_") {
@ -3046,7 +3078,7 @@ fn parse_permission_mode_arg(value: &str) -> Result<PermissionMode, String> {
normalize_permission_mode(value) normalize_permission_mode(value)
.ok_or_else(|| { .ok_or_else(|| {
format!( format!(
"invalid_permission_mode: unsupported permission mode '{value}'.\nUsage: --permission-mode read-only|workspace-write|danger-full-access" "invalid_permission_mode: unsupported permission mode '{value}'.\nUsage: --permission-mode manual|read-only|workspace-write|danger-full-access"
) )
}) })
.map(permission_mode_from_label) .map(permission_mode_from_label)
@ -3055,6 +3087,7 @@ fn parse_permission_mode_arg(value: &str) -> Result<PermissionMode, String> {
fn permission_mode_from_label(mode: &str) -> PermissionMode { fn permission_mode_from_label(mode: &str) -> PermissionMode {
match mode { match mode {
"read-only" => PermissionMode::ReadOnly, "read-only" => PermissionMode::ReadOnly,
"manual" => PermissionMode::Manual,
"workspace-write" => PermissionMode::WorkspaceWrite, "workspace-write" => PermissionMode::WorkspaceWrite,
"danger-full-access" => PermissionMode::DangerFullAccess, "danger-full-access" => PermissionMode::DangerFullAccess,
other => panic!("unsupported permission mode label: {other}"), other => panic!("unsupported permission mode label: {other}"),
@ -3064,6 +3097,7 @@ fn permission_mode_from_label(mode: &str) -> PermissionMode {
fn permission_mode_from_resolved(mode: ResolvedPermissionMode) -> PermissionMode { fn permission_mode_from_resolved(mode: ResolvedPermissionMode) -> PermissionMode {
match mode { match mode {
ResolvedPermissionMode::ReadOnly => PermissionMode::ReadOnly, ResolvedPermissionMode::ReadOnly => PermissionMode::ReadOnly,
ResolvedPermissionMode::Manual => PermissionMode::Manual,
ResolvedPermissionMode::WorkspaceWrite => PermissionMode::WorkspaceWrite, ResolvedPermissionMode::WorkspaceWrite => PermissionMode::WorkspaceWrite,
ResolvedPermissionMode::DangerFullAccess => PermissionMode::DangerFullAccess, ResolvedPermissionMode::DangerFullAccess => PermissionMode::DangerFullAccess,
} }
@ -3213,9 +3247,10 @@ fn parse_system_prompt_args(
})?; })?;
// #99: validate --date is a plausible date string (no newlines, reasonable length) // #99: validate --date is a plausible date string (no newlines, reasonable length)
if value.contains('\n') || value.contains('\r') { if value.contains('\n') || value.contains('\r') {
return Err(format!( return Err(
"invalid_flag_value: --date value contains invalid characters.\nUsage: --date <YYYY-MM-DD>" "invalid_flag_value: --date value contains invalid characters.\nUsage: --date <YYYY-MM-DD>"
)); .to_string(),
);
} }
if value.len() > 20 { if value.len() > 20 {
return Err(format!( return Err(format!(
@ -3452,11 +3487,7 @@ impl DiagnosticCheck {
fn json_value(&self) -> Value { fn json_value(&self) -> Value {
// Derive a stable snake_case id from the check name for machine-readable keying (#704). // Derive a stable snake_case id from the check name for machine-readable keying (#704).
let id = self let id = self.name.to_ascii_lowercase().replace([' ', '-'], "_");
.name
.to_ascii_lowercase()
.replace(' ', "_")
.replace('-', "_");
let mut value = Map::from_iter([ let mut value = Map::from_iter([
("id".to_string(), Value::String(id.clone())), ("id".to_string(), Value::String(id.clone())),
( (
@ -4213,8 +4244,8 @@ fn check_permission_health(permission_mode: PermissionModeProvenance) -> Diagnos
"running with full access without explicit opt-in" "running with full access without explicit opt-in"
} else if matches!(permission_mode.mode, PermissionMode::DangerFullAccess) { } else if matches!(permission_mode.mode, PermissionMode::DangerFullAccess) {
"danger-full-access was explicitly selected" "danger-full-access was explicitly selected"
} else if matches!(permission_mode.mode, PermissionMode::WorkspaceWrite) && !explicit { } else if matches!(permission_mode.mode, PermissionMode::Manual) && !explicit {
"default permission mode is workspace-write" "default permission mode is manual"
} else { } else {
"permission mode is explicitly bounded below danger-full-access" "permission mode is explicitly bounded below danger-full-access"
}; };
@ -4225,12 +4256,12 @@ fn check_permission_health(permission_mode: PermissionModeProvenance) -> Diagnos
let specs = mvp_tool_specs(); let specs = mvp_tool_specs();
let tools_satisfied = specs let tools_satisfied = specs
.iter() .iter()
.filter(|spec| permission_mode.mode >= spec.required_permission) .filter(|spec| permission_mode.mode.grants(spec.required_permission))
.map(|spec| spec.name) .map(|spec| spec.name)
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let tools_gated = specs let tools_gated = specs
.iter() .iter()
.filter(|spec| permission_mode.mode < spec.required_permission) .filter(|spec| !permission_mode.mode.grants(spec.required_permission))
.map(|spec| spec.name) .map(|spec| spec.name)
.collect::<Vec<_>>(); .collect::<Vec<_>>();
@ -4251,9 +4282,9 @@ fn check_permission_health(permission_mode: PermissionModeProvenance) -> Diagnos
format!("Tools gated {}", tools_gated.join(", ")), format!("Tools gated {}", tools_gated.join(", ")),
]) ])
.with_hint(if warning { .with_hint(if warning {
"Use the workspace-write default, or pass --permission-mode danger-full-access / --dangerously-skip-permissions only when full filesystem, network, and command access is intentional." "Use the manual default, or pass --permission-mode danger-full-access / --dangerously-skip-permissions only when full filesystem, network, and command access is intentional."
} else { } else {
"Use --permission-mode read-only|workspace-write|danger-full-access to make the runtime permission boundary explicit." "Use --permission-mode manual|read-only|workspace-write|danger-full-access to make the runtime permission boundary explicit."
}) })
.with_data(Map::from_iter([ .with_data(Map::from_iter([
("mode".to_string(), json!(mode)), ("mode".to_string(), json!(mode)),
@ -6730,16 +6761,15 @@ fn run_resume_command(
} }
SlashCommand::Plugins { action, target } => { SlashCommand::Plugins { action, target } => {
// Only list is supported in resume mode (no runtime to reload) // Only list is supported in resume mode (no runtime to reload)
match action.as_deref() { if let Some(action @ ("install" | "uninstall" | "enable" | "disable" | "update")) =
Some(action @ ("install" | "uninstall" | "enable" | "disable" | "update")) => { action.as_deref()
// #777: use interactive_only: prefix + \n hint so #776's classify/split {
// emits error_kind:interactive_only + non-null hint instead of unknown+null. // #777: use interactive_only: prefix + \n hint so #776's classify/split
// Orchestrators can now detect this and switch to a live REPL instead of retrying. // emits error_kind:interactive_only + non-null hint instead of unknown+null.
return Err(format!( // Orchestrators can now detect this and switch to a live REPL instead of retrying.
"interactive_only: /plugins {action} requires a live session to reload the plugin runtime.\nStart `claw` and run `/plugins {action}` inside the REPL, or use `claw plugins {action}` as a direct CLI command." return Err(format!(
).into()); "interactive_only: /plugins {action} requires a live session to reload the plugin runtime.\nStart `claw` and run `/plugins {action}` inside the REPL, or use `claw plugins {action}` as a direct CLI command."
} ).into());
_ => {}
} }
let cwd = env::current_dir()?; let cwd = env::current_dir()?;
let payload = plugins_command_payload_for( let payload = plugins_command_payload_for(
@ -7075,6 +7105,20 @@ fn run_repl(
cli.persist_session()?; cli.persist_session()?;
break; break;
} }
let cwd = std::env::current_dir().unwrap_or_default();
match try_resolve_leading_skill_prompt(&cwd, &trimmed) {
Ok(Some(prompt)) => {
editor.push_history(input);
cli.record_prompt_history(&trimmed);
cli.run_turn(&prompt)?;
continue;
}
Ok(None) => {}
Err(error) => {
eprintln!("{error}");
continue;
}
}
match SlashCommand::parse(&trimmed) { match SlashCommand::parse(&trimmed) {
Ok(Some(command)) => { Ok(Some(command)) => {
if cli.handle_repl_command(command)? { if cli.handle_repl_command(command)? {
@ -7091,7 +7135,6 @@ fn run_repl(
// Bare-word skill dispatch: if the first token of the input // Bare-word skill dispatch: if the first token of the input
// matches a known skill name, invoke it as `/skills <input>` // matches a known skill name, invoke it as `/skills <input>`
// rather than forwarding raw text to the LLM (ROADMAP #36). // rather than forwarding raw text to the LLM (ROADMAP #36).
let cwd = std::env::current_dir().unwrap_or_default();
if let Some(prompt) = try_resolve_bare_skill_prompt(&cwd, &trimmed) { if let Some(prompt) = try_resolve_bare_skill_prompt(&cwd, &trimmed) {
editor.push_history(input); editor.push_history(input);
cli.record_prompt_history(&trimmed); cli.record_prompt_history(&trimmed);
@ -7852,8 +7895,12 @@ impl LiveCli {
let max_compact_rounds = 4; let max_compact_rounds = 4;
let preserve_schedule = [4, 2, 1, 0]; let preserve_schedule = [4, 2, 1, 0];
for round in 0..max_compact_rounds { for (round, preserve) in preserve_schedule
let preserve = preserve_schedule[round]; .iter()
.copied()
.enumerate()
.take(max_compact_rounds)
{
println!( println!(
" Auto-compacting session (round {}/{}, preserving {} recent messages)...", " Auto-compacting session (round {}/{}, preserving {} recent messages)...",
round + 1, round + 1,
@ -8203,6 +8250,11 @@ impl LiveCli {
println!("{}", format_cost_report(usage)); println!("{}", format_cost_report(usage));
false false
} }
SlashCommand::Review { scope } => {
let scope = scope.unwrap_or_else(|| "medium".to_string());
self.run_turn(&format!("$code-review {scope}"))?;
false
}
SlashCommand::Login SlashCommand::Login
| SlashCommand::Logout | SlashCommand::Logout
| SlashCommand::Vim | SlashCommand::Vim
@ -8224,7 +8276,6 @@ impl LiveCli {
| SlashCommand::Keybindings | SlashCommand::Keybindings
| SlashCommand::PrivacySettings | SlashCommand::PrivacySettings
| SlashCommand::Plan { .. } | SlashCommand::Plan { .. }
| SlashCommand::Review { .. }
| SlashCommand::Tasks { .. } | SlashCommand::Tasks { .. }
| SlashCommand::Theme { .. } | SlashCommand::Theme { .. }
| SlashCommand::Voice { .. } | SlashCommand::Voice { .. }
@ -8406,7 +8457,7 @@ impl LiveCli {
let normalized = normalize_permission_mode(&mode).ok_or_else(|| { let normalized = normalize_permission_mode(&mode).ok_or_else(|| {
format!( format!(
"invalid_flag_value: unsupported permission mode '{mode}'.\nUsage: --permission-mode read-only|workspace-write|danger-full-access" "invalid_flag_value: unsupported permission mode '{mode}'.\nUsage: --permission-mode manual|read-only|workspace-write|danger-full-access"
) )
})?; })?;
@ -8611,8 +8662,8 @@ impl LiveCli {
let cwd = env::current_dir()?; let cwd = env::current_dir()?;
// #803: reject flag-shaped tokens in list filter for BOTH text and JSON modes. // #803: reject flag-shaped tokens in list filter for BOTH text and JSON modes.
// Previously the guard was JSON-only (#793); text mode silently returned empty success. // Previously the guard was JSON-only (#793); text mode silently returned empty success.
if action.as_deref() == Some("list") { if action == Some("list") {
if let Some(filter) = target.as_deref() { if let Some(filter) = target {
if filter.starts_with('-') { if filter.starts_with('-') {
if matches!(output_format, CliOutputFormat::Json) { if matches!(output_format, CliOutputFormat::Json) {
// ROADMAP #817: this is a handled local inventory parse error. // ROADMAP #817: this is a handled local inventory parse error.
@ -9577,6 +9628,7 @@ fn print_status_snapshot(
Ok(()) Ok(())
} }
#[allow(clippy::too_many_arguments)]
fn status_json_value( fn status_json_value(
model: Option<&str>, model: Option<&str>,
usage: StatusUsage, usage: StatusUsage,
@ -10073,9 +10125,7 @@ fn sandbox_json_value(status: &runtime::SandboxStatus) -> serde_json::Value {
// (#731: "not supported on macOS" is a degraded state, not a hard error; // (#731: "not supported on macOS" is a degraded state, not a hard error;
// filesystem_active:true means partial containment is working) // filesystem_active:true means partial containment is working)
// error = enabled but unsupported AND no filesystem sandbox either (nothing active) // error = enabled but unsupported AND no filesystem sandbox either (nothing active)
let top_status = if !status.enabled { let top_status = if !status.enabled || status.active {
"ok"
} else if status.active {
"ok" "ok"
} else if status.supported { } else if status.supported {
"warn" "warn"
@ -10325,7 +10375,7 @@ fn print_models(
CliOutputFormat::Text => { CliOutputFormat::Text => {
println!("Models"); println!("Models");
println!(" Default {DEFAULT_MODEL}"); println!(" Default {DEFAULT_MODEL}");
println!(" Built-in aliases opus, sonnet, haiku"); println!(" Built-in aliases opus, sonnet, haiku, fable");
if let Some(raw) = configured_model.as_deref() { if let Some(raw) = configured_model.as_deref() {
println!( println!(
" Config model {raw}{}", " Config model {raw}{}",
@ -10351,7 +10401,8 @@ fn print_models(
"aliases": [ "aliases": [
{"name": "opus", "model": resolve_model_alias("opus")}, {"name": "opus", "model": resolve_model_alias("opus")},
{"name": "sonnet", "model": resolve_model_alias("sonnet")}, {"name": "sonnet", "model": resolve_model_alias("sonnet")},
{"name": "haiku", "model": resolve_model_alias("haiku")} {"name": "haiku", "model": resolve_model_alias("haiku")},
{"name": "fable", "model": resolve_model_alias("fable")}
], ],
"configured_model": configured_model, "configured_model": configured_model,
"resolved_configured_model": resolved_config_model, "resolved_configured_model": resolved_config_model,
@ -10449,10 +10500,7 @@ fn render_doctor_help_json() -> serde_json::Value {
}) })
} }
/// #683-#692: extract structured metadata from help prose type HelpMetadata = (
fn extract_help_metadata(
topic: LocalHelpTopic,
) -> (
Option<String>, // usage Option<String>, // usage
Option<String>, // purpose Option<String>, // purpose
Option<String>, // output description Option<String>, // output description
@ -10461,7 +10509,10 @@ fn extract_help_metadata(
Option<Vec<String>>, // aliases Option<Vec<String>>, // aliases
bool, // local_only bool, // local_only
bool, // requires_credentials bool, // requires_credentials
) { );
/// #683-#692: extract structured metadata from help prose
fn extract_help_metadata(topic: LocalHelpTopic) -> HelpMetadata {
let text = render_help_topic(topic); let text = render_help_topic(topic);
let mut usage = None; let mut usage = None;
let mut purpose = None; let mut purpose = None;
@ -11076,7 +11127,8 @@ fn init_json_value(report: &crate::init::InitReport, message: &str) -> serde_jso
fn normalize_permission_mode(mode: &str) -> Option<&'static str> { fn normalize_permission_mode(mode: &str) -> Option<&'static str> {
match mode.trim() { match mode.trim() {
"default" | "plan" | "read-only" => Some("read-only"), "plan" | "read-only" => Some("read-only"),
"default" | "manual" | "prompt" => Some("manual"),
"acceptEdits" | "auto" | "workspace-write" => Some("workspace-write"), "acceptEdits" | "auto" | "workspace-write" => Some("workspace-write"),
"dontAsk" | "bypassPermissions" | "dangerFullAccess" | "danger-full-access" => { "dontAsk" | "bypassPermissions" | "dangerFullAccess" | "danger-full-access" => {
Some("danger-full-access") Some("danger-full-access")
@ -12710,9 +12762,27 @@ impl AnthropicRuntimeClient {
loop { loop {
let next = if apply_stall_timeout && !received_any_event { let next = if apply_stall_timeout && !received_any_event {
match tokio::time::timeout(POST_TOOL_STALL_TIMEOUT, stream.next_event()).await { match tokio::time::timeout(POST_TOOL_STALL_TIMEOUT, stream.next_event()).await {
Ok(inner) => inner.map_err(|error| { Ok(inner) => match inner {
RuntimeError::new(format_user_visible_api_error(&self.session_id, &error)) Ok(next) => next,
})?, Err(error) => {
if let Some(events) = finish_incomplete_stream(
&self.session_id,
&error,
out,
&renderer,
&mut markdown_stream,
&mut events,
&mut pending_thinking,
&mut pending_tool,
)? {
return Ok(events);
}
return Err(RuntimeError::new(format_user_visible_api_error(
&self.session_id,
&error,
)));
}
},
Err(_elapsed) => { Err(_elapsed) => {
return Err(RuntimeError::new( return Err(RuntimeError::new(
"post-tool stall: model did not respond within timeout", "post-tool stall: model did not respond within timeout",
@ -12720,9 +12790,27 @@ impl AnthropicRuntimeClient {
} }
} }
} else { } else {
stream.next_event().await.map_err(|error| { match stream.next_event().await {
RuntimeError::new(format_user_visible_api_error(&self.session_id, &error)) Ok(next) => next,
})? Err(error) => {
if let Some(events) = finish_incomplete_stream(
&self.session_id,
&error,
out,
&renderer,
&mut markdown_stream,
&mut events,
&mut pending_thinking,
&mut pending_tool,
)? {
return Ok(events);
}
return Err(RuntimeError::new(format_user_visible_api_error(
&self.session_id,
&error,
)));
}
}
}; };
let Some(event) = next else { let Some(event) = next else {
@ -12871,6 +12959,63 @@ impl AnthropicRuntimeClient {
} }
} }
#[allow(clippy::too_many_arguments)]
fn finish_incomplete_stream(
session_id: &str,
error: &api::ApiError,
out: &mut dyn Write,
renderer: &TerminalRenderer,
markdown_stream: &mut MarkdownStreamState,
events: &mut Vec<AssistantEvent>,
pending_thinking: &mut Option<(String, Option<String>)>,
pending_tool: &mut Option<(String, String, String)>,
) -> Result<Option<Vec<AssistantEvent>>, RuntimeError> {
let has_partial = events.iter().any(|event| {
matches!(event, AssistantEvent::TextDelta(text) if !text.is_empty())
|| matches!(
event,
AssistantEvent::Thinking { .. } | AssistantEvent::ToolUse { .. }
)
}) || pending_thinking.is_some()
|| pending_tool.is_some();
if !has_partial {
return Ok(None);
}
if let Some(rendered) = markdown_stream.flush(renderer) {
write!(out, "{rendered}")
.and_then(|()| out.flush())
.map_err(|io_error| RuntimeError::new(io_error.to_string()))?;
}
if let Some((thinking, signature)) = pending_thinking.take() {
events.push(AssistantEvent::Thinking {
thinking,
signature,
});
}
if let Some((id, name, input)) = pending_tool.take() {
let summary = format!(
"\n\n[Incomplete tool call preserved after stream error: {name} ({id}) with partial input `{input}`]"
);
write!(out, "{summary}")
.and_then(|()| out.flush())
.map_err(|io_error| RuntimeError::new(io_error.to_string()))?;
events.push(AssistantEvent::TextDelta(summary));
}
let reason = format_user_visible_api_error(session_id, error);
let notice = format!("\n\n[Incomplete response preserved after stream error: {reason}]");
write!(out, "{notice}")
.and_then(|()| out.flush())
.map_err(|io_error| RuntimeError::new(io_error.to_string()))?;
events.push(AssistantEvent::TextDelta(notice));
events.push(AssistantEvent::IncompleteResponse { reason });
events.push(AssistantEvent::MessageStop);
Ok(Some(std::mem::take(events)))
}
/// Returns `true` when the conversation ends with a tool-result message, /// Returns `true` when the conversation ends with a tool-result message,
/// meaning the model is expected to continue after tool execution. /// meaning the model is expected to continue after tool execution.
fn request_ends_with_tool_result(request: &ApiRequest) -> bool { fn request_ends_with_tool_result(request: &ApiRequest) -> bool {
@ -14008,39 +14153,37 @@ fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
let content = message let content = message
.blocks .blocks
.iter() .iter()
.filter_map(|block| match block { .map(|block| match block {
ContentBlock::Text { text } => { ContentBlock::Text { text } => InputContentBlock::Text { text: text.clone() },
Some(InputContentBlock::Text { text: text.clone() })
}
ContentBlock::Thinking { ContentBlock::Thinking {
thinking, thinking,
signature, signature,
} => { } => {
// 保留 Thinking 块OpenAI 兼容协议会把它转成 reasoning_content 字段 // 保留 Thinking 块OpenAI 兼容协议会把它转成 reasoning_content 字段
// 回传给 DeepSeek V4避免 400 "reasoning_content must be passed back" 错误) // 回传给 DeepSeek V4避免 400 "reasoning_content must be passed back" 错误)
Some(InputContentBlock::Thinking { InputContentBlock::Thinking {
thinking: thinking.clone(), thinking: thinking.clone(),
signature: signature.clone(), signature: signature.clone(),
}) }
} }
ContentBlock::ToolUse { id, name, input } => Some(InputContentBlock::ToolUse { ContentBlock::ToolUse { id, name, input } => InputContentBlock::ToolUse {
id: id.clone(), id: id.clone(),
name: name.clone(), name: name.clone(),
input: serde_json::from_str(input) input: serde_json::from_str(input)
.unwrap_or_else(|_| serde_json::json!({ "raw": input })), .unwrap_or_else(|_| serde_json::json!({ "raw": input })),
}), },
ContentBlock::ToolResult { ContentBlock::ToolResult {
tool_use_id, tool_use_id,
output, output,
is_error, is_error,
.. ..
} => Some(InputContentBlock::ToolResult { } => InputContentBlock::ToolResult {
tool_use_id: tool_use_id.clone(), tool_use_id: tool_use_id.clone(),
content: vec![ToolResultContentBlock::Text { content: vec![ToolResultContentBlock::Text {
text: output.clone(), text: output.clone(),
}], }],
is_error: *is_error, is_error: *is_error,
}), },
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
(!content.is_empty()).then(|| InputMessage { (!content.is_empty()).then(|| InputMessage {
@ -14386,7 +14529,7 @@ mod tests {
#[test] #[test]
fn context_window_preflight_errors_render_recovery_steps() { fn context_window_preflight_errors_render_recovery_steps() {
let error = ApiError::ContextWindowExceeded { let error = ApiError::ContextWindowExceeded {
model: "anthropic/claude-sonnet-4-6".to_string(), model: "anthropic/claude-sonnet-5".to_string(),
estimated_input_tokens: 182_000, estimated_input_tokens: 182_000,
requested_output_tokens: 64_000, requested_output_tokens: 64_000,
estimated_total_tokens: 246_000, estimated_total_tokens: 246_000,
@ -14401,7 +14544,7 @@ mod tests {
"{rendered}" "{rendered}"
); );
assert!( assert!(
rendered.contains("Model anthropic/claude-sonnet-4-6"), rendered.contains("Model anthropic/claude-sonnet-5"),
"{rendered}" "{rendered}"
); );
assert!( assert!(
@ -14637,7 +14780,7 @@ mod tests {
CliAction::Repl { CliAction::Repl {
model: DEFAULT_MODEL.to_string(), model: DEFAULT_MODEL.to_string(),
allowed_tools: None, allowed_tools: None,
permission_mode: PermissionMode::WorkspaceWrite, permission_mode: PermissionMode::Manual,
base_commit: None, base_commit: None,
reasoning_effort: None, reasoning_effort: None,
allow_broad_cwd: false, allow_broad_cwd: false,
@ -14770,7 +14913,7 @@ mod tests {
model: DEFAULT_MODEL.to_string(), model: DEFAULT_MODEL.to_string(),
output_format: CliOutputFormat::Text, output_format: CliOutputFormat::Text,
allowed_tools: None, allowed_tools: None,
permission_mode: PermissionMode::WorkspaceWrite, permission_mode: PermissionMode::Manual,
compact: false, compact: false,
base_commit: None, base_commit: None,
reasoning_effort: None, reasoning_effort: None,
@ -14858,10 +15001,10 @@ mod tests {
parse_args(&args).expect("args should parse"), parse_args(&args).expect("args should parse"),
CliAction::Prompt { CliAction::Prompt {
prompt: "explain this".to_string(), prompt: "explain this".to_string(),
model: "anthropic/claude-opus-4-7".to_string(), model: "anthropic/claude-opus-4-8".to_string(),
output_format: CliOutputFormat::Json, output_format: CliOutputFormat::Json,
allowed_tools: None, allowed_tools: None,
permission_mode: PermissionMode::WorkspaceWrite, permission_mode: PermissionMode::Manual,
compact: false, compact: false,
base_commit: None, base_commit: None,
reasoning_effort: None, reasoning_effort: None,
@ -14883,7 +15026,7 @@ mod tests {
model: DEFAULT_MODEL.to_string(), model: DEFAULT_MODEL.to_string(),
output_format: CliOutputFormat::Text, output_format: CliOutputFormat::Text,
allowed_tools: None, allowed_tools: None,
permission_mode: PermissionMode::WorkspaceWrite, permission_mode: PermissionMode::Manual,
compact: false, compact: false,
base_commit: None, base_commit: None,
reasoning_effort: None, reasoning_effort: None,
@ -14899,7 +15042,7 @@ mod tests {
model: DEFAULT_MODEL.to_string(), model: DEFAULT_MODEL.to_string(),
output_format: CliOutputFormat::Text, output_format: CliOutputFormat::Text,
allowed_tools: None, allowed_tools: None,
permission_mode: PermissionMode::WorkspaceWrite, permission_mode: PermissionMode::Manual,
compact: false, compact: false,
base_commit: None, base_commit: None,
reasoning_effort: None, reasoning_effort: None,
@ -14915,7 +15058,7 @@ mod tests {
model: DEFAULT_MODEL.to_string(), model: DEFAULT_MODEL.to_string(),
output_format: CliOutputFormat::Text, output_format: CliOutputFormat::Text,
allowed_tools: None, allowed_tools: None,
permission_mode: PermissionMode::WorkspaceWrite, permission_mode: PermissionMode::Manual,
compact: false, compact: false,
base_commit: None, base_commit: None,
reasoning_effort: None, reasoning_effort: None,
@ -14953,7 +15096,7 @@ mod tests {
model: DEFAULT_MODEL.to_string(), model: DEFAULT_MODEL.to_string(),
output_format: CliOutputFormat::Text, output_format: CliOutputFormat::Text,
allowed_tools: None, allowed_tools: None,
permission_mode: PermissionMode::WorkspaceWrite, permission_mode: PermissionMode::Manual,
compact: true, compact: true,
base_commit: None, base_commit: None,
reasoning_effort: None, reasoning_effort: None,
@ -14968,7 +15111,7 @@ mod tests {
model: DEFAULT_MODEL.to_string(), model: DEFAULT_MODEL.to_string(),
output_format: CliOutputFormat::Text, output_format: CliOutputFormat::Text,
allowed_tools: None, allowed_tools: None,
permission_mode: PermissionMode::WorkspaceWrite, permission_mode: PermissionMode::Manual,
compact: true, compact: true,
base_commit: None, base_commit: None,
reasoning_effort: None, reasoning_effort: None,
@ -15008,10 +15151,10 @@ mod tests {
parse_args(&args).expect("args should parse"), parse_args(&args).expect("args should parse"),
CliAction::Prompt { CliAction::Prompt {
prompt: "explain this".to_string(), prompt: "explain this".to_string(),
model: "anthropic/claude-opus-4-7".to_string(), model: "anthropic/claude-opus-4-8".to_string(),
output_format: CliOutputFormat::Text, output_format: CliOutputFormat::Text,
allowed_tools: None, allowed_tools: None,
permission_mode: PermissionMode::WorkspaceWrite, permission_mode: PermissionMode::Manual,
compact: false, compact: false,
base_commit: None, base_commit: None,
reasoning_effort: None, reasoning_effort: None,
@ -15022,19 +15165,19 @@ mod tests {
#[test] #[test]
fn resolves_known_model_aliases() { fn resolves_known_model_aliases() {
assert_eq!(resolve_model_alias("opus"), "anthropic/claude-opus-4-7"); assert_eq!(resolve_model_alias("opus"), "anthropic/claude-opus-4-8");
assert_eq!(resolve_model_alias("sonnet"), "anthropic/claude-sonnet-4-6"); assert_eq!(resolve_model_alias("sonnet"), "anthropic/claude-sonnet-5");
assert_eq!( assert_eq!(
resolve_model_alias("haiku"), resolve_model_alias("haiku"),
"anthropic/claude-haiku-4-5-20251213" "anthropic/claude-haiku-4-5-20251001"
); );
assert_eq!(resolve_model_alias("claude-opus"), "claude-opus"); assert_eq!(resolve_model_alias("claude-opus"), "claude-opus");
} }
#[test] #[test]
fn default_model_alias_uses_anthropic_routing_prefix() { fn default_model_alias_uses_anthropic_routing_prefix() {
assert_eq!(DEFAULT_MODEL, "anthropic/claude-opus-4-7"); assert_eq!(DEFAULT_MODEL, "anthropic/claude-sonnet-5");
assert_eq!(resolve_model_alias("opus"), "anthropic/claude-opus-4-7"); assert_eq!(resolve_model_alias("opus"), "anthropic/claude-opus-4-8");
} }
#[test] #[test]
@ -15048,7 +15191,7 @@ mod tests {
std::fs::create_dir_all(&config_home).expect("config home should exist"); std::fs::create_dir_all(&config_home).expect("config home should exist");
std::fs::write( std::fs::write(
cwd.join(".claw").join("settings.json"), cwd.join(".claw").join("settings.json"),
r#"{"aliases":{"fast":"anthropic/claude-haiku-4-5-20251213","smart":"opus","cheap":"grok-3-mini"}}"#, r#"{"aliases":{"fast":"anthropic/claude-haiku-4-5-20251001","smart":"opus","cheap":"grok-3-mini"}}"#,
) )
.expect("project config should write"); .expect("project config should write");
@ -15069,11 +15212,11 @@ mod tests {
std::fs::remove_dir_all(root).expect("temp config root should clean up"); std::fs::remove_dir_all(root).expect("temp config root should clean up");
// then // then
assert_eq!(direct, "anthropic/claude-haiku-4-5-20251213"); assert_eq!(direct, "anthropic/claude-haiku-4-5-20251001");
assert_eq!(chained, "anthropic/claude-opus-4-7"); assert_eq!(chained, "anthropic/claude-opus-4-8");
assert_eq!(cross_provider, "grok-3-mini"); assert_eq!(cross_provider, "grok-3-mini");
assert_eq!(unknown, "unknown-model"); assert_eq!(unknown, "unknown-model");
assert_eq!(builtin, "anthropic/claude-haiku-4-5-20251213"); assert_eq!(builtin, "anthropic/claude-haiku-4-5-20251001");
} }
#[test] #[test]
@ -15178,7 +15321,7 @@ mod tests {
.map(str::to_string) .map(str::to_string)
.collect() .collect()
), ),
permission_mode: PermissionMode::WorkspaceWrite, permission_mode: PermissionMode::Manual,
base_commit: None, base_commit: None,
reasoning_effort: None, reasoning_effort: None,
allow_broad_cwd: false, allow_broad_cwd: false,
@ -15541,7 +15684,7 @@ mod tests {
.. ..
} => { } => {
assert_eq!( assert_eq!(
model, "anthropic/claude-sonnet-4-6", model, "anthropic/claude-sonnet-5",
"sonnet alias should resolve" "sonnet alias should resolve"
); );
assert_eq!( assert_eq!(
@ -15554,7 +15697,7 @@ mod tests {
} }
// --model= form should also capture raw. // --model= form should also capture raw.
match parse_args(&[ match parse_args(&[
"--model=anthropic/claude-opus-4-6".to_string(), "--model=anthropic/claude-opus-4-8".to_string(),
"status".to_string(), "status".to_string(),
]) ])
.expect("--model=... status should parse") .expect("--model=... status should parse")
@ -15564,16 +15707,16 @@ mod tests {
model_flag_raw, model_flag_raw,
.. ..
} => { } => {
assert_eq!(model, "anthropic/claude-opus-4-6"); assert_eq!(model, "anthropic/claude-opus-4-8");
assert_eq!( assert_eq!(
model_flag_raw.as_deref(), model_flag_raw.as_deref(),
Some("anthropic/claude-opus-4-6"), Some("anthropic/claude-opus-4-8"),
"--model= form should also preserve raw input" "--model= form should also preserve raw input"
); );
} }
other => panic!("expected CliAction::Status, got: {other:?}"), other => panic!("expected CliAction::Status, got: {other:?}"),
} }
match parse_args(&["--model=claude-opus-4-6".to_string(), "status".to_string()]) match parse_args(&["--model=claude-opus-4-8".to_string(), "status".to_string()])
.expect("bare Anthropic model should parse") .expect("bare Anthropic model should parse")
{ {
CliAction::Status { CliAction::Status {
@ -15581,8 +15724,8 @@ mod tests {
model_flag_raw, model_flag_raw,
.. ..
} => { } => {
assert_eq!(model, "claude-opus-4-6"); assert_eq!(model, "claude-opus-4-8");
assert_eq!(model_flag_raw.as_deref(), Some("claude-opus-4-6")); assert_eq!(model_flag_raw.as_deref(), Some("claude-opus-4-8"));
} }
other => panic!("expected CliAction::Status, got: {other:?}"), other => panic!("expected CliAction::Status, got: {other:?}"),
} }
@ -16317,7 +16460,7 @@ mod tests {
"missing_flag_value" "missing_flag_value"
); );
assert_eq!( assert_eq!(
classify_error_kind("invalid_permission_mode: unsupported permission mode 'bogus'.\nUsage: --permission-mode read-only|workspace-write|danger-full-access"), classify_error_kind("invalid_permission_mode: unsupported permission mode 'bogus'.\nUsage: --permission-mode manual|read-only|workspace-write|danger-full-access"),
"invalid_permission_mode" "invalid_permission_mode"
); );
assert_eq!( assert_eq!(
@ -16650,6 +16793,8 @@ mod tests {
is_error: false, is_error: false,
}], }],
usage: None, usage: None,
incomplete: false,
incomplete_reason: None,
}, },
]; ];
@ -16690,6 +16835,8 @@ mod tests {
is_error: true, is_error: true,
}], }],
usage: None, usage: None,
incomplete: false,
incomplete_reason: None,
}]; }];
// when // when
@ -16785,7 +16932,7 @@ mod tests {
.expect("prompt shorthand should still work"), .expect("prompt shorthand should still work"),
CliAction::Prompt { CliAction::Prompt {
prompt: "please debug this".to_string(), prompt: "please debug this".to_string(),
model: "anthropic/claude-opus-4-7".to_string(), model: "anthropic/claude-opus-4-8".to_string(),
output_format: CliOutputFormat::Text, output_format: CliOutputFormat::Text,
allowed_tools: None, allowed_tools: None,
permission_mode: crate::default_permission_mode(), permission_mode: crate::default_permission_mode(),
@ -16972,7 +17119,7 @@ mod tests {
for action in ["remove", "uninstall", "delete"] { for action in ["remove", "uninstall", "delete"] {
assert_eq!( assert_eq!(
parse_args(&["skills".to_string(), action.to_string()]) parse_args(&["skills".to_string(), action.to_string()])
.expect(&format!("skills {action} should parse")), .unwrap_or_else(|_| panic!("skills {action} should parse")),
CliAction::Skills { CliAction::Skills {
args: Some(action.to_string()), args: Some(action.to_string()),
output_format: CliOutputFormat::Text, output_format: CliOutputFormat::Text,
@ -17040,7 +17187,7 @@ mod tests {
model: DEFAULT_MODEL.to_string(), model: DEFAULT_MODEL.to_string(),
output_format: CliOutputFormat::Text, output_format: CliOutputFormat::Text,
allowed_tools: None, allowed_tools: None,
permission_mode: PermissionMode::WorkspaceWrite, permission_mode: PermissionMode::Manual,
compact: false, compact: false,
base_commit: None, base_commit: None,
reasoning_effort: None, reasoning_effort: None,
@ -17069,7 +17216,7 @@ mod tests {
model: DEFAULT_MODEL.to_string(), model: DEFAULT_MODEL.to_string(),
output_format: CliOutputFormat::Text, output_format: CliOutputFormat::Text,
allowed_tools: None, allowed_tools: None,
permission_mode: PermissionMode::WorkspaceWrite, permission_mode: PermissionMode::Manual,
compact: false, compact: false,
base_commit: None, base_commit: None,
reasoning_effort: None, reasoning_effort: None,
@ -17291,12 +17438,12 @@ mod tests {
assert!(help.contains("/status")); assert!(help.contains("/status"));
assert!(help.contains("/sandbox")); assert!(help.contains("/sandbox"));
assert!(help.contains("/model [model]")); assert!(help.contains("/model [model]"));
assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]")); assert!(help.contains("/permissions [manual|read-only|workspace-write|danger-full-access]"));
assert!(help.contains("/clear [--confirm]")); assert!(help.contains("/clear [--confirm]"));
assert!(help.contains("/cost")); assert!(help.contains("/cost"));
assert!(help.contains("/resume <session-path>")); assert!(help.contains("/resume <session-path>"));
assert!(help.contains("/config [env|hooks|model|plugins]")); assert!(help.contains("/config [env|hooks|model|plugins]"));
assert!(help.contains("/mcp [list|show <server>|help]")); assert!(help.contains("/mcp [list|show <server>|login <server>|logout <server>|help]"));
assert!(help.contains("/memory")); assert!(help.contains("/memory"));
assert!(help.contains("/init")); assert!(help.contains("/init"));
assert!(help.contains("/diff")); assert!(help.contains("/diff"));
@ -17327,7 +17474,7 @@ mod tests {
vec!["session-old".to_string()], vec!["session-old".to_string()],
); );
assert!(completions.contains(&"/model anthropic/claude-sonnet-4-6".to_string())); assert!(completions.contains(&"/model anthropic/claude-sonnet-5".to_string()));
assert!(completions.contains(&"/permissions workspace-write".to_string())); assert!(completions.contains(&"/permissions workspace-write".to_string()));
assert!(completions.contains(&"/session list".to_string())); assert!(completions.contains(&"/session list".to_string()));
assert!(completions.contains(&"/session switch session-current".to_string())); assert!(completions.contains(&"/session switch session-current".to_string()));
@ -17346,7 +17493,7 @@ mod tests {
let banner = with_current_dir(&root, || { let banner = with_current_dir(&root, || {
LiveCli::new( LiveCli::new(
"anthropic/claude-sonnet-4-6".to_string(), "anthropic/claude-sonnet-5".to_string(),
true, true,
None, None,
PermissionMode::DangerFullAccess, PermissionMode::DangerFullAccess,
@ -17364,11 +17511,11 @@ mod tests {
#[test] #[test]
fn format_connected_line_renders_anthropic_provider_for_claude_model() { fn format_connected_line_renders_anthropic_provider_for_claude_model() {
let model = "anthropic/claude-sonnet-4-6"; let model = "anthropic/claude-sonnet-5";
let line = format_connected_line(model); let line = format_connected_line(model);
assert_eq!(line, "Connected: anthropic/claude-sonnet-4-6 via anthropic"); assert_eq!(line, "Connected: anthropic/claude-sonnet-5 via anthropic");
} }
#[test] #[test]
@ -17382,11 +17529,11 @@ mod tests {
#[test] #[test]
fn resolve_repl_model_returns_user_supplied_model_unchanged_when_explicit() { fn resolve_repl_model_returns_user_supplied_model_unchanged_when_explicit() {
let user_model = "anthropic/claude-sonnet-4-6".to_string(); let user_model = "anthropic/claude-sonnet-5".to_string();
let resolved = resolve_repl_model(user_model).expect("explicit model should resolve"); let resolved = resolve_repl_model(user_model).expect("explicit model should resolve");
assert_eq!(resolved, "anthropic/claude-sonnet-4-6"); assert_eq!(resolved, "anthropic/claude-sonnet-5");
} }
#[test] #[test]
@ -17403,7 +17550,7 @@ mod tests {
let resolved = with_current_dir(&root, || resolve_repl_model(DEFAULT_MODEL.to_string())) let resolved = with_current_dir(&root, || resolve_repl_model(DEFAULT_MODEL.to_string()))
.expect("env model should resolve"); .expect("env model should resolve");
assert_eq!(resolved, "anthropic/claude-sonnet-4-6"); assert_eq!(resolved, "anthropic/claude-sonnet-5");
std::env::remove_var("ANTHROPIC_MODEL"); std::env::remove_var("ANTHROPIC_MODEL");
std::env::remove_var("CLAW_CONFIG_HOME"); std::env::remove_var("CLAW_CONFIG_HOME");
@ -18625,6 +18772,8 @@ UU conflicted.rs",
is_error: false, is_error: false,
}], }],
usage: None, usage: None,
incomplete: false,
incomplete_reason: None,
}, },
]; ];
@ -19028,7 +19177,7 @@ UU conflicted.rs",
MessageResponse { MessageResponse {
id: "msg-1".to_string(), id: "msg-1".to_string(),
kind: "message".to_string(), kind: "message".to_string(),
model: "anthropic/claude-opus-4-6".to_string(), model: "anthropic/claude-opus-4-8".to_string(),
role: "assistant".to_string(), role: "assistant".to_string(),
content: vec![OutputContentBlock::ToolUse { content: vec![OutputContentBlock::ToolUse {
id: "tool-1".to_string(), id: "tool-1".to_string(),
@ -19063,7 +19212,7 @@ UU conflicted.rs",
MessageResponse { MessageResponse {
id: "msg-2".to_string(), id: "msg-2".to_string(),
kind: "message".to_string(), kind: "message".to_string(),
model: "anthropic/claude-opus-4-6".to_string(), model: "anthropic/claude-opus-4-8".to_string(),
role: "assistant".to_string(), role: "assistant".to_string(),
content: vec![OutputContentBlock::ToolUse { content: vec![OutputContentBlock::ToolUse {
id: "tool-2".to_string(), id: "tool-2".to_string(),
@ -19098,7 +19247,7 @@ UU conflicted.rs",
MessageResponse { MessageResponse {
id: "msg-3".to_string(), id: "msg-3".to_string(),
kind: "message".to_string(), kind: "message".to_string(),
model: "anthropic/claude-opus-4-6".to_string(), model: "anthropic/claude-opus-4-8".to_string(),
role: "assistant".to_string(), role: "assistant".to_string(),
content: vec![ content: vec![
OutputContentBlock::Thinking { OutputContentBlock::Thinking {
@ -19750,15 +19899,15 @@ mod alias_resolution_tests {
// Built-in aliases should resolve to their full IDs // Built-in aliases should resolve to their full IDs
assert_eq!( assert_eq!(
resolve_model_alias_with_config("opus"), resolve_model_alias_with_config("opus"),
"anthropic/claude-opus-4-7" "anthropic/claude-opus-4-8"
); );
assert_eq!( assert_eq!(
resolve_model_alias_with_config("sonnet"), resolve_model_alias_with_config("sonnet"),
"anthropic/claude-sonnet-4-6" "anthropic/claude-sonnet-5"
); );
assert_eq!( assert_eq!(
resolve_model_alias_with_config("haiku"), resolve_model_alias_with_config("haiku"),
"anthropic/claude-haiku-4-5-20251213" "anthropic/claude-haiku-4-5-20251001"
); );
} }

View File

@ -2,8 +2,6 @@ use std::io::{self, IsTerminal, Write};
use runtime::{save_user_provider_settings, ConfigLoader, RuntimeProviderConfig}; use runtime::{save_user_provider_settings, ConfigLoader, RuntimeProviderConfig};
use serde_json;
const PROVIDERS: &[(&str, &str, &str)] = &[ const PROVIDERS: &[(&str, &str, &str)] = &[
("1", "Anthropic", "anthropic"), ("1", "Anthropic", "anthropic"),
("2", "xAI / Grok", "xai"), ("2", "xAI / Grok", "xai"),

View File

@ -31,7 +31,7 @@ fn status_command_applies_model_and_permission_mode_flags() {
assert_success(&output); assert_success(&output);
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8"); let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
assert!(stdout.contains("Status")); assert!(stdout.contains("Status"));
assert!(stdout.contains("Model anthropic/claude-sonnet-4-6")); assert!(stdout.contains("Model anthropic/claude-sonnet-5"));
assert!(stdout.contains("Permission mode read-only")); assert!(stdout.contains("Permission mode read-only"));
fs::remove_dir_all(temp_dir).expect("cleanup temp dir"); fs::remove_dir_all(temp_dir).expect("cleanup temp dir");

View File

@ -240,7 +240,7 @@ stderr:
"Mock streaming says hello from the parity harness." "Mock streaming says hello from the parity harness."
); );
assert_eq!(parsed["compact"], true); assert_eq!(parsed["compact"], true);
assert_eq!(parsed["model"], "anthropic/claude-sonnet-4-6"); assert_eq!(parsed["model"], "anthropic/claude-sonnet-5");
assert!(parsed["usage"].is_object()); assert!(parsed["usage"].is_object());
fs::remove_dir_all(&workspace).expect("workspace cleanup should succeed"); fs::remove_dir_all(&workspace).expect("workspace cleanup should succeed");

View File

@ -1,3 +1,5 @@
#![allow(unused_variables)]
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::{Command, Output}; use std::process::{Command, Output};
@ -512,7 +514,7 @@ fn status_json_surfaces_permission_mode_override_for_security_audit() {
} }
#[test] #[test]
fn default_permission_mode_is_workspace_write_and_audited_428() { fn default_permission_mode_is_manual_and_audited_428() {
let root = unique_temp_dir("default-permission-mode-428"); let root = unique_temp_dir("default-permission-mode-428");
let config_home = root.join("config-home"); let config_home = root.join("config-home");
let home = root.join("home"); let home = root.join("home");
@ -529,7 +531,7 @@ fn default_permission_mode_is_workspace_write_and_audited_428() {
]; ];
let status = assert_json_command_with_env(&root, &["--output-format", "json", "status"], &envs); let status = assert_json_command_with_env(&root, &["--output-format", "json", "status"], &envs);
assert_eq!(status["permission_mode"], "workspace-write"); assert_eq!(status["permission_mode"], "manual");
assert_eq!(status["permission_mode_source"], "default"); assert_eq!(status["permission_mode_source"], "default");
let doctor = assert_json_command_with_env(&root, &["--output-format", "json", "doctor"], &envs); let doctor = assert_json_command_with_env(&root, &["--output-format", "json", "doctor"], &envs);
@ -540,12 +542,9 @@ fn default_permission_mode_is_workspace_write_and_audited_428() {
.find(|check| check["name"] == "permissions") .find(|check| check["name"] == "permissions")
.expect("permissions check"); .expect("permissions check");
assert_eq!(permissions["status"], "ok"); assert_eq!(permissions["status"], "ok");
assert_eq!(permissions["mode"], "workspace-write"); assert_eq!(permissions["mode"], "manual");
assert_eq!(permissions["source"], "default"); assert_eq!(permissions["source"], "default");
assert_eq!( assert_eq!(permissions["message"], "default permission mode is manual");
permissions["message"],
"default permission mode is workspace-write"
);
} }
#[test] #[test]
@ -780,12 +779,12 @@ fn status_json_accepts_namespaced_model_env_and_surfaces_alias_426() {
let parsed = assert_json_command_with_env(&root, &["--output-format", "json", "status"], &envs); let parsed = assert_json_command_with_env(&root, &["--output-format", "json", "status"], &envs);
assert_eq!(parsed["status"], "ok"); assert_eq!(parsed["status"], "ok");
assert_eq!(parsed["model"], "anthropic/claude-opus-4-7"); assert_eq!(parsed["model"], "anthropic/claude-opus-4-8");
assert_eq!(parsed["model_source"], "env"); assert_eq!(parsed["model_source"], "env");
assert_eq!(parsed["model_raw"], "opus"); assert_eq!(parsed["model_raw"], "opus");
assert_eq!( assert_eq!(
parsed["model_alias_resolved_to"], parsed["model_alias_resolved_to"],
"anthropic/claude-opus-4-7" "anthropic/claude-opus-4-8"
); );
assert_eq!(parsed["model_env_var"], "CLAW_MODEL"); assert_eq!(parsed["model_env_var"], "CLAW_MODEL");
} }
@ -1007,13 +1006,13 @@ fn inventory_commands_emit_structured_json_when_requested() {
assert!( assert!(
!plugins !plugins
.as_object() .as_object()
.map_or(false, |o| o.contains_key("reload_runtime")), .is_some_and(|o| o.contains_key("reload_runtime")),
"plugins list should not include reload_runtime" "plugins list should not include reload_runtime"
); );
assert!( assert!(
!plugins !plugins
.as_object() .as_object()
.map_or(false, |o| o.contains_key("target")), .is_some_and(|o| o.contains_key("target")),
"plugins list should not include target" "plugins list should not include target"
); );
// #703: structured summary replaces prose message // #703: structured summary replaces prose message
@ -1706,13 +1705,13 @@ fn resumed_inventory_commands_emit_structured_json_when_requested() {
assert!( assert!(
!plugins !plugins
.as_object() .as_object()
.map_or(false, |o| o.contains_key("reload_runtime")), .is_some_and(|o| o.contains_key("reload_runtime")),
"plugins list should not include reload_runtime" "plugins list should not include reload_runtime"
); );
assert!( assert!(
!plugins !plugins
.as_object() .as_object()
.map_or(false, |o| o.contains_key("target")), .is_some_and(|o| o.contains_key("target")),
"plugins list should not include target" "plugins list should not include target"
); );
assert!( assert!(
@ -2252,7 +2251,7 @@ fn config_json_attributes_precedence_and_shadowed_keys_425() {
fs::create_dir_all(&home).expect("home should exist"); fs::create_dir_all(&home).expect("home should exist");
fs::write( fs::write(
root.join(".claw.json"), root.join(".claw.json"),
r#"{"model":"anthropic/claude-sonnet-4-6","env":{"A":"legacy","B":"legacy"}}"#, r#"{"model":"anthropic/claude-sonnet-5","env":{"A":"legacy","B":"legacy"}}"#,
) )
.expect("legacy project config fixture should write"); .expect("legacy project config fixture should write");
fs::write( fs::write(
@ -2945,7 +2944,7 @@ fn prompt_empty_arg_json_stdout_missing_prompt_823() {
"claw prompt empty arg must retain abort action (#823); got: {parsed}" "claw prompt empty arg must retain abort action (#823); got: {parsed}"
); );
assert!( assert!(
parsed["hint"].as_str().map_or(false, |h| !h.is_empty()), parsed["hint"].as_str().is_some_and(|h| !h.is_empty()),
"claw prompt empty arg missing_prompt hint must be non-empty (#823)" "claw prompt empty arg missing_prompt hint must be non-empty (#823)"
); );
} }
@ -2983,9 +2982,9 @@ fn flag_value_errors_have_error_kind_and_hint_756() {
"invalid --reasoning-effort must be invalid_flag_value (#756): {parsed}" "invalid --reasoning-effort must be invalid_flag_value (#756): {parsed}"
); );
assert!( assert!(
parsed["hint"].as_str().map_or(false, |h| h.contains("low") parsed["hint"]
|| h.contains("medium") .as_str()
|| h.contains("high")), .is_some_and(|h| h.contains("low") || h.contains("medium") || h.contains("high")),
"hint must mention valid values (#756): {parsed}" "hint must mention valid values (#756): {parsed}"
); );
@ -3011,7 +3010,7 @@ fn flag_value_errors_have_error_kind_and_hint_756() {
"missing --model value must be missing_flag_value (#756): {parsed2}" "missing --model value must be missing_flag_value (#756): {parsed2}"
); );
assert!( assert!(
parsed2["hint"].as_str().map_or(false, |h| !h.is_empty()), parsed2["hint"].as_str().is_some_and(|h| !h.is_empty()),
"missing --model hint must be non-empty (#756): {parsed2}" "missing --model hint must be non-empty (#756): {parsed2}"
); );
} }
@ -3255,7 +3254,7 @@ fn short_p_flag_swallows_no_flags_755() {
"flag-like token after -p must be rejected as missing_prompt (#755): {parsed2}" "flag-like token after -p must be rejected as missing_prompt (#755): {parsed2}"
); );
assert!( assert!(
parsed2["hint"].as_str().map_or(false, |h| !h.is_empty()), parsed2["hint"].as_str().is_some_and(|h| !h.is_empty()),
"missing_prompt hint must be non-empty (#755)" "missing_prompt hint must be non-empty (#755)"
); );
} }
@ -3397,7 +3396,7 @@ fn config_unsupported_section_json_hint_741() {
assert!( assert!(
parsed["supported_sections"] parsed["supported_sections"]
.as_array() .as_array()
.map_or(false, |a| !a.is_empty()), .is_some_and(|a| !a.is_empty()),
"config {section} JSON must include supported_sections (#741)" "config {section} JSON must include supported_sections (#741)"
); );
} }
@ -5360,14 +5359,15 @@ fn agents_create_scaffolds_toml_and_lists_locally_431() {
assert_json_command_with_env(&root, &["--output-format", "json", "agents", "list"], &envs); assert_json_command_with_env(&root, &["--output-format", "json", "agents", "list"], &envs);
assert_eq!(list["kind"], "agents"); assert_eq!(list["kind"], "agents");
assert_eq!(list["action"], "list"); assert_eq!(list["action"], "list");
let canonical_agent_path = fs::canonicalize(&agent_path).expect("canonical listed agent path");
assert!(list["agents"] assert!(list["agents"]
.as_array() .as_array()
.expect("agents array") .expect("agents array")
.iter() .iter()
.any(|agent| { .any(|agent| {
agent["name"] == "my-agent" agent["name"] == "my-agent"
&& PathBuf::from(agent["path"].as_str().expect("listed agent path")) && Path::new(agent["path"].as_str().expect("listed agent path"))
== fs::canonicalize(&agent_path).expect("canonical listed agent path") == canonical_agent_path.as_path()
})); }));
} }

View File

@ -108,7 +108,7 @@ fn status_command_applies_cli_flags_end_to_end() {
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8"); let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
assert!(stdout.contains("Status")); assert!(stdout.contains("Status"));
assert!(stdout.contains("Model anthropic/claude-sonnet-4-6")); assert!(stdout.contains("Model anthropic/claude-sonnet-5"));
assert!(stdout.contains("Permission mode read-only")); assert!(stdout.contains("Permission mode read-only"));
} }
@ -335,7 +335,7 @@ fn resumed_status_command_emits_structured_json_when_requested() {
assert_eq!(parsed["kind"], "status"); assert_eq!(parsed["kind"], "status");
// model is null in resume mode (not known without --model flag) // model is null in resume mode (not known without --model flag)
assert!(parsed["model"].is_null()); assert!(parsed["model"].is_null());
assert_eq!(parsed["permission_mode"], "workspace-write"); assert_eq!(parsed["permission_mode"], "manual");
assert_eq!(parsed["usage"]["messages"], 1); assert_eq!(parsed["usage"]["messages"], 1);
assert!(parsed["usage"]["turns"].is_number()); assert!(parsed["usage"]["turns"].is_number());
assert!(parsed["workspace"]["cwd"].as_str().is_some()); assert!(parsed["workspace"]["cwd"].as_str().is_some());
@ -356,7 +356,7 @@ fn resumed_status_surfaces_persisted_model() {
let session_path = temp_dir.join("session.jsonl"); let session_path = temp_dir.join("session.jsonl");
let mut session = workspace_session(&temp_dir); let mut session = workspace_session(&temp_dir);
session.model = Some("anthropic/claude-sonnet-4-6".to_string()); session.model = Some("anthropic/claude-sonnet-5".to_string());
session session
.push_user_text("model persistence fixture") .push_user_text("model persistence fixture")
.expect("write ok"); .expect("write ok");
@ -384,7 +384,7 @@ fn resumed_status_surfaces_persisted_model() {
let parsed: Value = serde_json::from_str(stdout.trim()).expect("should be json"); let parsed: Value = serde_json::from_str(stdout.trim()).expect("should be json");
assert_eq!(parsed["kind"], "status"); assert_eq!(parsed["kind"], "status");
assert_eq!( assert_eq!(
parsed["model"], "anthropic/claude-sonnet-4-6", parsed["model"], "anthropic/claude-sonnet-5",
"model should round-trip through session metadata" "model should round-trip through session metadata"
); );
} }

View File

@ -107,6 +107,9 @@ mod tests {
description: "Test".to_string(), description: "Test".to_string(),
subagent_type: None, subagent_type: None,
model: None, model: None,
effort: None,
run_in_background: true,
isolation: None,
status: "Finished".to_string(), status: "Finished".to_string(),
output_file: "/tmp/test.output".to_string(), output_file: "/tmp/test.output".to_string(),
manifest_file: "/tmp/test.manifest".to_string(), manifest_file: "/tmp/test.manifest".to_string(),

File diff suppressed because it is too large Load Diff