feat: migrate Claude Code contracts to v2.1.201
This commit is contained in:
parent
4ea31c1bc9
commit
1a73dc2c4e
10
PARITY.md
10
PARITY.md
|
|
@ -9,6 +9,7 @@ Last updated: 2026-04-03
|
|||
- 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**.
|
||||
- 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
|
||||
|
||||
|
|
@ -144,17 +145,20 @@ Canonical scenario map: `rust/mock_parity_scenarios.json`
|
|||
- `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.
|
||||
|
||||
## 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`.
|
||||
- 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()`.
|
||||
|
||||
### Still limited or intentionally shallow
|
||||
|
||||
- `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.
|
||||
- `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.
|
||||
|
|
|
|||
25
USAGE.md
25
USAGE.md
|
|
@ -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"]`.
|
||||
|
||||
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.
|
||||
- `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.
|
||||
|
||||
Model aliases currently supported by the CLI:
|
||||
|
||||
- `opus` → `claude-opus-4-7`
|
||||
- `sonnet` → `claude-sonnet-4-6`
|
||||
- `haiku` → `claude-haiku-4-5-20251213`
|
||||
- `opus` -> `claude-opus-4-8`
|
||||
- `sonnet` -> `claude-sonnet-5`
|
||||
- `haiku` -> `claude-haiku-4-5-20251001`
|
||||
- `fable` -> `claude-fable-5`
|
||||
|
||||
## Authentication
|
||||
|
||||
|
|
@ -290,7 +292,7 @@ export ANTHROPIC_BASE_URL="http://127.0.0.1:8080"
|
|||
export ANTHROPIC_AUTH_TOKEN="local-dev-token"
|
||||
|
||||
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
|
||||
|
|
@ -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 |
|
||||
|---|---|---|---|---|
|
||||
| `opus` | `claude-opus-4-7` | Anthropic | 32 000 | 200 000 |
|
||||
| `sonnet` | `claude-sonnet-4-6` | Anthropic | 64 000 | 200 000 |
|
||||
| `haiku` | `claude-haiku-4-5-20251213` | Anthropic | 64 000 | 200 000 |
|
||||
| `opus` | `claude-opus-4-8` | Anthropic | 128 000 | 1 000 000 |
|
||||
| `sonnet` | `claude-sonnet-5` | Anthropic | 128 000 | 1 000 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-mini` / `grok-3-mini` | `grok-3-mini` | xAI | 64 000 | 131 072 |
|
||||
| `grok-2` | `grok-2` | xAI | — | — |
|
||||
|
|
@ -396,8 +399,8 @@ You can add custom aliases in any settings file (`~/.claw/settings.json`, `.claw
|
|||
```json
|
||||
{
|
||||
"aliases": {
|
||||
"fast": "claude-haiku-4-5-20251213",
|
||||
"smart": "claude-opus-4-7",
|
||||
"fast": "claude-haiku-4-5-20251001",
|
||||
"smart": "claude-opus-4-8",
|
||||
"cheap": "grok-3-mini"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# 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
|
||||
|
||||
|
|
@ -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)
|
||||
- [Reasoning Models (Tuning Parameter Stripping)](#reasoning-models-tuning-parameter-stripping)
|
||||
- [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)
|
||||
- [Custom Gateway Slugs and Extra Body Parameters](#custom-gateway-slugs-and-extra-body-parameters)
|
||||
- [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)
|
||||
|
||||
**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`.
|
||||
|
|
|
|||
|
|
@ -37,9 +37,9 @@ checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299"
|
|||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "1.0.0"
|
||||
version = "0.6.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
|
||||
checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"anstyle-parse",
|
||||
|
|
@ -58,9 +58,9 @@ checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
|||
|
||||
[[package]]
|
||||
name = "anstyle-parse"
|
||||
version = "1.0.0"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
|
||||
checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
|
||||
dependencies = [
|
||||
"utf8parse",
|
||||
]
|
||||
|
|
@ -312,16 +312,15 @@ checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
|
|||
|
||||
[[package]]
|
||||
name = "blake3"
|
||||
version = "1.8.5"
|
||||
version = "1.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce"
|
||||
checksum = "b8ee0c1824c4dea5b5f81736aff91bae041d2c07ee1192bec91054e10e3e601e"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"arrayvec",
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"constant_time_eq",
|
||||
"cpufeatures 0.3.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -412,9 +411,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.6.1"
|
||||
version = "4.5.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
|
||||
checksum = "6088f3ae8c3608d19260cd7445411865a485688711b78b5be70d78cd96136f83"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
|
|
@ -422,9 +421,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.6.0"
|
||||
version = "4.5.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
|
||||
checksum = "22a7ef7f676155edfb82daa97f99441f3ebf4a58d5e32f295a56259f1b6facc8"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
|
|
@ -434,18 +433,18 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "clap_complete"
|
||||
version = "4.6.5"
|
||||
version = "4.5.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e0a7a9bfdb35811f9e59832f0f05975114d2251b415fb534108e6f34060fd772"
|
||||
checksum = "74a01f4f9ee6c066d42a1c8dedf0dcddad16c72a8981a309d6398de3a75b0c39"
|
||||
dependencies = [
|
||||
"clap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "4.6.1"
|
||||
version = "4.5.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
|
||||
checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
|
|
@ -455,9 +454,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "clap_lex"
|
||||
version = "1.1.0"
|
||||
version = "0.7.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||
checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32"
|
||||
|
||||
[[package]]
|
||||
name = "claw-analog"
|
||||
|
|
@ -532,9 +531,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "constant_time_eq"
|
||||
version = "0.4.2"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
|
||||
checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
|
|
@ -561,15 +560,6 @@ dependencies = [
|
|||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
|
|
@ -718,9 +708,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.8"
|
||||
version = "0.3.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||
checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4"
|
||||
dependencies = [
|
||||
"powerfmt",
|
||||
]
|
||||
|
|
@ -808,7 +798,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1019,9 +1009,9 @@ checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
|
|||
|
||||
[[package]]
|
||||
name = "globset"
|
||||
version = "0.4.18"
|
||||
version = "0.4.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3"
|
||||
checksum = "54a1028dfc5f5df5da8a56a73e6c153c9a9708ec57232470703592a3f18e49f5"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"bstr",
|
||||
|
|
@ -1104,11 +1094,11 @@ checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
|
|||
|
||||
[[package]]
|
||||
name = "home"
|
||||
version = "0.5.12"
|
||||
version = "0.5.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d"
|
||||
checksum = "e3d1354bf6b7235cb4a0576c2619fd4ed18183f689b12b006a0ee7329eeff9a5"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1341,9 +1331,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "ignore"
|
||||
version = "0.4.25"
|
||||
version = "0.4.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a"
|
||||
checksum = "6d89fd380afde86567dfba715db065673989d6253f42b88179abd3eae47bda4b"
|
||||
dependencies = [
|
||||
"crossbeam-deque",
|
||||
"globset",
|
||||
|
|
@ -1399,7 +1389,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
|
|||
dependencies = [
|
||||
"hermit-abi",
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1575,9 +1565,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.1"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
|
||||
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
|
|
@ -1911,7 +1901,7 @@ dependencies = [
|
|||
"once_cell",
|
||||
"socket2 0.6.3",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2172,7 +2162,7 @@ dependencies = [
|
|||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.12.1",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2421,7 +2411,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
|
|
@ -2588,7 +2578,7 @@ dependencies = [
|
|||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"rustix 1.1.4",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2633,30 +2623,30 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.47"
|
||||
version = "0.3.36"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
|
||||
checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885"
|
||||
dependencies = [
|
||||
"deranged",
|
||||
"itoa",
|
||||
"num-conv",
|
||||
"powerfmt",
|
||||
"serde_core",
|
||||
"serde",
|
||||
"time-core",
|
||||
"time-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time-core"
|
||||
version = "0.1.8"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
|
||||
checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3"
|
||||
|
||||
[[package]]
|
||||
name = "time-macros"
|
||||
version = "0.2.27"
|
||||
version = "0.2.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
|
||||
checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf"
|
||||
dependencies = [
|
||||
"num-conv",
|
||||
"time-core",
|
||||
|
|
@ -2976,9 +2966,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
|||
|
||||
[[package]]
|
||||
name = "unicode-segmentation"
|
||||
version = "1.13.2"
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
|
||||
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
|
|
@ -3181,7 +3171,7 @@ version = "0.1.11"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -3202,7 +3192,7 @@ version = "0.52.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -3211,16 +3201,7 @@ version = "0.59.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[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",
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -3238,31 +3219,14 @@ version = "0.52.6"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm 0.52.6",
|
||||
"windows_aarch64_msvc 0.52.6",
|
||||
"windows_i686_gnu 0.52.6",
|
||||
"windows_i686_gnullvm 0.52.6",
|
||||
"windows_i686_msvc 0.52.6",
|
||||
"windows_x86_64_gnu 0.52.6",
|
||||
"windows_x86_64_gnullvm 0.52.6",
|
||||
"windows_x86_64_msvc 0.52.6",
|
||||
]
|
||||
|
||||
[[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",
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -3271,96 +3235,48 @@ version = "0.52.6"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "winnow"
|
||||
version = "0.7.15"
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ serde_json = "1"
|
|||
|
||||
[workspace.lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
unexpected_cfgs = { level = "warn", check-cfg = ["cfg(test)"] }
|
||||
|
||||
[workspace.lints.clippy]
|
||||
all = { level = "warn", priority = -1 }
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
| 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)
|
||||
|
||||
|
|
@ -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** |
|
||||
| **ListMcpResources** | `runtime::mcp_tool_bridge` + `tools` | connected-server resource listing — **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** |
|
||||
| **ToolSearch** | `tools` | tool discovery — **good 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** |
|
||||
| **REPL** | `tools` | subprocess code 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)
|
||||
|
||||
| Tool | Status | Notes |
|
||||
|------|--------|-------|
|
||||
| **AskUserQuestion** | stub | needs live user I/O integration |
|
||||
| **McpAuth** | stub | needs full auth UX beyond the MCP lifecycle bridge |
|
||||
| **RemoteTrigger** | stub | needs HTTP client |
|
||||
| **TestingPermission** | stub | test-only, low priority |
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ cargo run -p rusty-claude-cli -- --help
|
|||
cargo build --workspace
|
||||
|
||||
# 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
|
||||
cargo run -p rusty-claude-cli -- prompt "explain this codebase"
|
||||
|
|
@ -100,7 +100,7 @@ Primary artifacts:
|
|||
| Cost / usage / stats surfaces | ✅ |
|
||||
| Git integration | ✅ |
|
||||
| Markdown terminal rendering (ANSI) | ✅ |
|
||||
| Model aliases (opus/sonnet/haiku) | ✅ |
|
||||
| Model aliases (opus/sonnet/haiku/fable) | ✅ |
|
||||
| Direct CLI subcommands (`status`, `sandbox`, `agents`, `mcp`, `skills`, `doctor`) | ✅ |
|
||||
| Slash commands (including `/skills`, `/agents`, `/mcp`, `/doctor`, `/plugin`, `/subagent`) | ✅ |
|
||||
| Hooks (`/hooks`, config-backed lifecycle hooks) | ✅ |
|
||||
|
|
@ -114,9 +114,10 @@ Short names resolve to the latest model versions:
|
|||
|
||||
| Alias | Resolves To |
|
||||
|-------|------------|
|
||||
| `opus` | `claude-opus-4-7` |
|
||||
| `sonnet` | `claude-sonnet-4-6` |
|
||||
| `haiku` | `claude-haiku-4-5-20251213` |
|
||||
| `opus` | `claude-opus-4-8` |
|
||||
| `sonnet` | `claude-sonnet-5` |
|
||||
| `haiku` | `claude-haiku-4-5-20251001` |
|
||||
| `fable` | `claude-fable-5` |
|
||||
|
||||
## CLI Flags and Commands
|
||||
|
||||
|
|
@ -223,8 +224,8 @@ rust/
|
|||
- **~20K lines** of Rust
|
||||
- **9 crates** in workspace
|
||||
- **Binary name:** `claw`
|
||||
- **Default model:** `claude-opus-4-7`
|
||||
- **Default permissions:** `workspace-write`
|
||||
- **Default model:** `anthropic/claude-sonnet-5`
|
||||
- **Default permissions:** `manual`
|
||||
|
||||
## License
|
||||
|
||||
|
|
|
|||
|
|
@ -170,7 +170,9 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
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-mini"), "grok-3-mini");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
#![allow(clippy::result_large_err)]
|
||||
|
||||
mod client;
|
||||
mod error;
|
||||
mod http_client;
|
||||
|
|
|
|||
|
|
@ -1509,7 +1509,7 @@ mod tests {
|
|||
#[test]
|
||||
fn strip_unsupported_beta_body_fields_removes_betas_array() {
|
||||
let mut body = serde_json::json!({
|
||||
"model": "claude-sonnet-4-6",
|
||||
"model": "claude-sonnet-5",
|
||||
"max_tokens": 1024,
|
||||
"betas": ["claude-code-20250219", "prompt-caching-scope-2026-01-05"],
|
||||
"metadata": {"source": "test"},
|
||||
|
|
@ -1523,7 +1523,7 @@ mod tests {
|
|||
);
|
||||
assert_eq!(
|
||||
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["metadata"]["source"], serde_json::json!("test"));
|
||||
|
|
@ -1532,7 +1532,7 @@ mod tests {
|
|||
#[test]
|
||||
fn strip_unsupported_beta_body_fields_is_a_noop_when_betas_absent() {
|
||||
let mut body = serde_json::json!({
|
||||
"model": "claude-sonnet-4-6",
|
||||
"model": "claude-sonnet-5",
|
||||
"max_tokens": 1024,
|
||||
});
|
||||
let original = body.clone();
|
||||
|
|
@ -1545,7 +1545,7 @@ mod tests {
|
|||
#[test]
|
||||
fn strip_removes_openai_only_fields_and_converts_stop() {
|
||||
let mut body = serde_json::json!({
|
||||
"model": "claude-sonnet-4-6",
|
||||
"model": "claude-sonnet-5",
|
||||
"max_tokens": 1024,
|
||||
"temperature": 0.7,
|
||||
"frequency_penalty": 0.5,
|
||||
|
|
@ -1574,7 +1574,7 @@ mod tests {
|
|||
#[test]
|
||||
fn strip_does_not_add_empty_stop_sequences() {
|
||||
let mut body = serde_json::json!({
|
||||
"model": "claude-sonnet-4-6",
|
||||
"model": "claude-sonnet-5",
|
||||
"max_tokens": 1024,
|
||||
"stop": [],
|
||||
});
|
||||
|
|
@ -1592,7 +1592,7 @@ mod tests {
|
|||
fn rendered_request_body_strips_betas_for_standard_messages_endpoint() {
|
||||
let client = AnthropicClient::new("test-key").with_beta("tools-2026-04-01");
|
||||
let request = MessageRequest {
|
||||
model: "claude-sonnet-4-6".to_string(),
|
||||
model: "claude-sonnet-5".to_string(),
|
||||
max_tokens: 64,
|
||||
messages: vec![],
|
||||
system: None,
|
||||
|
|
@ -1618,7 +1618,7 @@ mod tests {
|
|||
);
|
||||
assert_eq!(
|
||||
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() {
|
||||
let client = AnthropicClient::new("test-key");
|
||||
let request = MessageRequest {
|
||||
model: "anthropic/claude-opus-4-6".to_string(),
|
||||
model: "anthropic/claude-opus-4-8".to_string(),
|
||||
max_tokens: 64,
|
||||
messages: vec![],
|
||||
system: None,
|
||||
|
|
@ -1639,7 +1639,7 @@ mod tests {
|
|||
let rendered = super::render_standard_messages_body(client.request_profile(), &request)
|
||||
.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());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -146,6 +146,15 @@ const MODEL_REGISTRY: &[(&str, ProviderMetadata)] = &[
|
|||
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",
|
||||
ProviderMetadata {
|
||||
|
|
@ -211,9 +220,10 @@ pub fn resolve_model_alias(model: &str) -> String {
|
|||
.find_map(|(alias, metadata)| {
|
||||
(*alias == lower).then_some(match metadata.provider {
|
||||
ProviderKind::Anthropic => match *alias {
|
||||
"opus" => "claude-opus-4-7",
|
||||
"sonnet" => "claude-sonnet-4-6",
|
||||
"haiku" => "claude-haiku-4-5-20251213",
|
||||
"opus" => "claude-opus-4-8",
|
||||
"sonnet" => "claude-sonnet-5",
|
||||
"haiku" => "claude-haiku-4-5-20251001",
|
||||
"fable" => "claude-fable-5",
|
||||
_ => trimmed,
|
||||
},
|
||||
ProviderKind::Xai => match *alias {
|
||||
|
|
@ -634,11 +644,15 @@ pub fn model_token_limit(model: &str) -> Option<ModelTokenLimit> {
|
|||
let canonical = resolve_model_alias(model);
|
||||
let base_model = canonical.rsplit('/').next().unwrap_or(canonical.as_str());
|
||||
match base_model {
|
||||
"claude-opus-4-7" | "claude-opus-4-6" => Some(ModelTokenLimit {
|
||||
max_output_tokens: 32_000,
|
||||
context_window_tokens: 200_000,
|
||||
"claude-opus-4-8" | "claude-opus-4-7" | "claude-opus-4-6" => Some(ModelTokenLimit {
|
||||
max_output_tokens: 128_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,
|
||||
context_window_tokens: 200_000,
|
||||
}),
|
||||
|
|
@ -1222,7 +1236,7 @@ mod tests {
|
|||
model_token_limit("claude-sonnet-4-6")
|
||||
.expect("claude-sonnet-4-6 should be registered")
|
||||
.context_window_tokens,
|
||||
200_000
|
||||
1_000_000
|
||||
);
|
||||
assert_eq!(
|
||||
model_token_limit("grok-mini")
|
||||
|
|
@ -1252,7 +1266,7 @@ mod tests {
|
|||
messages: vec![InputMessage {
|
||||
role: "user".to_string(),
|
||||
content: vec![InputContentBlock::Text {
|
||||
text: "x".repeat(600_000),
|
||||
text: "x".repeat(3_900_000),
|
||||
}],
|
||||
}],
|
||||
system: Some("Keep the answer short.".to_string()),
|
||||
|
|
@ -1281,10 +1295,10 @@ mod tests {
|
|||
context_window_tokens,
|
||||
} => {
|
||||
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!(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:?}"),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1908,7 +1908,7 @@ mod tests {
|
|||
"deepseek-reasoner",
|
||||
"deepseek-chat",
|
||||
"gpt-4o",
|
||||
"claude-sonnet-4-6",
|
||||
"claude-sonnet-5",
|
||||
];
|
||||
|
||||
// When checking whether history reasoning_content is required.
|
||||
|
|
@ -2461,7 +2461,7 @@ mod tests {
|
|||
assert!(is_reasoning_model("o3-mini"));
|
||||
assert!(!is_reasoning_model("gpt-4o"));
|
||||
assert!(!is_reasoning_model("grok-3"));
|
||||
assert!(!is_reasoning_model("claude-sonnet-4-6"));
|
||||
assert!(!is_reasoning_model("claude-sonnet-5"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -2699,7 +2699,7 @@ mod tests {
|
|||
// Non-kimi models should NOT be detected
|
||||
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("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-mini"));
|
||||
assert!(!super::model_rejects_is_error_field("xai/grok-3"));
|
||||
|
|
@ -2755,7 +2755,7 @@ mod tests {
|
|||
assert_eq!(translated2[0]["is_error"], json!(false));
|
||||
|
||||
// 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!(
|
||||
translated3[0].get("is_error").is_some(),
|
||||
"claude should include is_error field"
|
||||
|
|
|
|||
|
|
@ -119,7 +119,7 @@ async fn send_message_strips_anthropic_routing_prefix_on_wire() {
|
|||
"\"type\":\"message\",",
|
||||
"\"role\":\"assistant\",",
|
||||
"\"content\":[{\"type\":\"text\",\"text\":\"ok\"}],",
|
||||
"\"model\":\"claude-opus-4-6\",",
|
||||
"\"model\":\"claude-opus-4-8\",",
|
||||
"\"stop_reason\":\"end_turn\",",
|
||||
"\"stop_sequence\":null,",
|
||||
"\"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());
|
||||
client
|
||||
.send_message(&MessageRequest {
|
||||
model: "anthropic/claude-opus-4-6".to_string(),
|
||||
model: "anthropic/claude-opus-4-8".to_string(),
|
||||
..sample_request(false)
|
||||
})
|
||||
.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");
|
||||
assert_eq!(captured[0].path, "/v1/messages/count_tokens");
|
||||
assert_eq!(captured[1].path, "/v1/messages");
|
||||
assert_eq!(count_tokens_body["model"], json!("claude-opus-4-6"));
|
||||
assert_eq!(messages_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-8"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_message_blocks_oversized_requests_before_the_http_call() {
|
||||
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
|
||||
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 client = AnthropicClient::new("test-key").with_base_url("http://127.0.0.1:9".to_string());
|
||||
let error = client
|
||||
.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,
|
||||
messages: vec![InputMessage {
|
||||
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");
|
||||
|
||||
assert!(matches!(error, ApiError::ContextWindowExceeded { .. }));
|
||||
assert!(
|
||||
state.lock().await.is_empty(),
|
||||
"preflight failure should avoid any upstream HTTP request"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -523,7 +514,7 @@ async fn provider_client_dispatches_anthropic_requests() {
|
|||
.await;
|
||||
|
||||
let client = ProviderClient::from_model_with_anthropic_auth(
|
||||
"claude-sonnet-4-6",
|
||||
"claude-sonnet-5",
|
||||
Some(AuthSource::ApiKey("test-key".to_string())),
|
||||
)
|
||||
.expect("anthropic provider client should be constructed");
|
||||
|
|
|
|||
|
|
@ -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 client = ProviderClient::from_model_with_anthropic_auth(
|
||||
"claude-sonnet-4-6",
|
||||
"claude-sonnet-5",
|
||||
Some(AuthSource::ApiKey("anthropic-test-key".to_string())),
|
||||
)
|
||||
.expect("explicit anthropic auth should avoid env lookup");
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@ description = "Minimal agent harness: tool loop with explicit permissions and wo
|
|||
name = "claw_analog"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "claw-analog"
|
||||
path = "src/main.rs"
|
||||
|
|
|
|||
|
|
@ -248,6 +248,7 @@ pub const ANALOG_DEFAULT_MODEL: &str = "sonnet";
|
|||
pub fn permission_mode_from_toml_str(s: &str) -> Option<PermissionMode> {
|
||||
match s.to_ascii_lowercase().replace('_', "-").as_str() {
|
||||
"read-only" | "readonly" => Some(PermissionMode::ReadOnly),
|
||||
"manual" | "default" => Some(PermissionMode::Manual),
|
||||
"workspace-write" | "write" => Some(PermissionMode::WorkspaceWrite),
|
||||
"prompt" => Some(PermissionMode::Prompt),
|
||||
"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}. \
|
||||
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!(
|
||||
"You are a coding assistant with read/list/glob/grep/write{git_blurb}{rag_blurb}. Workspace root: {root_s}. \
|
||||
Relative paths only; no `..`."
|
||||
|
|
@ -2744,7 +2749,7 @@ glob_max_paths = 100
|
|||
let _g2 = EnvVarGuard::set("ANTHROPIC_BASE_URL", url.as_str());
|
||||
|
||||
let config = AnalogConfig {
|
||||
model: "claude-sonnet-4-6".into(),
|
||||
model: "claude-sonnet-5".into(),
|
||||
workspace: root.clone(),
|
||||
permission_mode: PermissionMode::ReadOnly,
|
||||
accept_danger_non_interactive: false,
|
||||
|
|
@ -2796,7 +2801,7 @@ glob_max_paths = 100
|
|||
let export = dir.path().join("export-session.json");
|
||||
|
||||
let config = AnalogConfig {
|
||||
model: "claude-sonnet-4-6".into(),
|
||||
model: "claude-sonnet-5".into(),
|
||||
workspace: root,
|
||||
permission_mode: PermissionMode::ReadOnly,
|
||||
accept_danger_non_interactive: false,
|
||||
|
|
@ -2849,7 +2854,7 @@ glob_max_paths = 100
|
|||
let _g2 = EnvVarGuard::set("ANTHROPIC_BASE_URL", url.as_str());
|
||||
|
||||
let config = AnalogConfig {
|
||||
model: "claude-sonnet-4-6".into(),
|
||||
model: "claude-sonnet-5".into(),
|
||||
workspace: root,
|
||||
permission_mode: PermissionMode::ReadOnly,
|
||||
accept_danger_non_interactive: false,
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ async fn flush_path_batch(
|
|||
#[cfg(feature = "qdrant-index")]
|
||||
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 cid = insert_chunk(conn, path, ord, &t)?;
|
||||
insert_embedding(conn, cid, dim, &vec)?;
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
|
|||
name: "permissions",
|
||||
aliases: &[],
|
||||
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,
|
||||
},
|
||||
SlashCommandSpec {
|
||||
|
|
@ -132,7 +132,7 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
|
|||
name: "mcp",
|
||||
aliases: &[],
|
||||
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,
|
||||
},
|
||||
SlashCommandSpec {
|
||||
|
|
@ -266,12 +266,26 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
|
|||
resume_supported: true,
|
||||
},
|
||||
SlashCommandSpec {
|
||||
name: "review",
|
||||
aliases: &[],
|
||||
name: "code-review",
|
||||
aliases: &["review", "simplify"],
|
||||
summary: "Run a code review on current changes",
|
||||
argument_hint: Some("[scope]"),
|
||||
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 {
|
||||
name: "tasks",
|
||||
aliases: &[],
|
||||
|
|
@ -1397,6 +1411,18 @@ pub fn validate_slash_command_input(
|
|||
"skills" | "skill" => SlashCommand::Skills {
|
||||
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" => {
|
||||
validate_no_args(command, &args)?;
|
||||
SlashCommand::Doctor
|
||||
|
|
@ -1489,7 +1515,7 @@ pub fn validate_slash_command_input(
|
|||
SlashCommand::PrivacySettings
|
||||
}
|
||||
"plan" => SlashCommand::Plan { mode: remainder },
|
||||
"review" => SlashCommand::Review { scope: remainder },
|
||||
"review" | "code-review" | "simplify" => SlashCommand::Review { scope: remainder },
|
||||
"tasks" => SlashCommand::Tasks { args: remainder },
|
||||
"theme" => SlashCommand::Theme { name: 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(
|
||||
"permissions",
|
||||
args,
|
||||
"[read-only|workspace-write|danger-full-access]",
|
||||
"[manual|read-only|workspace-write|danger-full-access]",
|
||||
)?;
|
||||
if let Some(mode) = mode {
|
||||
if matches!(
|
||||
mode.as_str(),
|
||||
"read-only" | "workspace-write" | "danger-full-access"
|
||||
"manual" | "default" | "read-only" | "workspace-write" | "danger-full-access"
|
||||
) {
|
||||
return Ok(Some(mode));
|
||||
}
|
||||
return Err(command_error(
|
||||
&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 [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 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 {
|
||||
action: Some("help".to_string()),
|
||||
target: None,
|
||||
}),
|
||||
[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 [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) {
|
||||
None | Some("list") => {
|
||||
None | Some("list") | Some("reload") => {
|
||||
let roots = discover_skill_roots(cwd);
|
||||
let skills = load_skills_from_roots(&roots)?;
|
||||
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) {
|
||||
None | Some("list") => {
|
||||
None | Some("list") | Some("reload") => {
|
||||
let roots = discover_skill_roots(cwd);
|
||||
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 ") => {
|
||||
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) {
|
||||
None
|
||||
| Some(
|
||||
"list" | "help" | "-h" | "--help" | "show" | "info" | "describe" | "install"
|
||||
"list" | "reload" | "help" | "-h" | "--help" | "show" | "info" | "describe" | "install"
|
||||
| "uninstall" | "remove" | "delete",
|
||||
) => SkillSlashDispatch::Local,
|
||||
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(' ') => {
|
||||
// `mcp list <filter>` — list does not accept arguments; treat as unsupported action.
|
||||
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 {
|
||||
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,
|
||||
"hint": hint,
|
||||
"usage": {
|
||||
"slash_command": "/mcp [list|show <server>|help]",
|
||||
"direct_cli": "claw mcp [list|show <server>|help]",
|
||||
"slash_command": "/mcp [list|show <server>|login <server>|logout <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)]
|
||||
fn render_mcp_report_json_for(
|
||||
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(' ') => {
|
||||
Ok(render_mcp_unsupported_action_json(
|
||||
args,
|
||||
|
|
@ -4973,8 +5073,10 @@ fn render_skills_usage_json(unexpected: Option<&str>) -> Value {
|
|||
fn render_mcp_usage(unexpected: Option<&str>) -> String {
|
||||
let mut lines = vec![
|
||||
"MCP".to_string(),
|
||||
" Usage /mcp [list|show <server>|help]".to_string(),
|
||||
" Direct CLI claw mcp [list|show <server>|help]".to_string(),
|
||||
" Usage /mcp [list|show <server>|login <server>|logout <server>|help]"
|
||||
.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(),
|
||||
];
|
||||
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",
|
||||
};
|
||||
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",
|
||||
"Usage: claw mcp [list|show <server>|help]",
|
||||
"Usage: claw mcp [list|show <server>|login <server>|logout <server>|help]",
|
||||
),
|
||||
};
|
||||
json!({
|
||||
|
|
@ -5013,8 +5115,8 @@ fn render_mcp_missing_argument_json(action: &str) -> Value {
|
|||
"message": message,
|
||||
"hint": hint,
|
||||
"usage": {
|
||||
"slash_command": "/mcp [list|show <server>|help]",
|
||||
"direct_cli": "claw mcp [list|show <server>|help]",
|
||||
"slash_command": "/mcp [list|show <server>|login <server>|logout <server>|help]",
|
||||
"direct_cli": "claw mcp [list|show <server>|login <server>|logout <server>|help]",
|
||||
"sources": [".claw/settings.json", ".claw/settings.local.json"],
|
||||
},
|
||||
"unexpected": Value::Null,
|
||||
|
|
@ -5043,8 +5145,8 @@ fn render_mcp_usage_json(unexpected: Option<&str>) -> Value {
|
|||
"error_kind": error_kind,
|
||||
"hint": hint,
|
||||
"usage": {
|
||||
"slash_command": "/mcp [list|show <server>|help]",
|
||||
"direct_cli": "claw mcp [list|show <server>|help]",
|
||||
"slash_command": "/mcp [list|show <server>|login <server>|logout <server>|help]",
|
||||
"direct_cli": "claw mcp [list|show <server>|login <server>|logout <server>|help]",
|
||||
"sources": [".claw.json", ".claw/settings.json", ".claw/settings.local.json"],
|
||||
},
|
||||
"unexpected": unexpected,
|
||||
|
|
@ -5802,10 +5904,10 @@ mod tests {
|
|||
|
||||
// then
|
||||
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(
|
||||
" 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>"));
|
||||
|
||||
let action_error = parse_error_message("/mcp inspect alpha");
|
||||
assert!(action_error
|
||||
.contains("Unknown /mcp action 'inspect'. Use list, show <server>, or help."));
|
||||
assert!(action_error.contains(" Usage /mcp [list|show <server>|help]"));
|
||||
assert!(action_error.contains(
|
||||
"Unknown /mcp action 'inspect'. Use list, show <server>, login <server>, logout <server>, or help."
|
||||
));
|
||||
assert!(action_error.contains(
|
||||
" Usage /mcp [list|show <server>|login <server>|logout <server>|help]"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -5987,12 +6092,12 @@ mod tests {
|
|||
assert!(help.contains("/teleport <symbol-or-path>"));
|
||||
assert!(help.contains("/debug-tool-call"));
|
||||
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("/cost"));
|
||||
assert!(help.contains("/resume <session-path>"));
|
||||
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("/init"));
|
||||
assert!(help.contains("/diff"));
|
||||
|
|
@ -6012,7 +6117,7 @@ mod tests {
|
|||
assert!(!help.contains("/login"));
|
||||
assert!(!help.contains("/logout"));
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
@ -6758,8 +6863,12 @@ mod tests {
|
|||
let cwd = temp_dir("mcp-usage");
|
||||
|
||||
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("Direct CLI claw mcp [list|show <server>|help]"));
|
||||
assert!(help.contains(
|
||||
"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 =
|
||||
super::handle_mcp_slash_command(Some("show alpha beta"), &cwd).expect("mcp usage");
|
||||
|
|
@ -6767,12 +6876,16 @@ mod tests {
|
|||
|
||||
let nested_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"));
|
||||
|
||||
let unknown_help =
|
||||
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"));
|
||||
|
||||
let _ = fs::remove_dir_all(cwd);
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ use tokio::sync::{oneshot, Mutex};
|
|||
use tokio::task::JoinHandle;
|
||||
|
||||
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)]
|
||||
pub struct CapturedRequest {
|
||||
|
|
|
|||
|
|
@ -296,9 +296,10 @@ pub fn validate_mode(command: &str, mode: PermissionMode) -> ValidationResult {
|
|||
}
|
||||
ValidationResult::Allow
|
||||
}
|
||||
PermissionMode::DangerFullAccess | PermissionMode::Allow | PermissionMode::Prompt => {
|
||||
ValidationResult::Allow
|
||||
}
|
||||
PermissionMode::DangerFullAccess
|
||||
| PermissionMode::Allow
|
||||
| PermissionMode::Manual
|
||||
| PermissionMode::Prompt => ValidationResult::Allow,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -175,6 +175,8 @@ pub fn compact_session(session: &Session, config: CompactionConfig) -> Compactio
|
|||
role: MessageRole::System,
|
||||
blocks: vec![ContentBlock::Text { text: continuation }],
|
||||
usage: None,
|
||||
incomplete: false,
|
||||
incomplete_reason: None,
|
||||
}];
|
||||
compacted_messages.extend(preserved);
|
||||
|
||||
|
|
@ -611,6 +613,8 @@ mod tests {
|
|||
text: "recent".to_string(),
|
||||
}],
|
||||
usage: None,
|
||||
incomplete: false,
|
||||
incomplete_reason: None,
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -725,6 +729,8 @@ mod tests {
|
|||
text: get_compact_continuation_message(summary, true, true),
|
||||
}],
|
||||
usage: None,
|
||||
incomplete: false,
|
||||
incomplete_reason: None,
|
||||
},
|
||||
ConversationMessage::user_text("tiny"),
|
||||
ConversationMessage::assistant(vec![ContentBlock::Text {
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ pub enum ConfigSource {
|
|||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ResolvedPermissionMode {
|
||||
ReadOnly,
|
||||
Manual,
|
||||
WorkspaceWrite,
|
||||
DangerFullAccess,
|
||||
}
|
||||
|
|
@ -123,6 +124,10 @@ pub struct RuntimePluginConfig {
|
|||
registry_path: Option<String>,
|
||||
bundled_root: Option<String>,
|
||||
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.
|
||||
|
|
@ -1082,6 +1087,26 @@ impl RuntimePluginConfig {
|
|||
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>) {
|
||||
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") {
|
||||
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 {
|
||||
return Ok(config);
|
||||
|
|
@ -2013,6 +2043,9 @@ fn parse_optional_plugin_config(root: &JsonValue) -> Result<RuntimePluginConfig,
|
|||
config.bundled_root =
|
||||
optional_string(plugins, "bundledRoot", "merged settings.plugins")?.map(str::to_string);
|
||||
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)
|
||||
}
|
||||
|
||||
|
|
@ -2041,7 +2074,8 @@ fn parse_permission_mode_label(
|
|||
context: &str,
|
||||
) -> Result<ResolvedPermissionMode, ConfigError> {
|
||||
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),
|
||||
"dontAsk" | "danger-full-access" => Ok(ResolvedPermissionMode::DangerFullAccess),
|
||||
other => Err(ConfigError::Parse(format!(
|
||||
|
|
@ -2915,7 +2949,7 @@ mod tests {
|
|||
home.join("settings.json"),
|
||||
r#"{
|
||||
"providerFallbacks": {
|
||||
"primary": "claude-opus-4-6",
|
||||
"primary": "claude-opus-4-8",
|
||||
"fallbacks": ["grok-3", "grok-3-mini"]
|
||||
}
|
||||
}"#,
|
||||
|
|
@ -2929,7 +2963,7 @@ mod tests {
|
|||
|
||||
// then
|
||||
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!(
|
||||
chain.fallbacks(),
|
||||
&["grok-3".to_string(), "grok-3-mini".to_string()]
|
||||
|
|
@ -3305,11 +3339,15 @@ mod tests {
|
|||
"enabledPlugins": {
|
||||
"core-helpers@builtin": true
|
||||
},
|
||||
"pluginSuggestionMarketplaces": ["team-marketplace"],
|
||||
"disableBundledSkills": true,
|
||||
"plugins": {
|
||||
"externalDirectories": ["./external-plugins"],
|
||||
"installRoot": "plugin-cache/installed",
|
||||
"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")
|
||||
);
|
||||
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");
|
||||
}
|
||||
|
|
@ -3435,12 +3480,12 @@ mod tests {
|
|||
|
||||
fs::write(
|
||||
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");
|
||||
fs::write(
|
||||
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");
|
||||
|
||||
|
|
@ -3453,11 +3498,11 @@ mod tests {
|
|||
let aliases = loaded.aliases();
|
||||
assert_eq!(
|
||||
aliases.get("fast").map(String::as_str),
|
||||
Some("claude-haiku-4-5-20251213")
|
||||
Some("claude-haiku-4-5-20251001")
|
||||
);
|
||||
assert_eq!(
|
||||
aliases.get("smart").map(String::as_str),
|
||||
Some("claude-sonnet-4-6")
|
||||
Some("claude-sonnet-5")
|
||||
);
|
||||
assert_eq!(
|
||||
aliases.get("cheap").map(String::as_str),
|
||||
|
|
@ -3574,6 +3619,14 @@ mod tests {
|
|||
parse_permission_mode_label("plan", "test").expect("plan should resolve"),
|
||||
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!(
|
||||
parse_permission_mode_label("acceptEdits", "test").expect("acceptEdits should resolve"),
|
||||
ResolvedPermissionMode::WorkspaceWrite
|
||||
|
|
|
|||
|
|
@ -184,6 +184,14 @@ const TOP_LEVEL_FIELDS: &[FieldSpec] = &[
|
|||
name: "enabledPlugins",
|
||||
expected: FieldType::Object,
|
||||
},
|
||||
FieldSpec {
|
||||
name: "pluginSuggestionMarketplaces",
|
||||
expected: FieldType::StringArray,
|
||||
},
|
||||
FieldSpec {
|
||||
name: "disableBundledSkills",
|
||||
expected: FieldType::Bool,
|
||||
},
|
||||
FieldSpec {
|
||||
name: "plugins",
|
||||
expected: FieldType::Object,
|
||||
|
|
@ -285,6 +293,14 @@ const PLUGINS_FIELDS: &[FieldSpec] = &[
|
|||
name: "maxOutputTokens",
|
||||
expected: FieldType::Number,
|
||||
},
|
||||
FieldSpec {
|
||||
name: "skipLfs",
|
||||
expected: FieldType::Bool,
|
||||
},
|
||||
FieldSpec {
|
||||
name: "defaultEnabled",
|
||||
expected: FieldType::Bool,
|
||||
},
|
||||
];
|
||||
|
||||
const SANDBOX_FIELDS: &[FieldSpec] = &[
|
||||
|
|
|
|||
|
|
@ -40,6 +40,9 @@ pub enum AssistantEvent {
|
|||
},
|
||||
Usage(TokenUsage),
|
||||
PromptCache(PromptCacheEvent),
|
||||
IncompleteResponse {
|
||||
reason: String,
|
||||
},
|
||||
MessageStop,
|
||||
}
|
||||
|
||||
|
|
@ -733,6 +736,7 @@ fn build_assistant_message(
|
|||
let mut blocks = Vec::new();
|
||||
let mut prompt_cache_events = Vec::new();
|
||||
let mut finished = false;
|
||||
let mut incomplete_reason = None;
|
||||
let mut usage = None;
|
||||
|
||||
for event in events {
|
||||
|
|
@ -754,6 +758,7 @@ fn build_assistant_message(
|
|||
}
|
||||
AssistantEvent::Usage(value) => usage = Some(value),
|
||||
AssistantEvent::PromptCache(event) => prompt_cache_events.push(event),
|
||||
AssistantEvent::IncompleteResponse { reason } => incomplete_reason = Some(reason),
|
||||
AssistantEvent::MessageStop => {
|
||||
finished = true;
|
||||
}
|
||||
|
|
@ -772,7 +777,7 @@ fn build_assistant_message(
|
|||
}
|
||||
|
||||
Ok((
|
||||
ConversationMessage::assistant_with_usage(blocks, usage),
|
||||
ConversationMessage::assistant_with_usage_status(blocks, usage, incomplete_reason),
|
||||
usage,
|
||||
prompt_cache_events,
|
||||
))
|
||||
|
|
|
|||
|
|
@ -39,7 +39,10 @@ impl PermissionEnforcer {
|
|||
pub fn check(&self, tool_name: &str, input: &str) -> EnforcementResult {
|
||||
// When the active mode is Prompt, defer to the caller's interactive
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
|
@ -75,14 +78,17 @@ impl PermissionEnforcer {
|
|||
) -> EnforcementResult {
|
||||
// When the active mode is Prompt, defer to the caller's interactive
|
||||
// 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;
|
||||
}
|
||||
|
||||
let active_mode = self.policy.active_mode();
|
||||
|
||||
// Check if active mode meets the dynamically determined required mode
|
||||
if active_mode >= required_mode {
|
||||
// Check if active mode meets the dynamically determined required mode.
|
||||
if active_mode.grants(required_mode) {
|
||||
return EnforcementResult::Allowed;
|
||||
}
|
||||
|
||||
|
|
@ -115,6 +121,12 @@ impl PermissionEnforcer {
|
|||
required_mode: PermissionMode::WorkspaceWrite.as_str().to_owned(),
|
||||
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 => {
|
||||
if is_within_workspace(path, workspace_root) {
|
||||
EnforcementResult::Allowed
|
||||
|
|
@ -161,11 +173,11 @@ impl PermissionEnforcer {
|
|||
}
|
||||
}
|
||||
}
|
||||
PermissionMode::Prompt => EnforcementResult::Denied {
|
||||
PermissionMode::Manual | PermissionMode::Prompt => EnforcementResult::Denied {
|
||||
tool: "bash".to_owned(),
|
||||
active_mode: mode.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
|
||||
_ => EnforcementResult::Allowed,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ use crate::config::RuntimePermissionRuleConfig;
|
|||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum PermissionMode {
|
||||
ReadOnly,
|
||||
Manual,
|
||||
WorkspaceWrite,
|
||||
DangerFullAccess,
|
||||
Prompt,
|
||||
|
|
@ -19,12 +20,22 @@ impl PermissionMode {
|
|||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::ReadOnly => "read-only",
|
||||
Self::Manual => "manual",
|
||||
Self::WorkspaceWrite => "workspace-write",
|
||||
Self::DangerFullAccess => "danger-full-access",
|
||||
Self::Prompt => "prompt",
|
||||
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.
|
||||
|
|
@ -253,7 +264,7 @@ impl PermissionPolicy {
|
|||
}
|
||||
if allow_rule.is_some()
|
||||
|| current_mode == PermissionMode::Allow
|
||||
|| current_mode >= required_mode
|
||||
|| current_mode.grants(required_mode)
|
||||
{
|
||||
return PermissionOutcome::Allow;
|
||||
}
|
||||
|
|
@ -278,14 +289,16 @@ impl PermissionPolicy {
|
|||
|
||||
if allow_rule.is_some()
|
||||
|| current_mode == PermissionMode::Allow
|
||||
|| current_mode >= required_mode
|
||||
|| current_mode.grants(required_mode)
|
||||
{
|
||||
return PermissionOutcome::Allow;
|
||||
}
|
||||
|
||||
if current_mode == PermissionMode::Prompt
|
||||
|| (current_mode == PermissionMode::WorkspaceWrite
|
||||
&& required_mode == PermissionMode::DangerFullAccess)
|
||||
if matches!(
|
||||
current_mode,
|
||||
PermissionMode::Manual | PermissionMode::Prompt
|
||||
) || (current_mode == PermissionMode::WorkspaceWrite
|
||||
&& required_mode == PermissionMode::DangerFullAccess)
|
||||
{
|
||||
let reason = Some(format!(
|
||||
"tool '{tool_name}' requires approval to escalate from {} to {}",
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
|
|||
|
||||
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)]
|
||||
pub struct PolicyRule {
|
||||
|
|
|
|||
|
|
@ -57,6 +57,8 @@ pub struct ConversationMessage {
|
|||
pub role: MessageRole,
|
||||
pub blocks: Vec<ContentBlock>,
|
||||
pub usage: Option<TokenUsage>,
|
||||
pub incomplete: bool,
|
||||
pub incomplete_reason: Option<String>,
|
||||
}
|
||||
|
||||
/// Metadata describing the latest compaction that summarized a session.
|
||||
|
|
@ -715,6 +717,8 @@ impl ConversationMessage {
|
|||
role: MessageRole::User,
|
||||
blocks: vec![ContentBlock::Text { text: text.into() }],
|
||||
usage: None,
|
||||
incomplete: false,
|
||||
incomplete_reason: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -724,15 +728,28 @@ impl ConversationMessage {
|
|||
role: MessageRole::Assistant,
|
||||
blocks,
|
||||
usage: None,
|
||||
incomplete: false,
|
||||
incomplete_reason: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
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 {
|
||||
role: MessageRole::Assistant,
|
||||
blocks,
|
||||
usage,
|
||||
incomplete: incomplete_reason.is_some(),
|
||||
incomplete_reason,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -752,6 +769,8 @@ impl ConversationMessage {
|
|||
is_error,
|
||||
}],
|
||||
usage: None,
|
||||
incomplete: false,
|
||||
incomplete_reason: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -777,6 +796,15 @@ impl ConversationMessage {
|
|||
if let Some(usage) = self.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)
|
||||
}
|
||||
|
||||
|
|
@ -807,10 +835,20 @@ impl ConversationMessage {
|
|||
.map(ContentBlock::from_json)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
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 {
|
||||
role,
|
||||
blocks,
|
||||
usage,
|
||||
incomplete,
|
||||
incomplete_reason,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1060,6 +1098,15 @@ fn persisted_message_json(message: &ConversationMessage) -> JsonValue {
|
|||
if let Some(usage) = message.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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ use std::sync::{Arc, Mutex};
|
|||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{validate_packet, TaskPacket, TaskPacketValidationError};
|
||||
|
||||
|
|
@ -46,6 +47,26 @@ pub struct Task {
|
|||
pub output: String,
|
||||
pub team_id: Option<String>,
|
||||
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)]
|
||||
|
|
@ -120,6 +141,14 @@ fn now_secs() -> u64 {
|
|||
.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 {
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
|
|
@ -127,7 +156,29 @@ impl TaskRegistry {
|
|||
}
|
||||
|
||||
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(
|
||||
|
|
@ -140,7 +191,13 @@ impl TaskRegistry {
|
|||
.scope_path
|
||||
.clone()
|
||||
.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(
|
||||
|
|
@ -148,6 +205,8 @@ impl TaskRegistry {
|
|||
prompt: String,
|
||||
description: Option<String>,
|
||||
task_packet: Option<TaskPacket>,
|
||||
active_form: Option<String>,
|
||||
metadata: Option<Value>,
|
||||
) -> Task {
|
||||
let mut inner = self.inner.lock().expect("registry lock poisoned");
|
||||
inner.counter += 1;
|
||||
|
|
@ -165,6 +224,10 @@ impl TaskRegistry {
|
|||
output: String::new(),
|
||||
team_id: None,
|
||||
heartbeat: None,
|
||||
active_form,
|
||||
metadata,
|
||||
blocks: Vec::new(),
|
||||
blocked_by: Vec::new(),
|
||||
};
|
||||
inner.tasks.insert(task_id, task.clone());
|
||||
task
|
||||
|
|
@ -269,17 +332,46 @@ impl TaskRegistry {
|
|||
}
|
||||
|
||||
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 task = inner
|
||||
.tasks
|
||||
.get_mut(task_id)
|
||||
.ok_or_else(|| format!("task not found: {task_id}"))?;
|
||||
|
||||
task.messages.push(TaskMessage {
|
||||
role: String::from("user"),
|
||||
content: message.to_owned(),
|
||||
timestamp: now_secs(),
|
||||
});
|
||||
if let Some(message) = update.message {
|
||||
task.messages.push(TaskMessage {
|
||||
role: String::from("user"),
|
||||
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();
|
||||
Ok(task.clone())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ impl Default for TridentConfig {
|
|||
}
|
||||
|
||||
/// Statistics from a Trident compaction run.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct TridentStats {
|
||||
pub superseded_count: usize,
|
||||
pub collapsed_chains: usize,
|
||||
|
|
@ -41,21 +41,6 @@ pub struct TridentStats {
|
|||
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 {
|
||||
pub fn format_report(&self) -> String {
|
||||
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();
|
||||
|
||||
for (_path, ops) in &file_ops {
|
||||
for ops in file_ops.values() {
|
||||
if ops.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -205,11 +190,7 @@ fn stage1_supersede(messages: &[ConversationMessage]) -> (Vec<ConversationMessag
|
|||
|
||||
if let Some(last_write) = last_write_idx {
|
||||
for op in ops {
|
||||
if op.op_type == FileOp::Read && 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
|
||||
{
|
||||
if op.index < last_write {
|
||||
obsolete_indices.insert(op.index);
|
||||
}
|
||||
}
|
||||
|
|
@ -323,9 +304,11 @@ fn stage2_collapse(
|
|||
text: format!("[Collapsed Conversation]\n{summary}"),
|
||||
}],
|
||||
usage: None,
|
||||
incomplete: false,
|
||||
incomplete_reason: None,
|
||||
});
|
||||
} else {
|
||||
result.extend(buffer.drain(..));
|
||||
result.append(&mut buffer);
|
||||
}
|
||||
buffer.clear();
|
||||
result.push(msg.clone());
|
||||
|
|
@ -342,6 +325,8 @@ fn stage2_collapse(
|
|||
text: format!("[Collapsed Conversation]\n{summary}"),
|
||||
}],
|
||||
usage: None,
|
||||
incomplete: false,
|
||||
incomplete_reason: None,
|
||||
});
|
||||
} else {
|
||||
result.extend(buffer);
|
||||
|
|
@ -476,7 +461,7 @@ fn stage3_cluster(
|
|||
}
|
||||
|
||||
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 cluster_buffers: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
|
||||
|
|
@ -498,6 +483,8 @@ fn stage3_cluster(
|
|||
text: format!("[Clustered {} messages]\n{summary}", buffer.len()),
|
||||
}],
|
||||
usage: None,
|
||||
incomplete: false,
|
||||
incomplete_reason: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -677,7 +664,7 @@ fn truncate_text(text: &str, max_chars: usize) -> String {
|
|||
mod tests {
|
||||
use super::*;
|
||||
use crate::compact::CompactionConfig;
|
||||
use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session};
|
||||
use crate::session::{ContentBlock, ConversationMessage, Session};
|
||||
|
||||
#[test]
|
||||
fn stage1_removes_obsolete_file_reads() {
|
||||
|
|
@ -736,7 +723,7 @@ mod tests {
|
|||
fn stage2_collapses_chatty_messages() {
|
||||
let mut messages = vec![];
|
||||
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 {
|
||||
text: format!("got {i}"),
|
||||
}]));
|
||||
|
|
@ -767,9 +754,9 @@ mod tests {
|
|||
},
|
||||
]));
|
||||
messages.push(ConversationMessage::tool_result(
|
||||
&format!("read_{i}"),
|
||||
format!("read_{i}"),
|
||||
"read_file",
|
||||
&format!(r#"{{"path":"src/{i}.rs","content":"data {i}"}}"#),
|
||||
format!(r#"{{"path":"src/{i}.rs","content":"data {i}"}}"#),
|
||||
false,
|
||||
));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,6 +66,14 @@ pub fn pricing_for_model(model: &str) -> Option<ModelPricing> {
|
|||
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") {
|
||||
return Some(ModelPricing {
|
||||
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,
|
||||
});
|
||||
}
|
||||
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") {
|
||||
return Some(ModelPricing::default_sonnet_tier());
|
||||
}
|
||||
|
|
@ -304,6 +320,8 @@ mod tests {
|
|||
cache_creation_input_tokens: 1,
|
||||
cache_read_input_tokens: 0,
|
||||
}),
|
||||
incomplete: false,
|
||||
incomplete_reason: None,
|
||||
}];
|
||||
|
||||
let tracker = UsageTracker::from_session(&session);
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ fn stale_branch_detection_flows_into_policy_engine() {
|
|||
let stale_context = LaneContext::new(
|
||||
"stale-lane",
|
||||
0,
|
||||
Duration::from_hours(2), // 2 hours stale
|
||||
Duration::from_secs(2 * 60 * 60), // 2 hours stale
|
||||
LaneBlocker::None,
|
||||
ReviewStatus::Pending,
|
||||
DiffScope::Full,
|
||||
|
|
@ -49,7 +49,7 @@ fn fresh_branch_does_not_trigger_stale_policy() {
|
|||
let fresh_context = LaneContext::new(
|
||||
"fresh-lane",
|
||||
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,
|
||||
ReviewStatus::Pending,
|
||||
DiffScope::Full,
|
||||
|
|
@ -210,8 +210,8 @@ fn end_to_end_stale_lane_gets_merge_forward_action() {
|
|||
// when: build context and evaluate policy
|
||||
let context = LaneContext::new(
|
||||
"lane-9411",
|
||||
3, // Workspace green
|
||||
Duration::from_hours(5), // 5 hours stale, definitely over threshold
|
||||
3, // Workspace green
|
||||
Duration::from_secs(5 * 60 * 60), // 5 hours stale, definitely over threshold
|
||||
LaneBlocker::None,
|
||||
ReviewStatus::Approved,
|
||||
DiffScope::Scoped,
|
||||
|
|
@ -259,8 +259,8 @@ fn end_to_end_stale_lane_gets_merge_forward_action() {
|
|||
fn fresh_approved_lane_gets_merge_action() {
|
||||
let context = LaneContext::new(
|
||||
"fresh-approved-lane",
|
||||
3, // Workspace green
|
||||
Duration::from_mins(30), // 30 min — under 1 hour threshold = fresh
|
||||
3, // Workspace green
|
||||
Duration::from_secs(30 * 60), // 30 min — under 1 hour threshold = fresh
|
||||
LaneBlocker::None,
|
||||
ReviewStatus::Approved,
|
||||
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)
|
||||
let recovery_success = matches!(result, RecoveryResult::Recovered { .. });
|
||||
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(
|
||||
"recovered-lane",
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ use tools::{
|
|||
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
|
||||
/// 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 {
|
||||
Self {
|
||||
mode: PermissionMode::WorkspaceWrite,
|
||||
mode: PermissionMode::Manual,
|
||||
source: PermissionModeSource::Default,
|
||||
env_var: None,
|
||||
}
|
||||
|
|
@ -1532,7 +1532,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
|||
"--model" => {
|
||||
let value = args
|
||||
.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
|
||||
if model_flag_raw.is_some() {
|
||||
push_duplicate_flag(&format!(
|
||||
|
|
@ -1574,7 +1574,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
|||
"--permission-mode" => {
|
||||
let value = args
|
||||
.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
|
||||
if permission_mode_override.is_some() {
|
||||
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.
|
||||
let first = rest[0].as_str();
|
||||
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"
|
||||
));
|
||||
.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
|
||||
// call or session is created.
|
||||
"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(),
|
||||
),
|
||||
// #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> {
|
||||
let joined = args.join(" ");
|
||||
let trimmed = joined.trim();
|
||||
|
|
@ -2901,9 +2932,10 @@ fn levenshtein_distance(left: &str, right: &str) -> usize {
|
|||
|
||||
fn resolve_model_alias(model: &str) -> &str {
|
||||
match model {
|
||||
"opus" => "anthropic/claude-opus-4-7",
|
||||
"sonnet" => "anthropic/claude-sonnet-4-6",
|
||||
"haiku" => "anthropic/claude-haiku-4-5-20251213",
|
||||
"opus" => "anthropic/claude-opus-4-8",
|
||||
"sonnet" => "anthropic/claude-sonnet-5",
|
||||
"haiku" => "anthropic/claude-haiku-4-5-20251001",
|
||||
"fable" => "anthropic/claude-fable-5",
|
||||
_ => model,
|
||||
}
|
||||
}
|
||||
|
|
@ -2933,12 +2965,12 @@ fn validate_model_syntax(model: &str) -> Result<(), String> {
|
|||
return Ok(());
|
||||
}
|
||||
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)
|
||||
if trimmed.contains(' ') {
|
||||
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
|
||||
));
|
||||
}
|
||||
|
|
@ -2953,7 +2985,7 @@ fn validate_model_syntax(model: &str) -> Result<(), String> {
|
|||
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
|
||||
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
|
||||
);
|
||||
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)
|
||||
.ok_or_else(|| {
|
||||
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)
|
||||
|
|
@ -3055,6 +3087,7 @@ fn parse_permission_mode_arg(value: &str) -> Result<PermissionMode, String> {
|
|||
fn permission_mode_from_label(mode: &str) -> PermissionMode {
|
||||
match mode {
|
||||
"read-only" => PermissionMode::ReadOnly,
|
||||
"manual" => PermissionMode::Manual,
|
||||
"workspace-write" => PermissionMode::WorkspaceWrite,
|
||||
"danger-full-access" => PermissionMode::DangerFullAccess,
|
||||
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 {
|
||||
match mode {
|
||||
ResolvedPermissionMode::ReadOnly => PermissionMode::ReadOnly,
|
||||
ResolvedPermissionMode::Manual => PermissionMode::Manual,
|
||||
ResolvedPermissionMode::WorkspaceWrite => PermissionMode::WorkspaceWrite,
|
||||
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)
|
||||
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>"
|
||||
));
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
if value.len() > 20 {
|
||||
return Err(format!(
|
||||
|
|
@ -3452,11 +3487,7 @@ impl DiagnosticCheck {
|
|||
|
||||
fn json_value(&self) -> Value {
|
||||
// Derive a stable snake_case id from the check name for machine-readable keying (#704).
|
||||
let id = self
|
||||
.name
|
||||
.to_ascii_lowercase()
|
||||
.replace(' ', "_")
|
||||
.replace('-', "_");
|
||||
let id = self.name.to_ascii_lowercase().replace([' ', '-'], "_");
|
||||
let mut value = Map::from_iter([
|
||||
("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"
|
||||
} else if matches!(permission_mode.mode, PermissionMode::DangerFullAccess) {
|
||||
"danger-full-access was explicitly selected"
|
||||
} else if matches!(permission_mode.mode, PermissionMode::WorkspaceWrite) && !explicit {
|
||||
"default permission mode is workspace-write"
|
||||
} else if matches!(permission_mode.mode, PermissionMode::Manual) && !explicit {
|
||||
"default permission mode is manual"
|
||||
} else {
|
||||
"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 tools_satisfied = specs
|
||||
.iter()
|
||||
.filter(|spec| permission_mode.mode >= spec.required_permission)
|
||||
.filter(|spec| permission_mode.mode.grants(spec.required_permission))
|
||||
.map(|spec| spec.name)
|
||||
.collect::<Vec<_>>();
|
||||
let tools_gated = specs
|
||||
.iter()
|
||||
.filter(|spec| permission_mode.mode < spec.required_permission)
|
||||
.filter(|spec| !permission_mode.mode.grants(spec.required_permission))
|
||||
.map(|spec| spec.name)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
|
|
@ -4251,9 +4282,9 @@ fn check_permission_health(permission_mode: PermissionModeProvenance) -> Diagnos
|
|||
format!("Tools gated {}", tools_gated.join(", ")),
|
||||
])
|
||||
.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 {
|
||||
"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([
|
||||
("mode".to_string(), json!(mode)),
|
||||
|
|
@ -6730,16 +6761,15 @@ fn run_resume_command(
|
|||
}
|
||||
SlashCommand::Plugins { action, target } => {
|
||||
// Only list is supported in resume mode (no runtime to reload)
|
||||
match action.as_deref() {
|
||||
Some(action @ ("install" | "uninstall" | "enable" | "disable" | "update")) => {
|
||||
// #777: use interactive_only: prefix + \n hint so #776's classify/split
|
||||
// emits error_kind:interactive_only + non-null hint instead of unknown+null.
|
||||
// Orchestrators can now detect this and switch to a live REPL instead of retrying.
|
||||
return Err(format!(
|
||||
"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());
|
||||
}
|
||||
_ => {}
|
||||
if let 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.
|
||||
// Orchestrators can now detect this and switch to a live REPL instead of retrying.
|
||||
return Err(format!(
|
||||
"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 payload = plugins_command_payload_for(
|
||||
|
|
@ -7075,6 +7105,20 @@ fn run_repl(
|
|||
cli.persist_session()?;
|
||||
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) {
|
||||
Ok(Some(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
|
||||
// matches a known skill name, invoke it as `/skills <input>`
|
||||
// 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) {
|
||||
editor.push_history(input);
|
||||
cli.record_prompt_history(&trimmed);
|
||||
|
|
@ -7852,8 +7895,12 @@ impl LiveCli {
|
|||
let max_compact_rounds = 4;
|
||||
let preserve_schedule = [4, 2, 1, 0];
|
||||
|
||||
for round in 0..max_compact_rounds {
|
||||
let preserve = preserve_schedule[round];
|
||||
for (round, preserve) in preserve_schedule
|
||||
.iter()
|
||||
.copied()
|
||||
.enumerate()
|
||||
.take(max_compact_rounds)
|
||||
{
|
||||
println!(
|
||||
" Auto-compacting session (round {}/{}, preserving {} recent messages)...",
|
||||
round + 1,
|
||||
|
|
@ -8203,6 +8250,11 @@ impl LiveCli {
|
|||
println!("{}", format_cost_report(usage));
|
||||
false
|
||||
}
|
||||
SlashCommand::Review { scope } => {
|
||||
let scope = scope.unwrap_or_else(|| "medium".to_string());
|
||||
self.run_turn(&format!("$code-review {scope}"))?;
|
||||
false
|
||||
}
|
||||
SlashCommand::Login
|
||||
| SlashCommand::Logout
|
||||
| SlashCommand::Vim
|
||||
|
|
@ -8224,7 +8276,6 @@ impl LiveCli {
|
|||
| SlashCommand::Keybindings
|
||||
| SlashCommand::PrivacySettings
|
||||
| SlashCommand::Plan { .. }
|
||||
| SlashCommand::Review { .. }
|
||||
| SlashCommand::Tasks { .. }
|
||||
| SlashCommand::Theme { .. }
|
||||
| SlashCommand::Voice { .. }
|
||||
|
|
@ -8406,7 +8457,7 @@ impl LiveCli {
|
|||
|
||||
let normalized = normalize_permission_mode(&mode).ok_or_else(|| {
|
||||
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()?;
|
||||
// #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.
|
||||
if action.as_deref() == Some("list") {
|
||||
if let Some(filter) = target.as_deref() {
|
||||
if action == Some("list") {
|
||||
if let Some(filter) = target {
|
||||
if filter.starts_with('-') {
|
||||
if matches!(output_format, CliOutputFormat::Json) {
|
||||
// ROADMAP #817: this is a handled local inventory parse error.
|
||||
|
|
@ -9577,6 +9628,7 @@ fn print_status_snapshot(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn status_json_value(
|
||||
model: Option<&str>,
|
||||
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;
|
||||
// filesystem_active:true means partial containment is working)
|
||||
// error = enabled but unsupported AND no filesystem sandbox either (nothing active)
|
||||
let top_status = if !status.enabled {
|
||||
"ok"
|
||||
} else if status.active {
|
||||
let top_status = if !status.enabled || status.active {
|
||||
"ok"
|
||||
} else if status.supported {
|
||||
"warn"
|
||||
|
|
@ -10325,7 +10375,7 @@ fn print_models(
|
|||
CliOutputFormat::Text => {
|
||||
println!("Models");
|
||||
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() {
|
||||
println!(
|
||||
" Config model {raw}{}",
|
||||
|
|
@ -10351,7 +10401,8 @@ fn print_models(
|
|||
"aliases": [
|
||||
{"name": "opus", "model": resolve_model_alias("opus")},
|
||||
{"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,
|
||||
"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
|
||||
fn extract_help_metadata(
|
||||
topic: LocalHelpTopic,
|
||||
) -> (
|
||||
type HelpMetadata = (
|
||||
Option<String>, // usage
|
||||
Option<String>, // purpose
|
||||
Option<String>, // output description
|
||||
|
|
@ -10461,7 +10509,10 @@ fn extract_help_metadata(
|
|||
Option<Vec<String>>, // aliases
|
||||
bool, // local_only
|
||||
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 mut usage = 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> {
|
||||
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"),
|
||||
"dontAsk" | "bypassPermissions" | "dangerFullAccess" | "danger-full-access" => {
|
||||
Some("danger-full-access")
|
||||
|
|
@ -12710,9 +12762,27 @@ impl AnthropicRuntimeClient {
|
|||
loop {
|
||||
let next = if apply_stall_timeout && !received_any_event {
|
||||
match tokio::time::timeout(POST_TOOL_STALL_TIMEOUT, stream.next_event()).await {
|
||||
Ok(inner) => inner.map_err(|error| {
|
||||
RuntimeError::new(format_user_visible_api_error(&self.session_id, &error))
|
||||
})?,
|
||||
Ok(inner) => match inner {
|
||||
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) => {
|
||||
return Err(RuntimeError::new(
|
||||
"post-tool stall: model did not respond within timeout",
|
||||
|
|
@ -12720,9 +12790,27 @@ impl AnthropicRuntimeClient {
|
|||
}
|
||||
}
|
||||
} else {
|
||||
stream.next_event().await.map_err(|error| {
|
||||
RuntimeError::new(format_user_visible_api_error(&self.session_id, &error))
|
||||
})?
|
||||
match stream.next_event().await {
|
||||
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 {
|
||||
|
|
@ -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,
|
||||
/// meaning the model is expected to continue after tool execution.
|
||||
fn request_ends_with_tool_result(request: &ApiRequest) -> bool {
|
||||
|
|
@ -14008,39 +14153,37 @@ fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
|
|||
let content = message
|
||||
.blocks
|
||||
.iter()
|
||||
.filter_map(|block| match block {
|
||||
ContentBlock::Text { text } => {
|
||||
Some(InputContentBlock::Text { text: text.clone() })
|
||||
}
|
||||
.map(|block| match block {
|
||||
ContentBlock::Text { text } => InputContentBlock::Text { text: text.clone() },
|
||||
ContentBlock::Thinking {
|
||||
thinking,
|
||||
signature,
|
||||
} => {
|
||||
// 保留 Thinking 块:OpenAI 兼容协议会把它转成 reasoning_content 字段
|
||||
// 回传给 DeepSeek V4(避免 400 "reasoning_content must be passed back" 错误)
|
||||
Some(InputContentBlock::Thinking {
|
||||
InputContentBlock::Thinking {
|
||||
thinking: thinking.clone(),
|
||||
signature: signature.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
ContentBlock::ToolUse { id, name, input } => Some(InputContentBlock::ToolUse {
|
||||
ContentBlock::ToolUse { id, name, input } => InputContentBlock::ToolUse {
|
||||
id: id.clone(),
|
||||
name: name.clone(),
|
||||
input: serde_json::from_str(input)
|
||||
.unwrap_or_else(|_| serde_json::json!({ "raw": input })),
|
||||
}),
|
||||
},
|
||||
ContentBlock::ToolResult {
|
||||
tool_use_id,
|
||||
output,
|
||||
is_error,
|
||||
..
|
||||
} => Some(InputContentBlock::ToolResult {
|
||||
} => InputContentBlock::ToolResult {
|
||||
tool_use_id: tool_use_id.clone(),
|
||||
content: vec![ToolResultContentBlock::Text {
|
||||
text: output.clone(),
|
||||
}],
|
||||
is_error: *is_error,
|
||||
}),
|
||||
},
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
(!content.is_empty()).then(|| InputMessage {
|
||||
|
|
@ -14386,7 +14529,7 @@ mod tests {
|
|||
#[test]
|
||||
fn context_window_preflight_errors_render_recovery_steps() {
|
||||
let error = ApiError::ContextWindowExceeded {
|
||||
model: "anthropic/claude-sonnet-4-6".to_string(),
|
||||
model: "anthropic/claude-sonnet-5".to_string(),
|
||||
estimated_input_tokens: 182_000,
|
||||
requested_output_tokens: 64_000,
|
||||
estimated_total_tokens: 246_000,
|
||||
|
|
@ -14401,7 +14544,7 @@ mod tests {
|
|||
"{rendered}"
|
||||
);
|
||||
assert!(
|
||||
rendered.contains("Model anthropic/claude-sonnet-4-6"),
|
||||
rendered.contains("Model anthropic/claude-sonnet-5"),
|
||||
"{rendered}"
|
||||
);
|
||||
assert!(
|
||||
|
|
@ -14637,7 +14780,7 @@ mod tests {
|
|||
CliAction::Repl {
|
||||
model: DEFAULT_MODEL.to_string(),
|
||||
allowed_tools: None,
|
||||
permission_mode: PermissionMode::WorkspaceWrite,
|
||||
permission_mode: PermissionMode::Manual,
|
||||
base_commit: None,
|
||||
reasoning_effort: None,
|
||||
allow_broad_cwd: false,
|
||||
|
|
@ -14770,7 +14913,7 @@ mod tests {
|
|||
model: DEFAULT_MODEL.to_string(),
|
||||
output_format: CliOutputFormat::Text,
|
||||
allowed_tools: None,
|
||||
permission_mode: PermissionMode::WorkspaceWrite,
|
||||
permission_mode: PermissionMode::Manual,
|
||||
compact: false,
|
||||
base_commit: None,
|
||||
reasoning_effort: None,
|
||||
|
|
@ -14858,10 +15001,10 @@ mod tests {
|
|||
parse_args(&args).expect("args should parse"),
|
||||
CliAction::Prompt {
|
||||
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,
|
||||
allowed_tools: None,
|
||||
permission_mode: PermissionMode::WorkspaceWrite,
|
||||
permission_mode: PermissionMode::Manual,
|
||||
compact: false,
|
||||
base_commit: None,
|
||||
reasoning_effort: None,
|
||||
|
|
@ -14883,7 +15026,7 @@ mod tests {
|
|||
model: DEFAULT_MODEL.to_string(),
|
||||
output_format: CliOutputFormat::Text,
|
||||
allowed_tools: None,
|
||||
permission_mode: PermissionMode::WorkspaceWrite,
|
||||
permission_mode: PermissionMode::Manual,
|
||||
compact: false,
|
||||
base_commit: None,
|
||||
reasoning_effort: None,
|
||||
|
|
@ -14899,7 +15042,7 @@ mod tests {
|
|||
model: DEFAULT_MODEL.to_string(),
|
||||
output_format: CliOutputFormat::Text,
|
||||
allowed_tools: None,
|
||||
permission_mode: PermissionMode::WorkspaceWrite,
|
||||
permission_mode: PermissionMode::Manual,
|
||||
compact: false,
|
||||
base_commit: None,
|
||||
reasoning_effort: None,
|
||||
|
|
@ -14915,7 +15058,7 @@ mod tests {
|
|||
model: DEFAULT_MODEL.to_string(),
|
||||
output_format: CliOutputFormat::Text,
|
||||
allowed_tools: None,
|
||||
permission_mode: PermissionMode::WorkspaceWrite,
|
||||
permission_mode: PermissionMode::Manual,
|
||||
compact: false,
|
||||
base_commit: None,
|
||||
reasoning_effort: None,
|
||||
|
|
@ -14953,7 +15096,7 @@ mod tests {
|
|||
model: DEFAULT_MODEL.to_string(),
|
||||
output_format: CliOutputFormat::Text,
|
||||
allowed_tools: None,
|
||||
permission_mode: PermissionMode::WorkspaceWrite,
|
||||
permission_mode: PermissionMode::Manual,
|
||||
compact: true,
|
||||
base_commit: None,
|
||||
reasoning_effort: None,
|
||||
|
|
@ -14968,7 +15111,7 @@ mod tests {
|
|||
model: DEFAULT_MODEL.to_string(),
|
||||
output_format: CliOutputFormat::Text,
|
||||
allowed_tools: None,
|
||||
permission_mode: PermissionMode::WorkspaceWrite,
|
||||
permission_mode: PermissionMode::Manual,
|
||||
compact: true,
|
||||
base_commit: None,
|
||||
reasoning_effort: None,
|
||||
|
|
@ -15008,10 +15151,10 @@ mod tests {
|
|||
parse_args(&args).expect("args should parse"),
|
||||
CliAction::Prompt {
|
||||
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,
|
||||
allowed_tools: None,
|
||||
permission_mode: PermissionMode::WorkspaceWrite,
|
||||
permission_mode: PermissionMode::Manual,
|
||||
compact: false,
|
||||
base_commit: None,
|
||||
reasoning_effort: None,
|
||||
|
|
@ -15022,19 +15165,19 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn resolves_known_model_aliases() {
|
||||
assert_eq!(resolve_model_alias("opus"), "anthropic/claude-opus-4-7");
|
||||
assert_eq!(resolve_model_alias("sonnet"), "anthropic/claude-sonnet-4-6");
|
||||
assert_eq!(resolve_model_alias("opus"), "anthropic/claude-opus-4-8");
|
||||
assert_eq!(resolve_model_alias("sonnet"), "anthropic/claude-sonnet-5");
|
||||
assert_eq!(
|
||||
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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_model_alias_uses_anthropic_routing_prefix() {
|
||||
assert_eq!(DEFAULT_MODEL, "anthropic/claude-opus-4-7");
|
||||
assert_eq!(resolve_model_alias("opus"), "anthropic/claude-opus-4-7");
|
||||
assert_eq!(DEFAULT_MODEL, "anthropic/claude-sonnet-5");
|
||||
assert_eq!(resolve_model_alias("opus"), "anthropic/claude-opus-4-8");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -15048,7 +15191,7 @@ mod tests {
|
|||
std::fs::create_dir_all(&config_home).expect("config home should exist");
|
||||
std::fs::write(
|
||||
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");
|
||||
|
||||
|
|
@ -15069,11 +15212,11 @@ mod tests {
|
|||
std::fs::remove_dir_all(root).expect("temp config root should clean up");
|
||||
|
||||
// then
|
||||
assert_eq!(direct, "anthropic/claude-haiku-4-5-20251213");
|
||||
assert_eq!(chained, "anthropic/claude-opus-4-7");
|
||||
assert_eq!(direct, "anthropic/claude-haiku-4-5-20251001");
|
||||
assert_eq!(chained, "anthropic/claude-opus-4-8");
|
||||
assert_eq!(cross_provider, "grok-3-mini");
|
||||
assert_eq!(unknown, "unknown-model");
|
||||
assert_eq!(builtin, "anthropic/claude-haiku-4-5-20251213");
|
||||
assert_eq!(builtin, "anthropic/claude-haiku-4-5-20251001");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -15178,7 +15321,7 @@ mod tests {
|
|||
.map(str::to_string)
|
||||
.collect()
|
||||
),
|
||||
permission_mode: PermissionMode::WorkspaceWrite,
|
||||
permission_mode: PermissionMode::Manual,
|
||||
base_commit: None,
|
||||
reasoning_effort: None,
|
||||
allow_broad_cwd: false,
|
||||
|
|
@ -15541,7 +15684,7 @@ mod tests {
|
|||
..
|
||||
} => {
|
||||
assert_eq!(
|
||||
model, "anthropic/claude-sonnet-4-6",
|
||||
model, "anthropic/claude-sonnet-5",
|
||||
"sonnet alias should resolve"
|
||||
);
|
||||
assert_eq!(
|
||||
|
|
@ -15554,7 +15697,7 @@ mod tests {
|
|||
}
|
||||
// --model= form should also capture raw.
|
||||
match parse_args(&[
|
||||
"--model=anthropic/claude-opus-4-6".to_string(),
|
||||
"--model=anthropic/claude-opus-4-8".to_string(),
|
||||
"status".to_string(),
|
||||
])
|
||||
.expect("--model=... status should parse")
|
||||
|
|
@ -15564,16 +15707,16 @@ mod tests {
|
|||
model_flag_raw,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(model, "anthropic/claude-opus-4-6");
|
||||
assert_eq!(model, "anthropic/claude-opus-4-8");
|
||||
assert_eq!(
|
||||
model_flag_raw.as_deref(),
|
||||
Some("anthropic/claude-opus-4-6"),
|
||||
Some("anthropic/claude-opus-4-8"),
|
||||
"--model= form should also preserve raw input"
|
||||
);
|
||||
}
|
||||
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")
|
||||
{
|
||||
CliAction::Status {
|
||||
|
|
@ -15581,8 +15724,8 @@ mod tests {
|
|||
model_flag_raw,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(model, "claude-opus-4-6");
|
||||
assert_eq!(model_flag_raw.as_deref(), Some("claude-opus-4-6"));
|
||||
assert_eq!(model, "claude-opus-4-8");
|
||||
assert_eq!(model_flag_raw.as_deref(), Some("claude-opus-4-8"));
|
||||
}
|
||||
other => panic!("expected CliAction::Status, got: {other:?}"),
|
||||
}
|
||||
|
|
@ -16317,7 +16460,7 @@ mod tests {
|
|||
"missing_flag_value"
|
||||
);
|
||||
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"
|
||||
);
|
||||
assert_eq!(
|
||||
|
|
@ -16650,6 +16793,8 @@ mod tests {
|
|||
is_error: false,
|
||||
}],
|
||||
usage: None,
|
||||
incomplete: false,
|
||||
incomplete_reason: None,
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -16690,6 +16835,8 @@ mod tests {
|
|||
is_error: true,
|
||||
}],
|
||||
usage: None,
|
||||
incomplete: false,
|
||||
incomplete_reason: None,
|
||||
}];
|
||||
|
||||
// when
|
||||
|
|
@ -16785,7 +16932,7 @@ mod tests {
|
|||
.expect("prompt shorthand should still work"),
|
||||
CliAction::Prompt {
|
||||
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,
|
||||
allowed_tools: None,
|
||||
permission_mode: crate::default_permission_mode(),
|
||||
|
|
@ -16972,7 +17119,7 @@ mod tests {
|
|||
for action in ["remove", "uninstall", "delete"] {
|
||||
assert_eq!(
|
||||
parse_args(&["skills".to_string(), action.to_string()])
|
||||
.expect(&format!("skills {action} should parse")),
|
||||
.unwrap_or_else(|_| panic!("skills {action} should parse")),
|
||||
CliAction::Skills {
|
||||
args: Some(action.to_string()),
|
||||
output_format: CliOutputFormat::Text,
|
||||
|
|
@ -17040,7 +17187,7 @@ mod tests {
|
|||
model: DEFAULT_MODEL.to_string(),
|
||||
output_format: CliOutputFormat::Text,
|
||||
allowed_tools: None,
|
||||
permission_mode: PermissionMode::WorkspaceWrite,
|
||||
permission_mode: PermissionMode::Manual,
|
||||
compact: false,
|
||||
base_commit: None,
|
||||
reasoning_effort: None,
|
||||
|
|
@ -17069,7 +17216,7 @@ mod tests {
|
|||
model: DEFAULT_MODEL.to_string(),
|
||||
output_format: CliOutputFormat::Text,
|
||||
allowed_tools: None,
|
||||
permission_mode: PermissionMode::WorkspaceWrite,
|
||||
permission_mode: PermissionMode::Manual,
|
||||
compact: false,
|
||||
base_commit: None,
|
||||
reasoning_effort: None,
|
||||
|
|
@ -17291,12 +17438,12 @@ mod tests {
|
|||
assert!(help.contains("/status"));
|
||||
assert!(help.contains("/sandbox"));
|
||||
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("/cost"));
|
||||
assert!(help.contains("/resume <session-path>"));
|
||||
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("/init"));
|
||||
assert!(help.contains("/diff"));
|
||||
|
|
@ -17327,7 +17474,7 @@ mod tests {
|
|||
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(&"/session list".to_string()));
|
||||
assert!(completions.contains(&"/session switch session-current".to_string()));
|
||||
|
|
@ -17346,7 +17493,7 @@ mod tests {
|
|||
|
||||
let banner = with_current_dir(&root, || {
|
||||
LiveCli::new(
|
||||
"anthropic/claude-sonnet-4-6".to_string(),
|
||||
"anthropic/claude-sonnet-5".to_string(),
|
||||
true,
|
||||
None,
|
||||
PermissionMode::DangerFullAccess,
|
||||
|
|
@ -17364,11 +17511,11 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
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);
|
||||
|
||||
assert_eq!(line, "Connected: anthropic/claude-sonnet-4-6 via anthropic");
|
||||
assert_eq!(line, "Connected: anthropic/claude-sonnet-5 via anthropic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -17382,11 +17529,11 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
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");
|
||||
|
||||
assert_eq!(resolved, "anthropic/claude-sonnet-4-6");
|
||||
assert_eq!(resolved, "anthropic/claude-sonnet-5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -17403,7 +17550,7 @@ mod tests {
|
|||
let resolved = with_current_dir(&root, || resolve_repl_model(DEFAULT_MODEL.to_string()))
|
||||
.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("CLAW_CONFIG_HOME");
|
||||
|
|
@ -18625,6 +18772,8 @@ UU conflicted.rs",
|
|||
is_error: false,
|
||||
}],
|
||||
usage: None,
|
||||
incomplete: false,
|
||||
incomplete_reason: None,
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -19028,7 +19177,7 @@ UU conflicted.rs",
|
|||
MessageResponse {
|
||||
id: "msg-1".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(),
|
||||
content: vec![OutputContentBlock::ToolUse {
|
||||
id: "tool-1".to_string(),
|
||||
|
|
@ -19063,7 +19212,7 @@ UU conflicted.rs",
|
|||
MessageResponse {
|
||||
id: "msg-2".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(),
|
||||
content: vec![OutputContentBlock::ToolUse {
|
||||
id: "tool-2".to_string(),
|
||||
|
|
@ -19098,7 +19247,7 @@ UU conflicted.rs",
|
|||
MessageResponse {
|
||||
id: "msg-3".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(),
|
||||
content: vec![
|
||||
OutputContentBlock::Thinking {
|
||||
|
|
@ -19750,15 +19899,15 @@ mod alias_resolution_tests {
|
|||
// Built-in aliases should resolve to their full IDs
|
||||
assert_eq!(
|
||||
resolve_model_alias_with_config("opus"),
|
||||
"anthropic/claude-opus-4-7"
|
||||
"anthropic/claude-opus-4-8"
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_model_alias_with_config("sonnet"),
|
||||
"anthropic/claude-sonnet-4-6"
|
||||
"anthropic/claude-sonnet-5"
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_model_alias_with_config("haiku"),
|
||||
"anthropic/claude-haiku-4-5-20251213"
|
||||
"anthropic/claude-haiku-4-5-20251001"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,8 +2,6 @@ use std::io::{self, IsTerminal, Write};
|
|||
|
||||
use runtime::{save_user_provider_settings, ConfigLoader, RuntimeProviderConfig};
|
||||
|
||||
use serde_json;
|
||||
|
||||
const PROVIDERS: &[(&str, &str, &str)] = &[
|
||||
("1", "Anthropic", "anthropic"),
|
||||
("2", "xAI / Grok", "xai"),
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ fn status_command_applies_model_and_permission_mode_flags() {
|
|||
assert_success(&output);
|
||||
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
|
||||
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"));
|
||||
|
||||
fs::remove_dir_all(temp_dir).expect("cleanup temp dir");
|
||||
|
|
|
|||
|
|
@ -240,7 +240,7 @@ stderr:
|
|||
"Mock streaming says hello from the parity harness."
|
||||
);
|
||||
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());
|
||||
|
||||
fs::remove_dir_all(&workspace).expect("workspace cleanup should succeed");
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
#![allow(unused_variables)]
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output};
|
||||
|
|
@ -512,7 +514,7 @@ fn status_json_surfaces_permission_mode_override_for_security_audit() {
|
|||
}
|
||||
|
||||
#[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 config_home = root.join("config-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);
|
||||
assert_eq!(status["permission_mode"], "workspace-write");
|
||||
assert_eq!(status["permission_mode"], "manual");
|
||||
assert_eq!(status["permission_mode_source"], "default");
|
||||
|
||||
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")
|
||||
.expect("permissions check");
|
||||
assert_eq!(permissions["status"], "ok");
|
||||
assert_eq!(permissions["mode"], "workspace-write");
|
||||
assert_eq!(permissions["mode"], "manual");
|
||||
assert_eq!(permissions["source"], "default");
|
||||
assert_eq!(
|
||||
permissions["message"],
|
||||
"default permission mode is workspace-write"
|
||||
);
|
||||
assert_eq!(permissions["message"], "default permission mode is manual");
|
||||
}
|
||||
|
||||
#[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);
|
||||
|
||||
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_raw"], "opus");
|
||||
assert_eq!(
|
||||
parsed["model_alias_resolved_to"],
|
||||
"anthropic/claude-opus-4-7"
|
||||
"anthropic/claude-opus-4-8"
|
||||
);
|
||||
assert_eq!(parsed["model_env_var"], "CLAW_MODEL");
|
||||
}
|
||||
|
|
@ -1007,13 +1006,13 @@ fn inventory_commands_emit_structured_json_when_requested() {
|
|||
assert!(
|
||||
!plugins
|
||||
.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"
|
||||
);
|
||||
assert!(
|
||||
!plugins
|
||||
.as_object()
|
||||
.map_or(false, |o| o.contains_key("target")),
|
||||
.is_some_and(|o| o.contains_key("target")),
|
||||
"plugins list should not include target"
|
||||
);
|
||||
// #703: structured summary replaces prose message
|
||||
|
|
@ -1706,13 +1705,13 @@ fn resumed_inventory_commands_emit_structured_json_when_requested() {
|
|||
assert!(
|
||||
!plugins
|
||||
.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"
|
||||
);
|
||||
assert!(
|
||||
!plugins
|
||||
.as_object()
|
||||
.map_or(false, |o| o.contains_key("target")),
|
||||
.is_some_and(|o| o.contains_key("target")),
|
||||
"plugins list should not include target"
|
||||
);
|
||||
assert!(
|
||||
|
|
@ -2252,7 +2251,7 @@ fn config_json_attributes_precedence_and_shadowed_keys_425() {
|
|||
fs::create_dir_all(&home).expect("home should exist");
|
||||
fs::write(
|
||||
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");
|
||||
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}"
|
||||
);
|
||||
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)"
|
||||
);
|
||||
}
|
||||
|
|
@ -2983,9 +2982,9 @@ fn flag_value_errors_have_error_kind_and_hint_756() {
|
|||
"invalid --reasoning-effort must be invalid_flag_value (#756): {parsed}"
|
||||
);
|
||||
assert!(
|
||||
parsed["hint"].as_str().map_or(false, |h| h.contains("low")
|
||||
|| h.contains("medium")
|
||||
|| h.contains("high")),
|
||||
parsed["hint"]
|
||||
.as_str()
|
||||
.is_some_and(|h| h.contains("low") || h.contains("medium") || h.contains("high")),
|
||||
"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}"
|
||||
);
|
||||
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}"
|
||||
);
|
||||
}
|
||||
|
|
@ -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}"
|
||||
);
|
||||
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)"
|
||||
);
|
||||
}
|
||||
|
|
@ -3397,7 +3396,7 @@ fn config_unsupported_section_json_hint_741() {
|
|||
assert!(
|
||||
parsed["supported_sections"]
|
||||
.as_array()
|
||||
.map_or(false, |a| !a.is_empty()),
|
||||
.is_some_and(|a| !a.is_empty()),
|
||||
"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_eq!(list["kind"], "agents");
|
||||
assert_eq!(list["action"], "list");
|
||||
let canonical_agent_path = fs::canonicalize(&agent_path).expect("canonical listed agent path");
|
||||
assert!(list["agents"]
|
||||
.as_array()
|
||||
.expect("agents array")
|
||||
.iter()
|
||||
.any(|agent| {
|
||||
agent["name"] == "my-agent"
|
||||
&& PathBuf::from(agent["path"].as_str().expect("listed agent path"))
|
||||
== fs::canonicalize(&agent_path).expect("canonical listed agent path")
|
||||
&& Path::new(agent["path"].as_str().expect("listed agent path"))
|
||||
== canonical_agent_path.as_path()
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
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"));
|
||||
}
|
||||
|
||||
|
|
@ -335,7 +335,7 @@ fn resumed_status_command_emits_structured_json_when_requested() {
|
|||
assert_eq!(parsed["kind"], "status");
|
||||
// model is null in resume mode (not known without --model flag)
|
||||
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!(parsed["usage"]["turns"].is_number());
|
||||
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 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
|
||||
.push_user_text("model persistence fixture")
|
||||
.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");
|
||||
assert_eq!(parsed["kind"], "status");
|
||||
assert_eq!(
|
||||
parsed["model"], "anthropic/claude-sonnet-4-6",
|
||||
parsed["model"], "anthropic/claude-sonnet-5",
|
||||
"model should round-trip through session metadata"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -107,6 +107,9 @@ mod tests {
|
|||
description: "Test".to_string(),
|
||||
subagent_type: None,
|
||||
model: None,
|
||||
effort: None,
|
||||
run_in_background: true,
|
||||
isolation: None,
|
||||
status: "Finished".to_string(),
|
||||
output_file: "/tmp/test.output".to_string(),
|
||||
manifest_file: "/tmp/test.manifest".to_string(),
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue