mirror of
https://github.com/instructkr/claude-code.git
synced 2026-06-09 13:46:45 +00:00
Compare commits
23 Commits
5dfb1d7c2b
...
feat/provi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5851f2dee8 | ||
|
|
8c6dfe57e6 | ||
|
|
eed57212bb | ||
|
|
3ac97e635e | ||
|
|
006f7d7ee6 | ||
|
|
82baaf3f22 | ||
|
|
c7b3296ef6 | ||
|
|
000aed4188 | ||
|
|
523ce7474a | ||
|
|
b513d6e462 | ||
|
|
c667d47c70 | ||
|
|
7546c1903d | ||
|
|
0530c509a3 | ||
|
|
eff0765167 | ||
|
|
aee5263aef | ||
|
|
9461522af5 | ||
|
|
c08f060ca1 | ||
|
|
cae11413dd | ||
|
|
60410b6c92 | ||
|
|
aa37dc6936 | ||
|
|
6ddfa78b7c | ||
|
|
bcdc52d72c | ||
|
|
dd97c49e6b |
57
ROADMAP.md
57
ROADMAP.md
@@ -308,7 +308,7 @@ Priority order: P0 = blocks CI/green state, P1 = blocks integration wiring, P2 =
|
|||||||
19. **Subcommand help falls through into runtime/API path** — **done**: `claw doctor --help`, `claw status --help`, `claw sandbox --help`, and nested `mcp`/`skills` help are now intercepted locally without runtime/provider startup, with regression tests covering the direct CLI paths.
|
19. **Subcommand help falls through into runtime/API path** — **done**: `claw doctor --help`, `claw status --help`, `claw sandbox --help`, and nested `mcp`/`skills` help are now intercepted locally without runtime/provider startup, with regression tests covering the direct CLI paths.
|
||||||
20. **Session state classification gap (working vs blocked vs finished vs truly stale)** — **done**: agent manifests now derive machine states such as `working`, `blocked_background_job`, `blocked_merge_conflict`, `degraded_mcp`, `interrupted_transport`, `finished_pending_report`, and `finished_cleanable`, and terminal-state persistence records commit provenance plus derived state so downstream monitoring can distinguish quiet progress from truly idle sessions.
|
20. **Session state classification gap (working vs blocked vs finished vs truly stale)** — **done**: agent manifests now derive machine states such as `working`, `blocked_background_job`, `blocked_merge_conflict`, `degraded_mcp`, `interrupted_transport`, `finished_pending_report`, and `finished_cleanable`, and terminal-state persistence records commit provenance plus derived state so downstream monitoring can distinguish quiet progress from truly idle sessions.
|
||||||
21. **Resumed `/status` JSON parity gap** — dogfooding shows fresh `claw status --output-format json` now emits structured JSON, but resumed slash-command status still leaks through a text-shaped path in at least one dispatch path. Local CI-equivalent repro fails `rust/crates/rusty-claude-cli/tests/resume_slash_commands.rs::resumed_status_command_emits_structured_json_when_requested` with `expected value at line 1 column 1`, so resumed automation can receive text where JSON was explicitly requested. **Action:** unify fresh vs resumed `/status` rendering through one output-format contract and add regression coverage so resumed JSON output is guaranteed valid.
|
21. **Resumed `/status` JSON parity gap** — dogfooding shows fresh `claw status --output-format json` now emits structured JSON, but resumed slash-command status still leaks through a text-shaped path in at least one dispatch path. Local CI-equivalent repro fails `rust/crates/rusty-claude-cli/tests/resume_slash_commands.rs::resumed_status_command_emits_structured_json_when_requested` with `expected value at line 1 column 1`, so resumed automation can receive text where JSON was explicitly requested. **Action:** unify fresh vs resumed `/status` rendering through one output-format contract and add regression coverage so resumed JSON output is guaranteed valid.
|
||||||
22. **Opaque failure surface for session/runtime crashes** — repeated dogfood-facing failures can currently collapse to generic wrappers like `Something went wrong while processing your request. Please try again, or use /new to start a fresh session.` without exposing whether the fault was provider auth, session corruption, slash-command dispatch, render failure, or transport/runtime panic. This blocks fast self-recovery and turns actionable clawability bugs into blind retries. **Action:** preserve a short user-safe failure class (`provider_auth`, `session_load`, `command_dispatch`, `render`, `runtime_panic`, etc.), attach a local trace/session id, and ensure operators can jump from the chat-visible error to the exact failure log quickly.
|
22. **Opaque failure surface for session/runtime crashes** — **done**: `safe_failure_class()` in `error.rs` classifies all API errors into 8 user-safe classes (`provider_auth`, `provider_internal`, `provider_retry_exhausted`, `provider_rate_limit`, `provider_transport`, `provider_error`, `context_window`, `runtime_io`). `format_user_visible_api_error` in `main.rs` attaches session ID + request trace ID to every user-visible error. Coverage in `opaque_provider_wrapper_surfaces_failure_class_session_and_trace` and 3 related tests.
|
||||||
23. **`doctor --output-format json` check-level structure gap** — **done**: `claw doctor --output-format json` now keeps the human-readable `message`/`report` while also emitting structured per-check diagnostics (`name`, `status`, `summary`, `details`, plus typed fields like workspace paths and sandbox fallback data), with regression coverage in `output_format_contract.rs`.
|
23. **`doctor --output-format json` check-level structure gap** — **done**: `claw doctor --output-format json` now keeps the human-readable `message`/`report` while also emitting structured per-check diagnostics (`name`, `status`, `summary`, `details`, plus typed fields like workspace paths and sandbox fallback data), with regression coverage in `output_format_contract.rs`.
|
||||||
24. **Plugin lifecycle init/shutdown test flakes under workspace-parallel execution** — dogfooding surfaced that `build_runtime_runs_plugin_lifecycle_init_and_shutdown` can fail under `cargo test --workspace` while passing in isolation because sibling tests race on tempdir-backed shell init script paths. This is test brittleness rather than a code-path regression, but it still destabilizes CI confidence and wastes diagnosis cycles. **Action:** isolate temp resources per test robustly (unique dirs + no shared cwd assumptions), audit cleanup timing, and add a regression guard so the plugin lifecycle test remains stable under parallel workspace execution.
|
24. **Plugin lifecycle init/shutdown test flakes under workspace-parallel execution** — dogfooding surfaced that `build_runtime_runs_plugin_lifecycle_init_and_shutdown` can fail under `cargo test --workspace` while passing in isolation because sibling tests race on tempdir-backed shell init script paths. This is test brittleness rather than a code-path regression, but it still destabilizes CI confidence and wastes diagnosis cycles. **Action:** isolate temp resources per test robustly (unique dirs + no shared cwd assumptions), audit cleanup timing, and add a regression guard so the plugin lifecycle test remains stable under parallel workspace execution.
|
||||||
26. **Resumed local-command JSON parity gap** — **done**: direct `claw --output-format json` already had structured renderers for `sandbox`, `mcp`, `skills`, `version`, and `init`, but resumed `claw --output-format json --resume <session> /…` paths still fell back to prose because resumed slash dispatch only emitted JSON for `/status`. Resumed `/sandbox`, `/mcp`, `/skills`, `/version`, and `/init` now reuse the same JSON envelopes as their direct CLI counterparts, with regression coverage in `rust/crates/rusty-claude-cli/tests/resume_slash_commands.rs` and `rust/crates/rusty-claude-cli/tests/output_format_contract.rs`.
|
26. **Resumed local-command JSON parity gap** — **done**: direct `claw --output-format json` already had structured renderers for `sandbox`, `mcp`, `skills`, `version`, and `init`, but resumed `claw --output-format json --resume <session> /…` paths still fell back to prose because resumed slash dispatch only emitted JSON for `/status`. Resumed `/sandbox`, `/mcp`, `/skills`, `/version`, and `/init` now reuse the same JSON envelopes as their direct CLI counterparts, with regression coverage in `rust/crates/rusty-claude-cli/tests/resume_slash_commands.rs` and `rust/crates/rusty-claude-cli/tests/output_format_contract.rs`.
|
||||||
@@ -404,3 +404,58 @@ to:
|
|||||||
**Action item:** Wire `WorkerRegistry::transition()` to atomically write `.claw/worker-state.json` on every state transition. Add a `claw state` CLI subcommand that reads and prints this file. Add regression test.
|
**Action item:** Wire `WorkerRegistry::transition()` to atomically write `.claw/worker-state.json` on every state transition. Add a `claw state` CLI subcommand that reads and prints this file. Add regression test.
|
||||||
|
|
||||||
**Prior session note:** A previous session summary claimed commit `0984cca` landed a `/state` HTTP endpoint via axum. This was incorrect — no such commit exists on main, axum is not a dependency, and the HTTP server is not ours. The actual work that exists: `worker_boot.rs` with `WorkerStatus` enum + `WorkerRegistry`, fully wired into `runtime/src/lib.rs` as public exports.
|
**Prior session note:** A previous session summary claimed commit `0984cca` landed a `/state` HTTP endpoint via axum. This was incorrect — no such commit exists on main, axum is not a dependency, and the HTTP server is not ours. The actual work that exists: `worker_boot.rs` with `WorkerStatus` enum + `WorkerRegistry`, fully wired into `runtime/src/lib.rs` as public exports.
|
||||||
|
|
||||||
|
## Startup Friction Gap: No Default trusted_roots in Settings (filed 2026-04-08)
|
||||||
|
|
||||||
|
### Every lane starts with manual trust babysitting unless caller explicitly passes roots
|
||||||
|
|
||||||
|
**Root cause discovered during direct dogfood of WorkerCreate tool.**
|
||||||
|
|
||||||
|
`WorkerCreate` accepts a `trusted_roots: Vec<String>` parameter. If the caller omits it (or passes `[]`), every new worker immediately enters `TrustRequired` and stalls — requiring manual intervention to advance to `ReadyForPrompt`. There is no mechanism to configure a default allowlist in `settings.json` or `.claw/settings.json`.
|
||||||
|
|
||||||
|
**Impact:** Batch tooling (clawhip, lane orchestrators) must pass `trusted_roots` explicitly on every `WorkerCreate` call. If a batch script forgets the field, all workers in that batch stall silently at `trust_required`. This was the root cause of several "batch 8 lanes not advancing" incidents.
|
||||||
|
|
||||||
|
**Recommended fix:**
|
||||||
|
1. Add a `trusted_roots` field to `RuntimeConfig` (or a nested `[trust]` table), loaded via `ConfigLoader`.
|
||||||
|
2. In `WorkerRegistry::spawn_worker()`, merge config-level `trusted_roots` with any per-call overrides.
|
||||||
|
3. Default: empty list (safest). Users opt in by adding their repo paths to settings.
|
||||||
|
4. Update `config_validate` schema with the new field.
|
||||||
|
|
||||||
|
**Action item:** Wire `RuntimeConfig::trusted_roots()` → `WorkerRegistry::spawn_worker()` default. Cover with test: config with `trusted_roots = ["/tmp"]` → spawning worker in `/tmp/x` auto-resolves trust without caller passing the field.
|
||||||
|
|
||||||
|
## Observability Transport Decision (filed 2026-04-08)
|
||||||
|
|
||||||
|
### Canonical state surface: CLI/file-based. HTTP endpoint deferred.
|
||||||
|
|
||||||
|
**Decision:** `claw state` reading `.claw/worker-state.json` is the **blessed observability contract** for clawhip and downstream tooling. This is not a stepping-stone — it is the supported surface. Build against it.
|
||||||
|
|
||||||
|
**Rationale:**
|
||||||
|
- claw-code is a plugin running inside the opencode binary. It cannot add HTTP routes to `opencode serve` — that server belongs to upstream sst/opencode.
|
||||||
|
- The file-based surface is fully within plugin scope: `emit_state_file()` in `worker_boot.rs` writes atomically on every `WorkerStatus` transition.
|
||||||
|
- `claw state --output-format json` gives clawhip everything it needs: `status`, `is_ready`, `seconds_since_update`, `trust_gate_cleared`, `last_event`, `updated_at`.
|
||||||
|
- Polling a local file has lower latency and fewer failure modes than an HTTP round-trip to a sidecar.
|
||||||
|
- An HTTP state endpoint would require either (a) upstreaming a route to sst/opencode — a multi-week PR cycle with no guarantee of acceptance — or (b) a sidecar process that queries `WorkerRegistry` in-process, which is fragile and adds an extra failure domain.
|
||||||
|
|
||||||
|
**What downstream tooling (clawhip) should do:**
|
||||||
|
1. After `WorkerCreate`, poll `.claw/worker-state.json` (or run `claw state --output-format json`) in the worker's CWD at whatever interval makes sense (e.g. 5s).
|
||||||
|
2. Trust `seconds_since_update > 60` in `trust_required` status as the stall signal.
|
||||||
|
3. Call `WorkerResolveTrust` tool to unblock, or `WorkerRestart` to reset.
|
||||||
|
|
||||||
|
**HTTP endpoint tracking:** Not scheduled. If a concrete use case emerges that file polling cannot serve (e.g. remote workers over a network boundary), open a new issue to upstream a `/worker/state` route to sst/opencode at that time. Until then: file/CLI is canonical.
|
||||||
|
|
||||||
|
## Provider Routing: Model-Name Prefix Must Win Over Env-Var Presence (fixed 2026-04-08, `0530c50`)
|
||||||
|
|
||||||
|
### `openai/gpt-4.1-mini` was silently misrouted to Anthropic when ANTHROPIC_API_KEY was set
|
||||||
|
|
||||||
|
**Root cause:** `metadata_for_model` returned `None` for any model not matching `claude` or `grok` prefix.
|
||||||
|
`detect_provider_kind` then fell through to auth-sniffer order: first `has_auth_from_env_or_saved()` (Anthropic), then `OPENAI_API_KEY`, then `XAI_API_KEY`.
|
||||||
|
|
||||||
|
If `ANTHROPIC_API_KEY` was present in the environment (e.g. user has both Anthropic and OpenRouter configured), any unknown model — including explicitly namespaced ones like `openai/gpt-4.1-mini` — was silently routed to the Anthropic client, which then failed with `missing Anthropic credentials` or a confusing 402/auth error rather than routing to OpenAI-compatible.
|
||||||
|
|
||||||
|
**Fix:** Added explicit prefix checks in `metadata_for_model`:
|
||||||
|
- `openai/` prefix → `ProviderKind::OpenAi`
|
||||||
|
- `gpt-` prefix → `ProviderKind::OpenAi`
|
||||||
|
|
||||||
|
Model name prefix now wins unconditionally over env-var presence. Regression test locked in: `providers::tests::openai_namespaced_model_routes_to_openai_not_anthropic`.
|
||||||
|
|
||||||
|
**Lesson:** Auth-sniffer fallback order is fragile. Any new provider added in the future should be registered in `metadata_for_model` via a model-name prefix, not left to env-var order. This is the canonical extension point.
|
||||||
|
|||||||
20
USAGE.md
20
USAGE.md
@@ -153,6 +153,23 @@ cd rust
|
|||||||
./target/debug/claw --model "openai/gpt-4.1-mini" prompt "summarize this repository in one sentence"
|
./target/debug/claw --model "openai/gpt-4.1-mini" prompt "summarize this repository in one sentence"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Alibaba DashScope (Qwen)
|
||||||
|
|
||||||
|
For Qwen models via Alibaba's native DashScope API (higher rate limits than OpenRouter):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export DASHSCOPE_API_KEY="sk-..."
|
||||||
|
|
||||||
|
cd rust
|
||||||
|
./target/debug/claw --model "qwen/qwen-max" prompt "hello"
|
||||||
|
# or bare:
|
||||||
|
./target/debug/claw --model "qwen-plus" prompt "hello"
|
||||||
|
```
|
||||||
|
|
||||||
|
Model names starting with `qwen/` or `qwen-` are automatically routed to the DashScope compatible-mode endpoint (`https://dashscope.aliyuncs.com/compatible-mode/v1`). You do **not** need to set `OPENAI_BASE_URL` or unset `ANTHROPIC_API_KEY` — the model prefix wins over the ambient credential sniffer.
|
||||||
|
|
||||||
|
Reasoning variants (`qwen-qwq-*`, `qwq-*`, `*-thinking`) automatically strip `temperature`/`top_p`/`frequency_penalty`/`presence_penalty` before the request hits the wire (these params are rejected by reasoning models).
|
||||||
|
|
||||||
## Supported Providers & Models
|
## Supported Providers & Models
|
||||||
|
|
||||||
`claw` has three built-in provider backends. The provider is selected automatically based on the model name, falling back to whichever credential is present in the environment.
|
`claw` has three built-in provider backends. The provider is selected automatically based on the model name, falling back to whichever credential is present in the environment.
|
||||||
@@ -164,9 +181,12 @@ cd rust
|
|||||||
| **Anthropic** (direct) | Anthropic Messages API | `ANTHROPIC_API_KEY` or `ANTHROPIC_AUTH_TOKEN` or OAuth (`claw login`) | `ANTHROPIC_BASE_URL` | `https://api.anthropic.com` |
|
| **Anthropic** (direct) | Anthropic Messages API | `ANTHROPIC_API_KEY` or `ANTHROPIC_AUTH_TOKEN` or OAuth (`claw login`) | `ANTHROPIC_BASE_URL` | `https://api.anthropic.com` |
|
||||||
| **xAI** | OpenAI-compatible | `XAI_API_KEY` | `XAI_BASE_URL` | `https://api.x.ai/v1` |
|
| **xAI** | OpenAI-compatible | `XAI_API_KEY` | `XAI_BASE_URL` | `https://api.x.ai/v1` |
|
||||||
| **OpenAI-compatible** | OpenAI Chat Completions | `OPENAI_API_KEY` | `OPENAI_BASE_URL` | `https://api.openai.com/v1` |
|
| **OpenAI-compatible** | OpenAI Chat Completions | `OPENAI_API_KEY` | `OPENAI_BASE_URL` | `https://api.openai.com/v1` |
|
||||||
|
| **DashScope** (Alibaba) | OpenAI-compatible | `DASHSCOPE_API_KEY` | `DASHSCOPE_BASE_URL` | `https://dashscope.aliyuncs.com/compatible-mode/v1` |
|
||||||
|
|
||||||
The OpenAI-compatible backend also serves as the gateway for **OpenRouter**, **Ollama**, and any other service that speaks the OpenAI `/v1/chat/completions` wire format — just point `OPENAI_BASE_URL` at the service.
|
The OpenAI-compatible backend also serves as the gateway for **OpenRouter**, **Ollama**, and any other service that speaks the OpenAI `/v1/chat/completions` wire format — just point `OPENAI_BASE_URL` at the service.
|
||||||
|
|
||||||
|
**Model-name prefix routing:** If a model name starts with `openai/`, `gpt-`, `qwen/`, or `qwen-`, the provider is selected by the prefix regardless of which env vars are set. This prevents accidental misrouting to Anthropic when multiple credentials exist in the environment.
|
||||||
|
|
||||||
### Tested models and aliases
|
### Tested models and aliases
|
||||||
|
|
||||||
These are the models registered in the built-in alias table with known token limits:
|
These are the models registered in the built-in alias table with known token limits:
|
||||||
|
|||||||
@@ -704,6 +704,7 @@ mod tests {
|
|||||||
tools: None,
|
tools: None,
|
||||||
tool_choice: None,
|
tool_choice: None,
|
||||||
stream: false,
|
stream: false,
|
||||||
|
..Default::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -487,10 +487,21 @@ impl AnthropicClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn preflight_message_request(&self, request: &MessageRequest) -> Result<(), ApiError> {
|
async fn preflight_message_request(&self, request: &MessageRequest) -> Result<(), ApiError> {
|
||||||
|
// Always run the local byte-estimate guard first. This catches
|
||||||
|
// oversized requests even if the remote count_tokens endpoint is
|
||||||
|
// unreachable, misconfigured, or unimplemented (e.g., third-party
|
||||||
|
// Anthropic-compatible gateways). If byte estimation already flags
|
||||||
|
// the request as oversized, reject immediately without a network
|
||||||
|
// round trip.
|
||||||
|
super::preflight_message_request(request)?;
|
||||||
|
|
||||||
let Some(limit) = model_token_limit(&request.model) else {
|
let Some(limit) = model_token_limit(&request.model) else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Best-effort refinement using the Anthropic count_tokens endpoint.
|
||||||
|
// On any failure (network, parse, auth), fall back to the local
|
||||||
|
// byte-estimate result which already passed above.
|
||||||
let counted_input_tokens = match self.count_tokens(request).await {
|
let counted_input_tokens = match self.count_tokens(request).await {
|
||||||
Ok(count) => count,
|
Ok(count) => count,
|
||||||
Err(_) => return Ok(()),
|
Err(_) => return Ok(()),
|
||||||
@@ -515,7 +526,10 @@ impl AnthropicClient {
|
|||||||
input_tokens: u32,
|
input_tokens: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
let request_url = format!("{}/v1/messages/count_tokens", self.base_url.trim_end_matches('/'));
|
let request_url = format!(
|
||||||
|
"{}/v1/messages/count_tokens",
|
||||||
|
self.base_url.trim_end_matches('/')
|
||||||
|
);
|
||||||
let mut request_body = self.request_profile.render_json_body(request)?;
|
let mut request_body = self.request_profile.render_json_body(request)?;
|
||||||
strip_unsupported_beta_body_fields(&mut request_body);
|
strip_unsupported_beta_body_fields(&mut request_body);
|
||||||
let response = self
|
let response = self
|
||||||
@@ -528,12 +542,7 @@ impl AnthropicClient {
|
|||||||
let response = expect_success(response).await?;
|
let response = expect_success(response).await?;
|
||||||
let body = response.text().await.map_err(ApiError::from)?;
|
let body = response.text().await.map_err(ApiError::from)?;
|
||||||
let parsed = serde_json::from_str::<CountTokensResponse>(&body).map_err(|error| {
|
let parsed = serde_json::from_str::<CountTokensResponse>(&body).map_err(|error| {
|
||||||
ApiError::json_deserialize(
|
ApiError::json_deserialize("Anthropic count_tokens", &request.model, &body, error)
|
||||||
"Anthropic count_tokens",
|
|
||||||
&request.model,
|
|
||||||
&body,
|
|
||||||
error,
|
|
||||||
)
|
|
||||||
})?;
|
})?;
|
||||||
Ok(parsed.input_tokens)
|
Ok(parsed.input_tokens)
|
||||||
}
|
}
|
||||||
@@ -597,7 +606,9 @@ fn jitter_for_base(base: Duration) -> Duration {
|
|||||||
let tick = JITTER_COUNTER.fetch_add(1, Ordering::Relaxed);
|
let tick = JITTER_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||||
// splitmix64 finalizer — mixes the low bits so large bases still see
|
// splitmix64 finalizer — mixes the low bits so large bases still see
|
||||||
// jitter across their full range instead of being clamped to subsec nanos.
|
// jitter across their full range instead of being clamped to subsec nanos.
|
||||||
let mut mixed = raw_nanos.wrapping_add(tick).wrapping_add(0x9E37_79B9_7F4A_7C15);
|
let mut mixed = raw_nanos
|
||||||
|
.wrapping_add(tick)
|
||||||
|
.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||||
mixed = (mixed ^ (mixed >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
mixed = (mixed ^ (mixed >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||||
mixed = (mixed ^ (mixed >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
mixed = (mixed ^ (mixed >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||||
mixed ^= mixed >> 31;
|
mixed ^= mixed >> 31;
|
||||||
@@ -930,6 +941,15 @@ const fn is_retryable_status(status: reqwest::StatusCode) -> bool {
|
|||||||
fn strip_unsupported_beta_body_fields(body: &mut Value) {
|
fn strip_unsupported_beta_body_fields(body: &mut Value) {
|
||||||
if let Some(object) = body.as_object_mut() {
|
if let Some(object) = body.as_object_mut() {
|
||||||
object.remove("betas");
|
object.remove("betas");
|
||||||
|
// These fields are OpenAI-compatible only; Anthropic rejects them.
|
||||||
|
object.remove("frequency_penalty");
|
||||||
|
object.remove("presence_penalty");
|
||||||
|
// Anthropic uses "stop_sequences" not "stop". Convert if present.
|
||||||
|
if let Some(stop_val) = object.remove("stop") {
|
||||||
|
if stop_val.as_array().map_or(false, |a| !a.is_empty()) {
|
||||||
|
object.insert("stop_sequences".to_string(), stop_val);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1259,6 +1279,7 @@ mod tests {
|
|||||||
tools: None,
|
tools: None,
|
||||||
tool_choice: None,
|
tool_choice: None,
|
||||||
stream: false,
|
stream: false,
|
||||||
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
assert!(request.with_streaming().stream);
|
assert!(request.with_streaming().stream);
|
||||||
@@ -1438,6 +1459,52 @@ mod tests {
|
|||||||
assert_eq!(body, original);
|
assert_eq!(body, original);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strip_removes_openai_only_fields_and_converts_stop() {
|
||||||
|
let mut body = serde_json::json!({
|
||||||
|
"model": "claude-sonnet-4-6",
|
||||||
|
"max_tokens": 1024,
|
||||||
|
"temperature": 0.7,
|
||||||
|
"frequency_penalty": 0.5,
|
||||||
|
"presence_penalty": 0.3,
|
||||||
|
"stop": ["\n"],
|
||||||
|
});
|
||||||
|
|
||||||
|
super::strip_unsupported_beta_body_fields(&mut body);
|
||||||
|
|
||||||
|
// temperature is kept (Anthropic supports it)
|
||||||
|
assert_eq!(body["temperature"], serde_json::json!(0.7));
|
||||||
|
// frequency_penalty and presence_penalty are removed
|
||||||
|
assert!(
|
||||||
|
body.get("frequency_penalty").is_none(),
|
||||||
|
"frequency_penalty must be stripped for Anthropic"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
body.get("presence_penalty").is_none(),
|
||||||
|
"presence_penalty must be stripped for Anthropic"
|
||||||
|
);
|
||||||
|
// stop is renamed to stop_sequences
|
||||||
|
assert!(body.get("stop").is_none(), "stop must be renamed");
|
||||||
|
assert_eq!(body["stop_sequences"], serde_json::json!(["\n"]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strip_does_not_add_empty_stop_sequences() {
|
||||||
|
let mut body = serde_json::json!({
|
||||||
|
"model": "claude-sonnet-4-6",
|
||||||
|
"max_tokens": 1024,
|
||||||
|
"stop": [],
|
||||||
|
});
|
||||||
|
|
||||||
|
super::strip_unsupported_beta_body_fields(&mut body);
|
||||||
|
|
||||||
|
assert!(body.get("stop").is_none());
|
||||||
|
assert!(
|
||||||
|
body.get("stop_sequences").is_none(),
|
||||||
|
"empty stop should not produce stop_sequences"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rendered_request_body_strips_betas_for_standard_messages_endpoint() {
|
fn rendered_request_body_strips_betas_for_standard_messages_endpoint() {
|
||||||
let client = AnthropicClient::new("test-key").with_beta("tools-2026-04-01");
|
let client = AnthropicClient::new("test-key").with_beta("tools-2026-04-01");
|
||||||
@@ -1449,6 +1516,7 @@ mod tests {
|
|||||||
tools: None,
|
tools: None,
|
||||||
tool_choice: None,
|
tool_choice: None,
|
||||||
stream: false,
|
stream: false,
|
||||||
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut rendered = client
|
let mut rendered = client
|
||||||
|
|||||||
@@ -169,6 +169,31 @@ pub fn metadata_for_model(model: &str) -> Option<ProviderMetadata> {
|
|||||||
default_base_url: openai_compat::DEFAULT_XAI_BASE_URL,
|
default_base_url: openai_compat::DEFAULT_XAI_BASE_URL,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// Explicit provider-namespaced models (e.g. "openai/gpt-4.1-mini") must
|
||||||
|
// route to the correct provider regardless of which auth env vars are set.
|
||||||
|
// Without this, detect_provider_kind falls through to the auth-sniffer
|
||||||
|
// order and misroutes to Anthropic if ANTHROPIC_API_KEY is present.
|
||||||
|
if canonical.starts_with("openai/") || canonical.starts_with("gpt-") {
|
||||||
|
return Some(ProviderMetadata {
|
||||||
|
provider: ProviderKind::OpenAi,
|
||||||
|
auth_env: "OPENAI_API_KEY",
|
||||||
|
base_url_env: "OPENAI_BASE_URL",
|
||||||
|
default_base_url: openai_compat::DEFAULT_OPENAI_BASE_URL,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Alibaba DashScope compatible-mode endpoint. Routes qwen/* and bare
|
||||||
|
// qwen-* model names (qwen-max, qwen-plus, qwen-turbo, qwen-qwq, etc.)
|
||||||
|
// to the OpenAI-compat client pointed at DashScope's /compatible-mode/v1.
|
||||||
|
// Uses the OpenAi provider kind because DashScope speaks the OpenAI REST
|
||||||
|
// shape — only the base URL and auth env var differ.
|
||||||
|
if canonical.starts_with("qwen/") || canonical.starts_with("qwen-") {
|
||||||
|
return Some(ProviderMetadata {
|
||||||
|
provider: ProviderKind::OpenAi,
|
||||||
|
auth_env: "DASHSCOPE_API_KEY",
|
||||||
|
base_url_env: "DASHSCOPE_BASE_URL",
|
||||||
|
default_base_url: openai_compat::DEFAULT_DASHSCOPE_BASE_URL,
|
||||||
|
});
|
||||||
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,6 +377,58 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn openai_namespaced_model_routes_to_openai_not_anthropic() {
|
||||||
|
// Regression: "openai/gpt-4.1-mini" was misrouted to Anthropic when
|
||||||
|
// ANTHROPIC_API_KEY was set because metadata_for_model returned None
|
||||||
|
// and detect_provider_kind fell through to auth-sniffer order.
|
||||||
|
// The model prefix must win over env-var presence.
|
||||||
|
let kind = super::metadata_for_model("openai/gpt-4.1-mini")
|
||||||
|
.map(|m| m.provider)
|
||||||
|
.unwrap_or_else(|| detect_provider_kind("openai/gpt-4.1-mini"));
|
||||||
|
assert_eq!(
|
||||||
|
kind,
|
||||||
|
ProviderKind::OpenAi,
|
||||||
|
"openai/ prefix must route to OpenAi regardless of ANTHROPIC_API_KEY"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Also cover bare gpt- prefix
|
||||||
|
let kind2 = super::metadata_for_model("gpt-4o")
|
||||||
|
.map(|m| m.provider)
|
||||||
|
.unwrap_or_else(|| detect_provider_kind("gpt-4o"));
|
||||||
|
assert_eq!(kind2, ProviderKind::OpenAi);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn qwen_prefix_routes_to_dashscope_not_anthropic() {
|
||||||
|
// User request from Discord #clawcode-get-help: web3g wants to use
|
||||||
|
// Qwen 3.6 Plus via native Alibaba DashScope API (not OpenRouter,
|
||||||
|
// which has lower rate limits). metadata_for_model must route
|
||||||
|
// qwen/* and bare qwen-* to the OpenAi provider kind pointed at
|
||||||
|
// the DashScope compatible-mode endpoint, regardless of whether
|
||||||
|
// ANTHROPIC_API_KEY is present in the environment.
|
||||||
|
let meta = super::metadata_for_model("qwen/qwen-max")
|
||||||
|
.expect("qwen/ prefix must resolve to DashScope metadata");
|
||||||
|
assert_eq!(meta.provider, ProviderKind::OpenAi);
|
||||||
|
assert_eq!(meta.auth_env, "DASHSCOPE_API_KEY");
|
||||||
|
assert_eq!(meta.base_url_env, "DASHSCOPE_BASE_URL");
|
||||||
|
assert!(meta.default_base_url.contains("dashscope.aliyuncs.com"));
|
||||||
|
|
||||||
|
// Bare qwen- prefix also routes
|
||||||
|
let meta2 = super::metadata_for_model("qwen-plus")
|
||||||
|
.expect("qwen- prefix must resolve to DashScope metadata");
|
||||||
|
assert_eq!(meta2.provider, ProviderKind::OpenAi);
|
||||||
|
assert_eq!(meta2.auth_env, "DASHSCOPE_API_KEY");
|
||||||
|
|
||||||
|
// detect_provider_kind must agree even if ANTHROPIC_API_KEY is set
|
||||||
|
let kind = detect_provider_kind("qwen/qwen3-coder");
|
||||||
|
assert_eq!(
|
||||||
|
kind,
|
||||||
|
ProviderKind::OpenAi,
|
||||||
|
"qwen/ prefix must win over auth-sniffer order"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn keeps_existing_max_token_heuristic() {
|
fn keeps_existing_max_token_heuristic() {
|
||||||
assert_eq!(max_tokens_for_model("opus"), 32_000);
|
assert_eq!(max_tokens_for_model("opus"), 32_000);
|
||||||
@@ -446,6 +523,7 @@ mod tests {
|
|||||||
}]),
|
}]),
|
||||||
tool_choice: Some(ToolChoice::Auto),
|
tool_choice: Some(ToolChoice::Auto),
|
||||||
stream: true,
|
stream: true,
|
||||||
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
let error = preflight_message_request(&request)
|
let error = preflight_message_request(&request)
|
||||||
@@ -484,6 +562,7 @@ mod tests {
|
|||||||
tools: None,
|
tools: None,
|
||||||
tool_choice: None,
|
tool_choice: None,
|
||||||
stream: false,
|
stream: false,
|
||||||
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
preflight_message_request(&request)
|
preflight_message_request(&request)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ use super::{preflight_message_request, Provider, ProviderFuture};
|
|||||||
|
|
||||||
pub const DEFAULT_XAI_BASE_URL: &str = "https://api.x.ai/v1";
|
pub const DEFAULT_XAI_BASE_URL: &str = "https://api.x.ai/v1";
|
||||||
pub const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1";
|
pub const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1";
|
||||||
|
pub const DEFAULT_DASHSCOPE_BASE_URL: &str = "https://dashscope.aliyuncs.com/compatible-mode/v1";
|
||||||
const REQUEST_ID_HEADER: &str = "request-id";
|
const REQUEST_ID_HEADER: &str = "request-id";
|
||||||
const ALT_REQUEST_ID_HEADER: &str = "x-request-id";
|
const ALT_REQUEST_ID_HEADER: &str = "x-request-id";
|
||||||
const DEFAULT_INITIAL_BACKOFF: Duration = Duration::from_secs(1);
|
const DEFAULT_INITIAL_BACKOFF: Duration = Duration::from_secs(1);
|
||||||
@@ -34,6 +35,7 @@ pub struct OpenAiCompatConfig {
|
|||||||
|
|
||||||
const XAI_ENV_VARS: &[&str] = &["XAI_API_KEY"];
|
const XAI_ENV_VARS: &[&str] = &["XAI_API_KEY"];
|
||||||
const OPENAI_ENV_VARS: &[&str] = &["OPENAI_API_KEY"];
|
const OPENAI_ENV_VARS: &[&str] = &["OPENAI_API_KEY"];
|
||||||
|
const DASHSCOPE_ENV_VARS: &[&str] = &["DASHSCOPE_API_KEY"];
|
||||||
|
|
||||||
impl OpenAiCompatConfig {
|
impl OpenAiCompatConfig {
|
||||||
#[must_use]
|
#[must_use]
|
||||||
@@ -55,11 +57,27 @@ impl OpenAiCompatConfig {
|
|||||||
default_base_url: DEFAULT_OPENAI_BASE_URL,
|
default_base_url: DEFAULT_OPENAI_BASE_URL,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Alibaba DashScope compatible-mode endpoint (Qwen family models).
|
||||||
|
/// Uses the OpenAI-compatible REST shape at /compatible-mode/v1.
|
||||||
|
/// Requested via Discord #clawcode-get-help: native Alibaba API for
|
||||||
|
/// higher rate limits than going through OpenRouter.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn dashscope() -> Self {
|
||||||
|
Self {
|
||||||
|
provider_name: "DashScope",
|
||||||
|
api_key_env: "DASHSCOPE_API_KEY",
|
||||||
|
base_url_env: "DASHSCOPE_BASE_URL",
|
||||||
|
default_base_url: DEFAULT_DASHSCOPE_BASE_URL,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn credential_env_vars(self) -> &'static [&'static str] {
|
pub fn credential_env_vars(self) -> &'static [&'static str] {
|
||||||
match self.provider_name {
|
match self.provider_name {
|
||||||
"xAI" => XAI_ENV_VARS,
|
"xAI" => XAI_ENV_VARS,
|
||||||
"OpenAI" => OPENAI_ENV_VARS,
|
"OpenAI" => OPENAI_ENV_VARS,
|
||||||
|
"DashScope" => DASHSCOPE_ENV_VARS,
|
||||||
_ => &[],
|
_ => &[],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -135,12 +153,7 @@ impl OpenAiCompatClient {
|
|||||||
let request_id = request_id_from_headers(response.headers());
|
let request_id = request_id_from_headers(response.headers());
|
||||||
let body = response.text().await.map_err(ApiError::from)?;
|
let body = response.text().await.map_err(ApiError::from)?;
|
||||||
let payload = serde_json::from_str::<ChatCompletionResponse>(&body).map_err(|error| {
|
let payload = serde_json::from_str::<ChatCompletionResponse>(&body).map_err(|error| {
|
||||||
ApiError::json_deserialize(
|
ApiError::json_deserialize(self.config.provider_name, &request.model, &body, error)
|
||||||
self.config.provider_name,
|
|
||||||
&request.model,
|
|
||||||
&body,
|
|
||||||
error,
|
|
||||||
)
|
|
||||||
})?;
|
})?;
|
||||||
let mut normalized = normalize_response(&request.model, payload)?;
|
let mut normalized = normalize_response(&request.model, payload)?;
|
||||||
if normalized.request_id.is_none() {
|
if normalized.request_id.is_none() {
|
||||||
@@ -160,10 +173,7 @@ impl OpenAiCompatClient {
|
|||||||
Ok(MessageStream {
|
Ok(MessageStream {
|
||||||
request_id: request_id_from_headers(response.headers()),
|
request_id: request_id_from_headers(response.headers()),
|
||||||
response,
|
response,
|
||||||
parser: OpenAiSseParser::with_context(
|
parser: OpenAiSseParser::with_context(self.config.provider_name, request.model.clone()),
|
||||||
self.config.provider_name,
|
|
||||||
request.model.clone(),
|
|
||||||
),
|
|
||||||
pending: VecDeque::new(),
|
pending: VecDeque::new(),
|
||||||
done: false,
|
done: false,
|
||||||
state: StreamState::new(request.model.clone()),
|
state: StreamState::new(request.model.clone()),
|
||||||
@@ -253,7 +263,9 @@ fn jitter_for_base(base: Duration) -> Duration {
|
|||||||
.map(|elapsed| u64::try_from(elapsed.as_nanos()).unwrap_or(u64::MAX))
|
.map(|elapsed| u64::try_from(elapsed.as_nanos()).unwrap_or(u64::MAX))
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
let tick = JITTER_COUNTER.fetch_add(1, Ordering::Relaxed);
|
let tick = JITTER_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||||
let mut mixed = raw_nanos.wrapping_add(tick).wrapping_add(0x9E37_79B9_7F4A_7C15);
|
let mut mixed = raw_nanos
|
||||||
|
.wrapping_add(tick)
|
||||||
|
.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||||
mixed = (mixed ^ (mixed >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
mixed = (mixed ^ (mixed >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||||
mixed = (mixed ^ (mixed >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
mixed = (mixed ^ (mixed >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||||
mixed ^= mixed >> 31;
|
mixed ^= mixed >> 31;
|
||||||
@@ -690,6 +702,25 @@ struct ErrorBody {
|
|||||||
message: Option<String>,
|
message: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns true for models known to reject tuning parameters like temperature,
|
||||||
|
/// top_p, frequency_penalty, and presence_penalty. These are typically
|
||||||
|
/// reasoning/chain-of-thought models with fixed sampling.
|
||||||
|
fn is_reasoning_model(model: &str) -> bool {
|
||||||
|
let lowered = model.to_ascii_lowercase();
|
||||||
|
// Strip any provider/ prefix for the check (e.g. qwen/qwen-qwq -> qwen-qwq)
|
||||||
|
let canonical = lowered.rsplit('/').next().unwrap_or(lowered.as_str());
|
||||||
|
// OpenAI reasoning models
|
||||||
|
canonical.starts_with("o1")
|
||||||
|
|| canonical.starts_with("o3")
|
||||||
|
|| canonical.starts_with("o4")
|
||||||
|
// xAI reasoning: grok-3-mini always uses reasoning mode
|
||||||
|
|| canonical == "grok-3-mini"
|
||||||
|
// Alibaba DashScope reasoning variants (QwQ + Qwen3-Thinking family)
|
||||||
|
|| canonical.starts_with("qwen-qwq")
|
||||||
|
|| canonical.starts_with("qwq")
|
||||||
|
|| canonical.contains("thinking")
|
||||||
|
}
|
||||||
|
|
||||||
fn build_chat_completion_request(request: &MessageRequest, config: OpenAiCompatConfig) -> Value {
|
fn build_chat_completion_request(request: &MessageRequest, config: OpenAiCompatConfig) -> Value {
|
||||||
let mut messages = Vec::new();
|
let mut messages = Vec::new();
|
||||||
if let Some(system) = request.system.as_ref().filter(|value| !value.is_empty()) {
|
if let Some(system) = request.system.as_ref().filter(|value| !value.is_empty()) {
|
||||||
@@ -721,6 +752,30 @@ fn build_chat_completion_request(request: &MessageRequest, config: OpenAiCompatC
|
|||||||
payload["tool_choice"] = openai_tool_choice(tool_choice);
|
payload["tool_choice"] = openai_tool_choice(tool_choice);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OpenAI-compatible tuning parameters — only included when explicitly set.
|
||||||
|
// Reasoning models (o1/o3/o4/grok-3-mini) reject these params with 400;
|
||||||
|
// silently strip them to avoid cryptic provider errors.
|
||||||
|
if !is_reasoning_model(&request.model) {
|
||||||
|
if let Some(temperature) = request.temperature {
|
||||||
|
payload["temperature"] = json!(temperature);
|
||||||
|
}
|
||||||
|
if let Some(top_p) = request.top_p {
|
||||||
|
payload["top_p"] = json!(top_p);
|
||||||
|
}
|
||||||
|
if let Some(frequency_penalty) = request.frequency_penalty {
|
||||||
|
payload["frequency_penalty"] = json!(frequency_penalty);
|
||||||
|
}
|
||||||
|
if let Some(presence_penalty) = request.presence_penalty {
|
||||||
|
payload["presence_penalty"] = json!(presence_penalty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// stop is generally safe for all providers
|
||||||
|
if let Some(stop) = &request.stop {
|
||||||
|
if !stop.is_empty() {
|
||||||
|
payload["stop"] = json!(stop);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
payload
|
payload
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1009,8 +1064,9 @@ impl StringExt for String {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
build_chat_completion_request, chat_completions_endpoint, normalize_finish_reason,
|
build_chat_completion_request, chat_completions_endpoint, is_reasoning_model,
|
||||||
openai_tool_choice, parse_tool_arguments, OpenAiCompatClient, OpenAiCompatConfig,
|
normalize_finish_reason, openai_tool_choice, parse_tool_arguments, OpenAiCompatClient,
|
||||||
|
OpenAiCompatConfig,
|
||||||
};
|
};
|
||||||
use crate::error::ApiError;
|
use crate::error::ApiError;
|
||||||
use crate::types::{
|
use crate::types::{
|
||||||
@@ -1049,6 +1105,7 @@ mod tests {
|
|||||||
}]),
|
}]),
|
||||||
tool_choice: Some(ToolChoice::Auto),
|
tool_choice: Some(ToolChoice::Auto),
|
||||||
stream: false,
|
stream: false,
|
||||||
|
..Default::default()
|
||||||
},
|
},
|
||||||
OpenAiCompatConfig::xai(),
|
OpenAiCompatConfig::xai(),
|
||||||
);
|
);
|
||||||
@@ -1071,6 +1128,7 @@ mod tests {
|
|||||||
tools: None,
|
tools: None,
|
||||||
tool_choice: None,
|
tool_choice: None,
|
||||||
stream: true,
|
stream: true,
|
||||||
|
..Default::default()
|
||||||
},
|
},
|
||||||
OpenAiCompatConfig::openai(),
|
OpenAiCompatConfig::openai(),
|
||||||
);
|
);
|
||||||
@@ -1089,6 +1147,7 @@ mod tests {
|
|||||||
tools: None,
|
tools: None,
|
||||||
tool_choice: None,
|
tool_choice: None,
|
||||||
stream: true,
|
stream: true,
|
||||||
|
..Default::default()
|
||||||
},
|
},
|
||||||
OpenAiCompatConfig::xai(),
|
OpenAiCompatConfig::xai(),
|
||||||
);
|
);
|
||||||
@@ -1159,4 +1218,104 @@ mod tests {
|
|||||||
assert_eq!(normalize_finish_reason("stop"), "end_turn");
|
assert_eq!(normalize_finish_reason("stop"), "end_turn");
|
||||||
assert_eq!(normalize_finish_reason("tool_calls"), "tool_use");
|
assert_eq!(normalize_finish_reason("tool_calls"), "tool_use");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tuning_params_included_in_payload_when_set() {
|
||||||
|
let request = MessageRequest {
|
||||||
|
model: "gpt-4o".to_string(),
|
||||||
|
max_tokens: 1024,
|
||||||
|
messages: vec![],
|
||||||
|
system: None,
|
||||||
|
tools: None,
|
||||||
|
tool_choice: None,
|
||||||
|
stream: false,
|
||||||
|
temperature: Some(0.7),
|
||||||
|
top_p: Some(0.9),
|
||||||
|
frequency_penalty: Some(0.5),
|
||||||
|
presence_penalty: Some(0.3),
|
||||||
|
stop: Some(vec!["\n".to_string()]),
|
||||||
|
};
|
||||||
|
let payload = build_chat_completion_request(&request, OpenAiCompatConfig::openai());
|
||||||
|
assert_eq!(payload["temperature"], 0.7);
|
||||||
|
assert_eq!(payload["top_p"], 0.9);
|
||||||
|
assert_eq!(payload["frequency_penalty"], 0.5);
|
||||||
|
assert_eq!(payload["presence_penalty"], 0.3);
|
||||||
|
assert_eq!(payload["stop"], json!(["\n"]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reasoning_model_strips_tuning_params() {
|
||||||
|
let request = MessageRequest {
|
||||||
|
model: "o1-mini".to_string(),
|
||||||
|
max_tokens: 1024,
|
||||||
|
messages: vec![],
|
||||||
|
stream: false,
|
||||||
|
temperature: Some(0.7),
|
||||||
|
top_p: Some(0.9),
|
||||||
|
frequency_penalty: Some(0.5),
|
||||||
|
presence_penalty: Some(0.3),
|
||||||
|
stop: Some(vec!["\n".to_string()]),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let payload = build_chat_completion_request(&request, OpenAiCompatConfig::openai());
|
||||||
|
assert!(
|
||||||
|
payload.get("temperature").is_none(),
|
||||||
|
"reasoning model should strip temperature"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
payload.get("top_p").is_none(),
|
||||||
|
"reasoning model should strip top_p"
|
||||||
|
);
|
||||||
|
assert!(payload.get("frequency_penalty").is_none());
|
||||||
|
assert!(payload.get("presence_penalty").is_none());
|
||||||
|
// stop is safe for all providers
|
||||||
|
assert_eq!(payload["stop"], json!(["\n"]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn grok_3_mini_is_reasoning_model() {
|
||||||
|
assert!(is_reasoning_model("grok-3-mini"));
|
||||||
|
assert!(is_reasoning_model("o1"));
|
||||||
|
assert!(is_reasoning_model("o1-mini"));
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn qwen_reasoning_variants_are_detected() {
|
||||||
|
// QwQ reasoning model
|
||||||
|
assert!(is_reasoning_model("qwen-qwq-32b"));
|
||||||
|
assert!(is_reasoning_model("qwen/qwen-qwq-32b"));
|
||||||
|
// Qwen3 thinking family
|
||||||
|
assert!(is_reasoning_model("qwen3-30b-a3b-thinking"));
|
||||||
|
assert!(is_reasoning_model("qwen/qwen3-30b-a3b-thinking"));
|
||||||
|
// Bare qwq
|
||||||
|
assert!(is_reasoning_model("qwq-plus"));
|
||||||
|
// Regular Qwen models must NOT be classified as reasoning
|
||||||
|
assert!(!is_reasoning_model("qwen-max"));
|
||||||
|
assert!(!is_reasoning_model("qwen/qwen-plus"));
|
||||||
|
assert!(!is_reasoning_model("qwen-turbo"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tuning_params_omitted_from_payload_when_none() {
|
||||||
|
let request = MessageRequest {
|
||||||
|
model: "gpt-4o".to_string(),
|
||||||
|
max_tokens: 1024,
|
||||||
|
messages: vec![],
|
||||||
|
stream: false,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let payload = build_chat_completion_request(&request, OpenAiCompatConfig::openai());
|
||||||
|
assert!(
|
||||||
|
payload.get("temperature").is_none(),
|
||||||
|
"temperature should be absent"
|
||||||
|
);
|
||||||
|
assert!(payload.get("top_p").is_none(), "top_p should be absent");
|
||||||
|
assert!(payload.get("frequency_penalty").is_none());
|
||||||
|
assert!(payload.get("presence_penalty").is_none());
|
||||||
|
assert!(payload.get("stop").is_none());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use runtime::{pricing_for_model, TokenUsage, UsageCostEstimate};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
||||||
pub struct MessageRequest {
|
pub struct MessageRequest {
|
||||||
pub model: String,
|
pub model: String,
|
||||||
pub max_tokens: u32,
|
pub max_tokens: u32,
|
||||||
@@ -15,6 +15,17 @@ pub struct MessageRequest {
|
|||||||
pub tool_choice: Option<ToolChoice>,
|
pub tool_choice: Option<ToolChoice>,
|
||||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||||
pub stream: bool,
|
pub stream: bool,
|
||||||
|
/// OpenAI-compatible tuning parameters. Optional — omitted from payload when None.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub temperature: Option<f64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub top_p: Option<f64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub frequency_penalty: Option<f64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub presence_penalty: Option<f64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub stop: Option<Vec<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MessageRequest {
|
impl MessageRequest {
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ async fn send_message_blocks_oversized_requests_before_the_http_call() {
|
|||||||
tools: None,
|
tools: None,
|
||||||
tool_choice: None,
|
tool_choice: None,
|
||||||
stream: false,
|
stream: false,
|
||||||
|
..Default::default()
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect_err("oversized request should fail local context-window preflight");
|
.expect_err("oversized request should fail local context-window preflight");
|
||||||
@@ -741,6 +742,7 @@ async fn live_stream_smoke_test() {
|
|||||||
tools: None,
|
tools: None,
|
||||||
tool_choice: None,
|
tool_choice: None,
|
||||||
stream: false,
|
stream: false,
|
||||||
|
..Default::default()
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("live stream should start");
|
.expect("live stream should start");
|
||||||
@@ -921,5 +923,6 @@ fn sample_request(stream: bool) -> MessageRequest {
|
|||||||
}]),
|
}]),
|
||||||
tool_choice: Some(ToolChoice::Auto),
|
tool_choice: Some(ToolChoice::Auto),
|
||||||
stream,
|
stream,
|
||||||
|
..Default::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ async fn send_message_blocks_oversized_xai_requests_before_the_http_call() {
|
|||||||
tools: None,
|
tools: None,
|
||||||
tool_choice: None,
|
tool_choice: None,
|
||||||
stream: false,
|
stream: false,
|
||||||
|
..Default::default()
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect_err("oversized request should fail local context-window preflight");
|
.expect_err("oversized request should fail local context-window preflight");
|
||||||
@@ -496,6 +497,7 @@ fn sample_request(stream: bool) -> MessageRequest {
|
|||||||
}]),
|
}]),
|
||||||
tool_choice: Some(ToolChoice::Auto),
|
tool_choice: Some(ToolChoice::Auto),
|
||||||
stream,
|
stream,
|
||||||
|
..Default::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4469,7 +4469,7 @@ mod tests {
|
|||||||
assert!(help.contains("/diff"));
|
assert!(help.contains("/diff"));
|
||||||
assert!(help.contains("/version"));
|
assert!(help.contains("/version"));
|
||||||
assert!(help.contains("/export [file]"));
|
assert!(help.contains("/export [file]"));
|
||||||
assert!(help.contains("/session [list|switch <session-id>|fork [branch-name]]"));
|
assert!(help.contains("/session"), "help must mention /session");
|
||||||
assert!(help.contains("/sandbox"));
|
assert!(help.contains("/sandbox"));
|
||||||
assert!(help.contains(
|
assert!(help.contains(
|
||||||
"/plugin [list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]"
|
"/plugin [list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]"
|
||||||
|
|||||||
@@ -9,27 +9,6 @@ use crate::sandbox::{FilesystemIsolationMode, SandboxConfig};
|
|||||||
/// Schema name advertised by generated settings files.
|
/// Schema name advertised by generated settings files.
|
||||||
pub const CLAW_SETTINGS_SCHEMA_NAME: &str = "SettingsSchema";
|
pub const CLAW_SETTINGS_SCHEMA_NAME: &str = "SettingsSchema";
|
||||||
|
|
||||||
/// Top-level settings keys recognized by the runtime configuration loader.
|
|
||||||
const KNOWN_TOP_LEVEL_KEYS: &[&str] = &[
|
|
||||||
"$schema",
|
|
||||||
"enabledPlugins",
|
|
||||||
"env",
|
|
||||||
"hooks",
|
|
||||||
"mcpServers",
|
|
||||||
"model",
|
|
||||||
"oauth",
|
|
||||||
"permissionMode",
|
|
||||||
"permissions",
|
|
||||||
"plugins",
|
|
||||||
"sandbox",
|
|
||||||
];
|
|
||||||
|
|
||||||
/// Deprecated top-level keys mapped to their replacement guidance.
|
|
||||||
const DEPRECATED_TOP_LEVEL_KEYS: &[(&str, &str)] = &[
|
|
||||||
("allowedTools", "permissions.allow"),
|
|
||||||
("ignorePatterns", "permissions.deny"),
|
|
||||||
];
|
|
||||||
|
|
||||||
/// Origin of a loaded settings file in the configuration precedence chain.
|
/// Origin of a loaded settings file in the configuration precedence chain.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
pub enum ConfigSource {
|
pub enum ConfigSource {
|
||||||
@@ -85,6 +64,7 @@ pub struct RuntimeFeatureConfig {
|
|||||||
permission_rules: RuntimePermissionRuleConfig,
|
permission_rules: RuntimePermissionRuleConfig,
|
||||||
sandbox: SandboxConfig,
|
sandbox: SandboxConfig,
|
||||||
provider_fallbacks: ProviderFallbackConfig,
|
provider_fallbacks: ProviderFallbackConfig,
|
||||||
|
trusted_roots: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ordered chain of fallback model identifiers used when the primary
|
/// Ordered chain of fallback model identifiers used when the primary
|
||||||
@@ -334,6 +314,7 @@ impl ConfigLoader {
|
|||||||
permission_rules: parse_optional_permission_rules(&merged_value)?,
|
permission_rules: parse_optional_permission_rules(&merged_value)?,
|
||||||
sandbox: parse_optional_sandbox_config(&merged_value)?,
|
sandbox: parse_optional_sandbox_config(&merged_value)?,
|
||||||
provider_fallbacks: parse_optional_provider_fallbacks(&merged_value)?,
|
provider_fallbacks: parse_optional_provider_fallbacks(&merged_value)?,
|
||||||
|
trusted_roots: parse_optional_trusted_roots(&merged_value)?,
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(RuntimeConfig {
|
Ok(RuntimeConfig {
|
||||||
@@ -428,6 +409,11 @@ impl RuntimeConfig {
|
|||||||
pub fn provider_fallbacks(&self) -> &ProviderFallbackConfig {
|
pub fn provider_fallbacks(&self) -> &ProviderFallbackConfig {
|
||||||
&self.feature_config.provider_fallbacks
|
&self.feature_config.provider_fallbacks
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn trusted_roots(&self) -> &[String] {
|
||||||
|
&self.feature_config.trusted_roots
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RuntimeFeatureConfig {
|
impl RuntimeFeatureConfig {
|
||||||
@@ -492,6 +478,11 @@ impl RuntimeFeatureConfig {
|
|||||||
pub fn provider_fallbacks(&self) -> &ProviderFallbackConfig {
|
pub fn provider_fallbacks(&self) -> &ProviderFallbackConfig {
|
||||||
&self.provider_fallbacks
|
&self.provider_fallbacks
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn trusted_roots(&self) -> &[String] {
|
||||||
|
&self.trusted_roots
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProviderFallbackConfig {
|
impl ProviderFallbackConfig {
|
||||||
@@ -913,6 +904,16 @@ fn parse_optional_provider_fallbacks(
|
|||||||
Ok(ProviderFallbackConfig { primary, fallbacks })
|
Ok(ProviderFallbackConfig { primary, fallbacks })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_optional_trusted_roots(root: &JsonValue) -> Result<Vec<String>, ConfigError> {
|
||||||
|
let Some(object) = root.as_object() else {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
};
|
||||||
|
Ok(
|
||||||
|
optional_string_array(object, "trustedRoots", "merged settings.trustedRoots")?
|
||||||
|
.unwrap_or_default(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_filesystem_mode_label(value: &str) -> Result<FilesystemIsolationMode, ConfigError> {
|
fn parse_filesystem_mode_label(value: &str) -> Result<FilesystemIsolationMode, ConfigError> {
|
||||||
match value {
|
match value {
|
||||||
"off" => Ok(FilesystemIsolationMode::Off),
|
"off" => Ok(FilesystemIsolationMode::Off),
|
||||||
@@ -1465,6 +1466,53 @@ mod tests {
|
|||||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_trusted_roots_from_settings() {
|
||||||
|
// given
|
||||||
|
let root = temp_dir();
|
||||||
|
let cwd = root.join("project");
|
||||||
|
let home = root.join("home").join(".claw");
|
||||||
|
fs::create_dir_all(&home).expect("home config dir");
|
||||||
|
fs::create_dir_all(&cwd).expect("project dir");
|
||||||
|
fs::write(
|
||||||
|
home.join("settings.json"),
|
||||||
|
r#"{"trustedRoots": ["/tmp/worktrees", "/home/user/projects"]}"#,
|
||||||
|
)
|
||||||
|
.expect("write settings");
|
||||||
|
|
||||||
|
// when
|
||||||
|
let loaded = ConfigLoader::new(&cwd, &home)
|
||||||
|
.load()
|
||||||
|
.expect("config should load");
|
||||||
|
|
||||||
|
// then
|
||||||
|
let roots = loaded.trusted_roots();
|
||||||
|
assert_eq!(roots, ["/tmp/worktrees", "/home/user/projects"]);
|
||||||
|
|
||||||
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trusted_roots_default_is_empty_when_unset() {
|
||||||
|
// given
|
||||||
|
let root = temp_dir();
|
||||||
|
let cwd = root.join("project");
|
||||||
|
let home = root.join("home").join(".claw");
|
||||||
|
fs::create_dir_all(&home).expect("home config dir");
|
||||||
|
fs::create_dir_all(&cwd).expect("project dir");
|
||||||
|
fs::write(home.join("settings.json"), "{}").expect("write empty settings");
|
||||||
|
|
||||||
|
// when
|
||||||
|
let loaded = ConfigLoader::new(&cwd, &home)
|
||||||
|
.load()
|
||||||
|
.expect("config should load");
|
||||||
|
|
||||||
|
// then
|
||||||
|
assert!(loaded.trusted_roots().is_empty());
|
||||||
|
|
||||||
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_typed_mcp_and_oauth_config() {
|
fn parses_typed_mcp_and_oauth_config() {
|
||||||
let root = temp_dir();
|
let root = temp_dir();
|
||||||
|
|||||||
@@ -193,6 +193,10 @@ const TOP_LEVEL_FIELDS: &[FieldSpec] = &[
|
|||||||
name: "providerFallbacks",
|
name: "providerFallbacks",
|
||||||
expected: FieldType::Object,
|
expected: FieldType::Object,
|
||||||
},
|
},
|
||||||
|
FieldSpec {
|
||||||
|
name: "trustedRoots",
|
||||||
|
expected: FieldType::StringArray,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const HOOKS_FIELDS: &[FieldSpec] = &[
|
const HOOKS_FIELDS: &[FieldSpec] = &[
|
||||||
|
|||||||
@@ -56,10 +56,6 @@ pub use compact::{
|
|||||||
compact_session, estimate_session_tokens, format_compact_summary,
|
compact_session, estimate_session_tokens, format_compact_summary,
|
||||||
get_compact_continuation_message, should_compact, CompactionConfig, CompactionResult,
|
get_compact_continuation_message, should_compact, CompactionConfig, CompactionResult,
|
||||||
};
|
};
|
||||||
pub use config_validate::{
|
|
||||||
check_unsupported_format, format_diagnostics, validate_config_file, ConfigDiagnostic,
|
|
||||||
DiagnosticKind, ValidationResult,
|
|
||||||
};
|
|
||||||
pub use config::{
|
pub use config::{
|
||||||
ConfigEntry, ConfigError, ConfigLoader, ConfigSource, McpConfigCollection,
|
ConfigEntry, ConfigError, ConfigLoader, ConfigSource, McpConfigCollection,
|
||||||
McpManagedProxyServerConfig, McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig,
|
McpManagedProxyServerConfig, McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig,
|
||||||
@@ -68,17 +64,21 @@ pub use config::{
|
|||||||
RuntimeHookConfig, RuntimePermissionRuleConfig, RuntimePluginConfig, ScopedMcpServerConfig,
|
RuntimeHookConfig, RuntimePermissionRuleConfig, RuntimePluginConfig, ScopedMcpServerConfig,
|
||||||
CLAW_SETTINGS_SCHEMA_NAME,
|
CLAW_SETTINGS_SCHEMA_NAME,
|
||||||
};
|
};
|
||||||
|
pub use config_validate::{
|
||||||
|
check_unsupported_format, format_diagnostics, validate_config_file, ConfigDiagnostic,
|
||||||
|
DiagnosticKind, ValidationResult,
|
||||||
|
};
|
||||||
pub use conversation::{
|
pub use conversation::{
|
||||||
auto_compaction_threshold_from_env, ApiClient, ApiRequest, AssistantEvent, AutoCompactionEvent,
|
auto_compaction_threshold_from_env, ApiClient, ApiRequest, AssistantEvent, AutoCompactionEvent,
|
||||||
ConversationRuntime, PromptCacheEvent, RuntimeError, StaticToolExecutor, ToolError,
|
ConversationRuntime, PromptCacheEvent, RuntimeError, StaticToolExecutor, ToolError,
|
||||||
ToolExecutor, TurnSummary,
|
ToolExecutor, TurnSummary,
|
||||||
};
|
};
|
||||||
pub use git_context::{GitCommitEntry, GitContext};
|
|
||||||
pub use file_ops::{
|
pub use file_ops::{
|
||||||
edit_file, glob_search, grep_search, read_file, write_file, EditFileOutput, GlobSearchOutput,
|
edit_file, glob_search, grep_search, read_file, write_file, EditFileOutput, GlobSearchOutput,
|
||||||
GrepSearchInput, GrepSearchOutput, ReadFileOutput, StructuredPatchHunk, TextFilePayload,
|
GrepSearchInput, GrepSearchOutput, ReadFileOutput, StructuredPatchHunk, TextFilePayload,
|
||||||
WriteFileOutput,
|
WriteFileOutput,
|
||||||
};
|
};
|
||||||
|
pub use git_context::{GitCommitEntry, GitContext};
|
||||||
pub use hooks::{
|
pub use hooks::{
|
||||||
HookAbortSignal, HookEvent, HookProgressEvent, HookProgressReporter, HookRunResult, HookRunner,
|
HookAbortSignal, HookEvent, HookProgressEvent, HookProgressReporter, HookRunResult, HookRunner,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -366,9 +366,7 @@ mod tests {
|
|||||||
server_name: "test".to_string(),
|
server_name: "test".to_string(),
|
||||||
server_version: "0.0.0".to_string(),
|
server_version: "0.0.0".to_string(),
|
||||||
tools: Vec::new(),
|
tools: Vec::new(),
|
||||||
tool_handler: Box::new(|name, args| {
|
tool_handler: Box::new(|name, args| Ok(format!("called {name} with {args}"))),
|
||||||
Ok(format!("called {name} with {args}"))
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
stdin: BufReader::new(stdin()),
|
stdin: BufReader::new(stdin()),
|
||||||
stdout: stdout(),
|
stdout: stdout(),
|
||||||
|
|||||||
@@ -253,31 +253,6 @@ fn read_git_status(cwd: &Path) -> Option<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_git_recent_commits(cwd: &Path) -> Option<String> {
|
|
||||||
let output = Command::new("git")
|
|
||||||
.args([
|
|
||||||
"--no-optional-locks",
|
|
||||||
"log",
|
|
||||||
"--oneline",
|
|
||||||
"--no-decorate",
|
|
||||||
"-n",
|
|
||||||
"5",
|
|
||||||
])
|
|
||||||
.current_dir(cwd)
|
|
||||||
.output()
|
|
||||||
.ok()?;
|
|
||||||
if !output.status.success() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let stdout = String::from_utf8(output.stdout).ok()?;
|
|
||||||
let trimmed = stdout.trim();
|
|
||||||
if trimmed.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(trimmed.to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_git_diff(cwd: &Path) -> Option<String> {
|
fn read_git_diff(cwd: &Path) -> Option<String> {
|
||||||
let mut sections = Vec::new();
|
let mut sections = Vec::new();
|
||||||
|
|
||||||
@@ -739,8 +714,16 @@ mod tests {
|
|||||||
.render();
|
.render();
|
||||||
|
|
||||||
// then: branch, recent commits and staged files are present in context
|
// then: branch, recent commits and staged files are present in context
|
||||||
let gc = context.git_context.as_ref().expect("git context should be present");
|
let gc = context
|
||||||
let commits: String = gc.recent_commits.iter().map(|c| c.subject.clone()).collect::<Vec<_>>().join("\n");
|
.git_context
|
||||||
|
.as_ref()
|
||||||
|
.expect("git context should be present");
|
||||||
|
let commits: String = gc
|
||||||
|
.recent_commits
|
||||||
|
.iter()
|
||||||
|
.map(|c| c.subject.clone())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
assert!(commits.contains("first commit"));
|
assert!(commits.contains("first commit"));
|
||||||
assert!(commits.contains("second commit"));
|
assert!(commits.contains("second commit"));
|
||||||
assert!(commits.contains("third commit"));
|
assert!(commits.contains("third commit"));
|
||||||
|
|||||||
@@ -1438,8 +1438,59 @@ mod tests {
|
|||||||
/// Per-worktree session isolation: returns a session directory namespaced
|
/// Per-worktree session isolation: returns a session directory namespaced
|
||||||
/// by the workspace fingerprint of the given working directory.
|
/// by the workspace fingerprint of the given working directory.
|
||||||
/// This prevents parallel `opencode serve` instances from colliding.
|
/// This prevents parallel `opencode serve` instances from colliding.
|
||||||
|
/// Called by external consumers (e.g. clawhip) to enumerate sessions for a CWD.
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn workspace_sessions_dir(cwd: &std::path::Path) -> Result<std::path::PathBuf, SessionError> {
|
pub fn workspace_sessions_dir(cwd: &std::path::Path) -> Result<std::path::PathBuf, SessionError> {
|
||||||
let store = crate::session_control::SessionStore::from_cwd(cwd)
|
let store = crate::session_control::SessionStore::from_cwd(cwd).map_err(|e| {
|
||||||
.map_err(|e| SessionError::Io(std::io::Error::new(std::io::ErrorKind::Other, e.to_string())))?;
|
SessionError::Io(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::Other,
|
||||||
|
e.to_string(),
|
||||||
|
))
|
||||||
|
})?;
|
||||||
Ok(store.sessions_dir().to_path_buf())
|
Ok(store.sessions_dir().to_path_buf())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod workspace_sessions_dir_tests {
|
||||||
|
use super::*;
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspace_sessions_dir_returns_fingerprinted_path_for_valid_cwd() {
|
||||||
|
let tmp = std::env::temp_dir().join("claw-session-dir-test");
|
||||||
|
fs::create_dir_all(&tmp).expect("create temp dir");
|
||||||
|
|
||||||
|
let result = workspace_sessions_dir(&tmp);
|
||||||
|
assert!(
|
||||||
|
result.is_ok(),
|
||||||
|
"workspace_sessions_dir should succeed for a valid CWD, got: {:?}",
|
||||||
|
result
|
||||||
|
);
|
||||||
|
let dir = result.unwrap();
|
||||||
|
// The returned path should be non-empty and end with a hash component
|
||||||
|
assert!(!dir.as_os_str().is_empty());
|
||||||
|
// Two calls with the same CWD should produce identical paths (deterministic)
|
||||||
|
let result2 = workspace_sessions_dir(&tmp).unwrap();
|
||||||
|
assert_eq!(dir, result2, "workspace_sessions_dir must be deterministic");
|
||||||
|
|
||||||
|
fs::remove_dir_all(&tmp).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspace_sessions_dir_differs_for_different_cwds() {
|
||||||
|
let tmp_a = std::env::temp_dir().join("claw-session-dir-a");
|
||||||
|
let tmp_b = std::env::temp_dir().join("claw-session-dir-b");
|
||||||
|
fs::create_dir_all(&tmp_a).expect("create dir a");
|
||||||
|
fs::create_dir_all(&tmp_b).expect("create dir b");
|
||||||
|
|
||||||
|
let dir_a = workspace_sessions_dir(&tmp_a).expect("dir a");
|
||||||
|
let dir_b = workspace_sessions_dir(&tmp_b).expect("dir b");
|
||||||
|
assert_ne!(
|
||||||
|
dir_a, dir_b,
|
||||||
|
"different CWDs must produce different session dirs"
|
||||||
|
);
|
||||||
|
|
||||||
|
fs::remove_dir_all(&tmp_a).ok();
|
||||||
|
fs::remove_dir_all(&tmp_b).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1105,7 +1105,13 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn emit_state_file_writes_worker_status_on_transition() {
|
fn emit_state_file_writes_worker_status_on_transition() {
|
||||||
let cwd_path = std::env::temp_dir().join(format!("claw-state-test-{}", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos()));
|
let cwd_path = std::env::temp_dir().join(format!(
|
||||||
|
"claw-state-test-{}",
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_nanos()
|
||||||
|
));
|
||||||
std::fs::create_dir_all(&cwd_path).expect("test dir should create");
|
std::fs::create_dir_all(&cwd_path).expect("test dir should create");
|
||||||
let cwd = cwd_path.to_str().expect("test path should be utf8");
|
let cwd = cwd_path.to_str().expect("test path should be utf8");
|
||||||
let registry = WorkerRegistry::new();
|
let registry = WorkerRegistry::new();
|
||||||
@@ -1113,11 +1119,19 @@ mod tests {
|
|||||||
|
|
||||||
// After create the worker is Spawning — state file should exist
|
// After create the worker is Spawning — state file should exist
|
||||||
let state_path = cwd_path.join(".claw").join("worker-state.json");
|
let state_path = cwd_path.join(".claw").join("worker-state.json");
|
||||||
assert!(state_path.exists(), "state file should exist after worker creation");
|
assert!(
|
||||||
|
state_path.exists(),
|
||||||
|
"state file should exist after worker creation"
|
||||||
|
);
|
||||||
|
|
||||||
let raw = std::fs::read_to_string(&state_path).expect("state file should be readable");
|
let raw = std::fs::read_to_string(&state_path).expect("state file should be readable");
|
||||||
let value: serde_json::Value = serde_json::from_str(&raw).expect("state file should be valid JSON");
|
let value: serde_json::Value =
|
||||||
assert_eq!(value["status"].as_str(), Some("spawning"), "initial status should be spawning");
|
serde_json::from_str(&raw).expect("state file should be valid JSON");
|
||||||
|
assert_eq!(
|
||||||
|
value["status"].as_str(),
|
||||||
|
Some("spawning"),
|
||||||
|
"initial status should be spawning"
|
||||||
|
);
|
||||||
assert_eq!(value["is_ready"].as_bool(), Some(false));
|
assert_eq!(value["is_ready"].as_bool(), Some(false));
|
||||||
|
|
||||||
// Transition to ReadyForPrompt by observing trust-cleared text
|
// Transition to ReadyForPrompt by observing trust-cleared text
|
||||||
@@ -1125,14 +1139,20 @@ mod tests {
|
|||||||
.observe(&worker.worker_id, "Ready for input\n>")
|
.observe(&worker.worker_id, "Ready for input\n>")
|
||||||
.expect("observe ready should succeed");
|
.expect("observe ready should succeed");
|
||||||
|
|
||||||
let raw = std::fs::read_to_string(&state_path).expect("state file should be readable after observe");
|
let raw = std::fs::read_to_string(&state_path)
|
||||||
let value: serde_json::Value = serde_json::from_str(&raw).expect("state file should be valid JSON after observe");
|
.expect("state file should be readable after observe");
|
||||||
|
let value: serde_json::Value =
|
||||||
|
serde_json::from_str(&raw).expect("state file should be valid JSON after observe");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
value["status"].as_str(),
|
value["status"].as_str(),
|
||||||
Some("ready_for_prompt"),
|
Some("ready_for_prompt"),
|
||||||
"status should be ready_for_prompt after observe"
|
"status should be ready_for_prompt after observe"
|
||||||
);
|
);
|
||||||
assert_eq!(value["is_ready"].as_bool(), Some(true), "is_ready should be true when ReadyForPrompt");
|
assert_eq!(
|
||||||
|
value["is_ready"].as_bool(),
|
||||||
|
Some(true),
|
||||||
|
"is_ready should be true when ReadyForPrompt"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -54,7 +54,9 @@ use runtime::{
|
|||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{json, Map, Value};
|
use serde_json::{json, Map, Value};
|
||||||
use tools::{execute_tool, mvp_tool_specs, GlobalToolRegistry, RuntimeToolDefinition, ToolSearchOutput};
|
use tools::{
|
||||||
|
execute_tool, mvp_tool_specs, GlobalToolRegistry, RuntimeToolDefinition, ToolSearchOutput,
|
||||||
|
};
|
||||||
|
|
||||||
const DEFAULT_MODEL: &str = "claude-opus-4-6";
|
const DEFAULT_MODEL: &str = "claude-opus-4-6";
|
||||||
fn max_tokens_for_model(model: &str) -> u32 {
|
fn max_tokens_for_model(model: &str) -> u32 {
|
||||||
@@ -199,14 +201,26 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
output_format,
|
output_format,
|
||||||
allowed_tools,
|
allowed_tools,
|
||||||
permission_mode,
|
permission_mode,
|
||||||
compact: _,
|
compact,
|
||||||
base_commit,
|
base_commit,
|
||||||
} => {
|
} => {
|
||||||
run_stale_base_preflight(base_commit.as_deref());
|
run_stale_base_preflight(base_commit.as_deref());
|
||||||
let stdin_context = read_piped_stdin();
|
// Only consume piped stdin as prompt context when the permission
|
||||||
|
// mode is fully unattended. In modes where the permission
|
||||||
|
// prompter may invoke CliPermissionPrompter::decide(), stdin
|
||||||
|
// must remain available for interactive approval; otherwise the
|
||||||
|
// prompter's read_line() would hit EOF and deny every request.
|
||||||
|
let stdin_context = if matches!(permission_mode, PermissionMode::DangerFullAccess) {
|
||||||
|
read_piped_stdin()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
let effective_prompt = merge_prompt_with_stdin(&prompt, stdin_context.as_deref());
|
let effective_prompt = merge_prompt_with_stdin(&prompt, stdin_context.as_deref());
|
||||||
LiveCli::new(model, true, allowed_tools, permission_mode)?
|
LiveCli::new(model, true, allowed_tools, permission_mode)?.run_turn_with_output(
|
||||||
.run_turn_with_output(&effective_prompt, output_format, false)?;
|
&effective_prompt,
|
||||||
|
output_format,
|
||||||
|
compact,
|
||||||
|
)?;
|
||||||
}
|
}
|
||||||
CliAction::Login { output_format } => run_login(output_format)?,
|
CliAction::Login { output_format } => run_login(output_format)?,
|
||||||
CliAction::Logout { output_format } => run_logout(output_format)?,
|
CliAction::Logout { output_format } => run_logout(output_format)?,
|
||||||
@@ -942,11 +956,7 @@ fn config_permission_mode_for_current_dir() -> Option<PermissionMode> {
|
|||||||
fn config_model_for_current_dir() -> Option<String> {
|
fn config_model_for_current_dir() -> Option<String> {
|
||||||
let cwd = env::current_dir().ok()?;
|
let cwd = env::current_dir().ok()?;
|
||||||
let loader = ConfigLoader::default_for(&cwd);
|
let loader = ConfigLoader::default_for(&cwd);
|
||||||
loader
|
loader.load().ok()?.model().map(ToOwned::to_owned)
|
||||||
.load()
|
|
||||||
.ok()?
|
|
||||||
.model()
|
|
||||||
.map(ToOwned::to_owned)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_repl_model(cli_model: String) -> String {
|
fn resolve_repl_model(cli_model: String) -> String {
|
||||||
@@ -1021,10 +1031,7 @@ fn parse_system_prompt_args(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_export_args(
|
fn parse_export_args(args: &[String], output_format: CliOutputFormat) -> Result<CliAction, String> {
|
||||||
args: &[String],
|
|
||||||
output_format: CliOutputFormat,
|
|
||||||
) -> Result<CliAction, String> {
|
|
||||||
let mut session_reference = LATEST_SESSION_REFERENCE.to_string();
|
let mut session_reference = LATEST_SESSION_REFERENCE.to_string();
|
||||||
let mut output_path: Option<PathBuf> = None;
|
let mut output_path: Option<PathBuf> = None;
|
||||||
let mut index = 0;
|
let mut index = 0;
|
||||||
@@ -1336,8 +1343,13 @@ fn run_worker_state(output_format: CliOutputFormat) -> Result<(), Box<dyn std::e
|
|||||||
let state_path = cwd.join(".claw").join("worker-state.json");
|
let state_path = cwd.join(".claw").join("worker-state.json");
|
||||||
if !state_path.exists() {
|
if !state_path.exists() {
|
||||||
match output_format {
|
match output_format {
|
||||||
CliOutputFormat::Text => println!("No worker state file found at {}", state_path.display()),
|
CliOutputFormat::Text => {
|
||||||
CliOutputFormat::Json => println!("{}", serde_json::json!({"error": "no_state_file", "path": state_path.display().to_string()})),
|
println!("No worker state file found at {}", state_path.display())
|
||||||
|
}
|
||||||
|
CliOutputFormat::Json => println!(
|
||||||
|
"{}",
|
||||||
|
serde_json::json!({"error": "no_state_file", "path": state_path.display().to_string()})
|
||||||
|
),
|
||||||
}
|
}
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -2660,7 +2672,8 @@ fn run_resume_command(
|
|||||||
json: None,
|
json: None,
|
||||||
}),
|
}),
|
||||||
SlashCommand::History { count } => {
|
SlashCommand::History { count } => {
|
||||||
let limit = parse_history_count(count.as_deref()).map_err(|error| -> Box<dyn std::error::Error> { error.into() })?;
|
let limit = parse_history_count(count.as_deref())
|
||||||
|
.map_err(|error| -> Box<dyn std::error::Error> { error.into() })?;
|
||||||
let entries = collect_session_prompt_history(session);
|
let entries = collect_session_prompt_history(session);
|
||||||
Ok(ResumeCommandOutcome {
|
Ok(ResumeCommandOutcome {
|
||||||
session: session.clone(),
|
session: session.clone(),
|
||||||
@@ -4390,6 +4403,22 @@ fn resolve_managed_session_path(session_id: &str) -> Result<PathBuf, Box<dyn std
|
|||||||
return Ok(path);
|
return Ok(path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Backward compatibility: pre-isolation sessions were stored at
|
||||||
|
// `.claw/sessions/<id>.{jsonl,json}` without the per-workspace hash
|
||||||
|
// subdirectory. Walk up from `directory` to the `.claw/sessions/` root
|
||||||
|
// and try the flat layout as a fallback so users do not lose access
|
||||||
|
// to their pre-upgrade managed sessions.
|
||||||
|
if let Some(legacy_root) = directory
|
||||||
|
.parent()
|
||||||
|
.filter(|parent| parent.file_name().is_some_and(|name| name == "sessions"))
|
||||||
|
{
|
||||||
|
for extension in [PRIMARY_SESSION_EXTENSION, LEGACY_SESSION_EXTENSION] {
|
||||||
|
let path = legacy_root.join(format!("{session_id}.{extension}"));
|
||||||
|
if path.exists() {
|
||||||
|
return Ok(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Err(format_missing_session_reference(session_id).into())
|
Err(format_missing_session_reference(session_id).into())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4401,9 +4430,14 @@ fn is_managed_session_file(path: &Path) -> bool {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn list_managed_sessions() -> Result<Vec<ManagedSessionSummary>, Box<dyn std::error::Error>> {
|
fn collect_sessions_from_dir(
|
||||||
let mut sessions = Vec::new();
|
directory: &Path,
|
||||||
for entry in fs::read_dir(sessions_dir()?)? {
|
sessions: &mut Vec<ManagedSessionSummary>,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
if !directory.exists() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
for entry in fs::read_dir(directory)? {
|
||||||
let entry = entry?;
|
let entry = entry?;
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
if !is_managed_session_file(&path) {
|
if !is_managed_session_file(&path) {
|
||||||
@@ -4453,6 +4487,24 @@ fn list_managed_sessions() -> Result<Vec<ManagedSessionSummary>, Box<dyn std::er
|
|||||||
branch_name,
|
branch_name,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_managed_sessions() -> Result<Vec<ManagedSessionSummary>, Box<dyn std::error::Error>> {
|
||||||
|
let mut sessions = Vec::new();
|
||||||
|
let primary_dir = sessions_dir()?;
|
||||||
|
collect_sessions_from_dir(&primary_dir, &mut sessions)?;
|
||||||
|
|
||||||
|
// Backward compatibility: include sessions stored in the pre-isolation
|
||||||
|
// flat `.claw/sessions/` root so users do not lose access to existing
|
||||||
|
// managed sessions after the workspace-hashed subdirectory rollout.
|
||||||
|
if let Some(legacy_root) = primary_dir
|
||||||
|
.parent()
|
||||||
|
.filter(|parent| parent.file_name().is_some_and(|name| name == "sessions"))
|
||||||
|
{
|
||||||
|
collect_sessions_from_dir(legacy_root, &mut sessions)?;
|
||||||
|
}
|
||||||
|
|
||||||
sessions.sort_by(|left, right| {
|
sessions.sort_by(|left, right| {
|
||||||
right
|
right
|
||||||
.modified_epoch_millis
|
.modified_epoch_millis
|
||||||
@@ -5331,16 +5383,18 @@ fn format_history_timestamp(timestamp_ms: u64) -> String {
|
|||||||
let seconds = seconds_of_day % 60;
|
let seconds = seconds_of_day % 60;
|
||||||
|
|
||||||
let (year, month, day) = civil_from_days(i64::try_from(days_since_epoch).unwrap_or(0));
|
let (year, month, day) = civil_from_days(i64::try_from(days_since_epoch).unwrap_or(0));
|
||||||
format!(
|
format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}.{subsec_ms:03}Z")
|
||||||
"{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}.{subsec_ms:03}Z"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Computes civil (Gregorian) year/month/day from days since the Unix epoch
|
// Computes civil (Gregorian) year/month/day from days since the Unix epoch
|
||||||
// (1970-01-01) using Howard Hinnant's `civil_from_days` algorithm.
|
// (1970-01-01) using Howard Hinnant's `civil_from_days` algorithm.
|
||||||
fn civil_from_days(days: i64) -> (i32, u32, u32) {
|
fn civil_from_days(days: i64) -> (i32, u32, u32) {
|
||||||
let z = days + 719_468;
|
let z = days + 719_468;
|
||||||
let era = if z >= 0 { z / 146_097 } else { (z - 146_096) / 146_097 };
|
let era = if z >= 0 {
|
||||||
|
z / 146_097
|
||||||
|
} else {
|
||||||
|
(z - 146_096) / 146_097
|
||||||
|
};
|
||||||
let doe = (z - era * 146_097) as u64; // [0, 146_096]
|
let doe = (z - era * 146_097) as u64; // [0, 146_096]
|
||||||
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
|
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
|
||||||
let y = yoe as i64 + era * 400;
|
let y = yoe as i64 + era * 400;
|
||||||
@@ -6375,6 +6429,7 @@ impl ApiClient for AnthropicRuntimeClient {
|
|||||||
.then(|| filter_tool_specs(&self.tool_registry, self.allowed_tools.as_ref())),
|
.then(|| filter_tool_specs(&self.tool_registry, self.allowed_tools.as_ref())),
|
||||||
tool_choice: self.enable_tools.then_some(ToolChoice::Auto),
|
tool_choice: self.enable_tools.then_some(ToolChoice::Auto),
|
||||||
stream: true,
|
stream: true,
|
||||||
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
self.runtime.block_on(async {
|
self.runtime.block_on(async {
|
||||||
@@ -6390,7 +6445,10 @@ impl ApiClient for AnthropicRuntimeClient {
|
|||||||
.await;
|
.await;
|
||||||
match result {
|
match result {
|
||||||
Ok(events) => return Ok(events),
|
Ok(events) => return Ok(events),
|
||||||
Err(error) if error.to_string().contains("post-tool stall") && attempt < max_attempts => {
|
Err(error)
|
||||||
|
if error.to_string().contains("post-tool stall")
|
||||||
|
&& attempt < max_attempts =>
|
||||||
|
{
|
||||||
// Stalled after tool completion — nudge the model by
|
// Stalled after tool completion — nudge the model by
|
||||||
// re-sending the same request.
|
// re-sending the same request.
|
||||||
continue;
|
continue;
|
||||||
@@ -6399,9 +6457,7 @@ impl ApiClient for AnthropicRuntimeClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Err(RuntimeError::new(
|
Err(RuntimeError::new("post-tool continuation nudge exhausted"))
|
||||||
"post-tool continuation nudge exhausted",
|
|
||||||
))
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6415,13 +6471,13 @@ impl AnthropicRuntimeClient {
|
|||||||
message_request: &MessageRequest,
|
message_request: &MessageRequest,
|
||||||
apply_stall_timeout: bool,
|
apply_stall_timeout: bool,
|
||||||
) -> Result<Vec<AssistantEvent>, RuntimeError> {
|
) -> Result<Vec<AssistantEvent>, RuntimeError> {
|
||||||
let mut stream =
|
let mut stream = self
|
||||||
self.client
|
.client
|
||||||
.stream_message(message_request)
|
.stream_message(message_request)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| {
|
.map_err(|error| {
|
||||||
RuntimeError::new(format_user_visible_api_error(&self.session_id, &error))
|
RuntimeError::new(format_user_visible_api_error(&self.session_id, &error))
|
||||||
})?;
|
})?;
|
||||||
let mut stdout = io::stdout();
|
let mut stdout = io::stdout();
|
||||||
let mut sink = io::sink();
|
let mut sink = io::sink();
|
||||||
let out: &mut dyn Write = if self.emit_output {
|
let out: &mut dyn Write = if self.emit_output {
|
||||||
@@ -6441,10 +6497,7 @@ impl AnthropicRuntimeClient {
|
|||||||
let next = if apply_stall_timeout && !received_any_event {
|
let next = if apply_stall_timeout && !received_any_event {
|
||||||
match tokio::time::timeout(POST_TOOL_STALL_TIMEOUT, stream.next_event()).await {
|
match tokio::time::timeout(POST_TOOL_STALL_TIMEOUT, stream.next_event()).await {
|
||||||
Ok(inner) => inner.map_err(|error| {
|
Ok(inner) => inner.map_err(|error| {
|
||||||
RuntimeError::new(format_user_visible_api_error(
|
RuntimeError::new(format_user_visible_api_error(&self.session_id, &error))
|
||||||
&self.session_id,
|
|
||||||
&error,
|
|
||||||
))
|
|
||||||
})?,
|
})?,
|
||||||
Err(_elapsed) => {
|
Err(_elapsed) => {
|
||||||
return Err(RuntimeError::new(
|
return Err(RuntimeError::new(
|
||||||
@@ -6628,9 +6681,15 @@ fn format_context_window_blocked_error(session_id: &str, error: &api::ApiError)
|
|||||||
context_window_tokens,
|
context_window_tokens,
|
||||||
} => {
|
} => {
|
||||||
lines.push(format!(" Model {model}"));
|
lines.push(format!(" Model {model}"));
|
||||||
lines.push(format!(" Input estimate ~{estimated_input_tokens} tokens (heuristic)"));
|
lines.push(format!(
|
||||||
lines.push(format!(" Requested output {requested_output_tokens} tokens"));
|
" Input estimate ~{estimated_input_tokens} tokens (heuristic)"
|
||||||
lines.push(format!(" Total estimate ~{estimated_total_tokens} tokens (heuristic)"));
|
));
|
||||||
|
lines.push(format!(
|
||||||
|
" Requested output {requested_output_tokens} tokens"
|
||||||
|
));
|
||||||
|
lines.push(format!(
|
||||||
|
" Total estimate ~{estimated_total_tokens} tokens (heuristic)"
|
||||||
|
));
|
||||||
lines.push(format!(" Context window {context_window_tokens} tokens"));
|
lines.push(format!(" Context window {context_window_tokens} tokens"));
|
||||||
}
|
}
|
||||||
api::ApiError::Api { message, body, .. } => {
|
api::ApiError::Api { message, body, .. } => {
|
||||||
@@ -7603,7 +7662,10 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> {
|
|||||||
writeln!(out, " claw login")?;
|
writeln!(out, " claw login")?;
|
||||||
writeln!(out, " claw logout")?;
|
writeln!(out, " claw logout")?;
|
||||||
writeln!(out, " claw init")?;
|
writeln!(out, " claw init")?;
|
||||||
writeln!(out, " claw export [PATH] [--session SESSION] [--output PATH]")?;
|
writeln!(
|
||||||
|
out,
|
||||||
|
" claw export [PATH] [--session SESSION] [--output PATH]"
|
||||||
|
)?;
|
||||||
writeln!(
|
writeln!(
|
||||||
out,
|
out,
|
||||||
" Dump the latest (or named) session as markdown; writes to PATH or stdout"
|
" Dump the latest (or named) session as markdown; writes to PATH or stdout"
|
||||||
@@ -7668,10 +7730,7 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> {
|
|||||||
out,
|
out,
|
||||||
" claw --output-format json prompt \"explain src/main.rs\""
|
" claw --output-format json prompt \"explain src/main.rs\""
|
||||||
)?;
|
)?;
|
||||||
writeln!(
|
writeln!(out, " claw --compact \"summarize Cargo.toml\" | wc -l")?;
|
||||||
out,
|
|
||||||
" claw --compact \"summarize Cargo.toml\" | wc -l"
|
|
||||||
)?;
|
|
||||||
writeln!(
|
writeln!(
|
||||||
out,
|
out,
|
||||||
" claw --allowedTools read,glob \"summarize Cargo.toml\""
|
" claw --allowedTools read,glob \"summarize Cargo.toml\""
|
||||||
@@ -7722,18 +7781,19 @@ mod tests {
|
|||||||
format_resume_report, format_status_report, format_tool_call_start, format_tool_result,
|
format_resume_report, format_status_report, format_tool_call_start, format_tool_result,
|
||||||
format_ultraplan_report, format_unknown_slash_command,
|
format_ultraplan_report, format_unknown_slash_command,
|
||||||
format_unknown_slash_command_message, format_user_visible_api_error,
|
format_unknown_slash_command_message, format_user_visible_api_error,
|
||||||
merge_prompt_with_stdin, normalize_permission_mode, parse_args, parse_git_status_branch,
|
merge_prompt_with_stdin, normalize_permission_mode, parse_args, parse_export_args,
|
||||||
parse_git_status_metadata_for, parse_git_workspace_summary, permission_policy,
|
parse_git_status_branch, parse_git_status_metadata_for, parse_git_workspace_summary,
|
||||||
print_help_to, push_output_block, render_config_report, render_diff_report,
|
parse_history_count, permission_policy, print_help_to, push_output_block,
|
||||||
render_diff_report_for, render_memory_report, render_repl_help, render_resume_usage,
|
render_config_report, render_diff_report, render_diff_report_for, render_memory_report,
|
||||||
resolve_model_alias, resolve_model_alias_with_config, resolve_repl_model,
|
render_prompt_history_report, render_repl_help, render_resume_usage,
|
||||||
resolve_session_reference, response_to_events, resume_supported_slash_commands,
|
render_session_markdown, resolve_model_alias, resolve_model_alias_with_config,
|
||||||
run_resume_command, slash_command_completion_candidates_with_sessions, status_context,
|
resolve_repl_model, resolve_session_reference, response_to_events,
|
||||||
validate_no_args, write_mcp_server_fixture, CliAction, CliOutputFormat, CliToolExecutor,
|
resume_supported_slash_commands, run_resume_command, short_tool_id,
|
||||||
GitWorkspaceSummary, InternalPromptProgressEvent, InternalPromptProgressState, LiveCli,
|
slash_command_completion_candidates_with_sessions, status_context,
|
||||||
LocalHelpTopic, SlashCommand, StatusUsage, DEFAULT_MODEL, LATEST_SESSION_REFERENCE,
|
summarize_tool_payload_for_markdown, validate_no_args, write_mcp_server_fixture, CliAction,
|
||||||
PromptHistoryEntry, render_prompt_history_report, parse_history_count,
|
CliOutputFormat, CliToolExecutor, GitWorkspaceSummary, InternalPromptProgressEvent,
|
||||||
parse_export_args, render_session_markdown, summarize_tool_payload_for_markdown, short_tool_id,
|
InternalPromptProgressState, LiveCli, LocalHelpTopic, PromptHistoryEntry, SlashCommand,
|
||||||
|
StatusUsage, DEFAULT_MODEL, LATEST_SESSION_REFERENCE,
|
||||||
};
|
};
|
||||||
use api::{ApiError, MessageResponse, OutputContentBlock, Usage};
|
use api::{ApiError, MessageResponse, OutputContentBlock, Usage};
|
||||||
use plugins::{
|
use plugins::{
|
||||||
@@ -8585,8 +8645,12 @@ mod tests {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse_args(&["state".to_string(), "--output-format".to_string(), "json".to_string()])
|
parse_args(&[
|
||||||
.expect("state --output-format json should parse"),
|
"state".to_string(),
|
||||||
|
"--output-format".to_string(),
|
||||||
|
"json".to_string()
|
||||||
|
])
|
||||||
|
.expect("state --output-format json should parse"),
|
||||||
CliAction::State {
|
CliAction::State {
|
||||||
output_format: CliOutputFormat::Json,
|
output_format: CliOutputFormat::Json,
|
||||||
}
|
}
|
||||||
@@ -9002,11 +9066,14 @@ mod tests {
|
|||||||
fn multi_word_prompt_still_uses_shorthand_prompt_mode() {
|
fn multi_word_prompt_still_uses_shorthand_prompt_mode() {
|
||||||
let _guard = env_lock();
|
let _guard = env_lock();
|
||||||
std::env::remove_var("RUSTY_CLAUDE_PERMISSION_MODE");
|
std::env::remove_var("RUSTY_CLAUDE_PERMISSION_MODE");
|
||||||
|
// Input is ["help", "me", "debug"] so the joined prompt shorthand
|
||||||
|
// must be "help me debug". A previous batch accidentally rewrote
|
||||||
|
// the expected string to "$help overview" (copy-paste slip).
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse_args(&["help".to_string(), "me".to_string(), "debug".to_string()])
|
parse_args(&["help".to_string(), "me".to_string(), "debug".to_string()])
|
||||||
.expect("prompt shorthand should still work"),
|
.expect("prompt shorthand should still work"),
|
||||||
CliAction::Prompt {
|
CliAction::Prompt {
|
||||||
prompt: "$help overview".to_string(),
|
prompt: "help me debug".to_string(),
|
||||||
model: DEFAULT_MODEL.to_string(),
|
model: DEFAULT_MODEL.to_string(),
|
||||||
output_format: CliOutputFormat::Text,
|
output_format: CliOutputFormat::Text,
|
||||||
allowed_tools: None,
|
allowed_tools: None,
|
||||||
@@ -9323,7 +9390,9 @@ mod tests {
|
|||||||
assert!(help.contains("/diff"));
|
assert!(help.contains("/diff"));
|
||||||
assert!(help.contains("/version"));
|
assert!(help.contains("/version"));
|
||||||
assert!(help.contains("/export [file]"));
|
assert!(help.contains("/export [file]"));
|
||||||
assert!(help.contains("/session [list|switch <session-id>|fork [branch-name]]"));
|
// Batch 5 added `/session delete`; match on the stable core rather than
|
||||||
|
// the trailing bracket so future additions don't re-break this.
|
||||||
|
assert!(help.contains("/session [list|switch <session-id>|fork [branch-name]"));
|
||||||
assert!(help.contains(
|
assert!(help.contains(
|
||||||
"/plugin [list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]"
|
"/plugin [list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]"
|
||||||
));
|
));
|
||||||
@@ -9416,8 +9485,7 @@ mod tests {
|
|||||||
std::env::remove_var("ANTHROPIC_MODEL");
|
std::env::remove_var("ANTHROPIC_MODEL");
|
||||||
std::env::set_var("ANTHROPIC_MODEL", "sonnet");
|
std::env::set_var("ANTHROPIC_MODEL", "sonnet");
|
||||||
|
|
||||||
let resolved =
|
let resolved = with_current_dir(&root, || resolve_repl_model(DEFAULT_MODEL.to_string()));
|
||||||
with_current_dir(&root, || resolve_repl_model(DEFAULT_MODEL.to_string()));
|
|
||||||
|
|
||||||
assert_eq!(resolved, "claude-sonnet-4-6");
|
assert_eq!(resolved, "claude-sonnet-4-6");
|
||||||
|
|
||||||
@@ -9436,8 +9504,7 @@ mod tests {
|
|||||||
std::env::set_var("CLAW_CONFIG_HOME", &config_home);
|
std::env::set_var("CLAW_CONFIG_HOME", &config_home);
|
||||||
std::env::remove_var("ANTHROPIC_MODEL");
|
std::env::remove_var("ANTHROPIC_MODEL");
|
||||||
|
|
||||||
let resolved =
|
let resolved = with_current_dir(&root, || resolve_repl_model(DEFAULT_MODEL.to_string()));
|
||||||
with_current_dir(&root, || resolve_repl_model(DEFAULT_MODEL.to_string()));
|
|
||||||
|
|
||||||
assert_eq!(resolved, DEFAULT_MODEL);
|
assert_eq!(resolved, DEFAULT_MODEL);
|
||||||
|
|
||||||
@@ -10802,6 +10869,9 @@ UU conflicted.rs",
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn build_runtime_runs_plugin_lifecycle_init_and_shutdown() {
|
fn build_runtime_runs_plugin_lifecycle_init_and_shutdown() {
|
||||||
|
// Serialize access to process-wide env vars so parallel tests that
|
||||||
|
// set/remove ANTHROPIC_API_KEY do not race with this test.
|
||||||
|
let _guard = env_lock();
|
||||||
let config_home = temp_dir();
|
let config_home = temp_dir();
|
||||||
// Inject a dummy API key so runtime construction succeeds without real credentials.
|
// Inject a dummy API key so runtime construction succeeds without real credentials.
|
||||||
// This test only exercises plugin lifecycle (init/shutdown), never calls the API.
|
// This test only exercises plugin lifecycle (init/shutdown), never calls the API.
|
||||||
|
|||||||
@@ -183,17 +183,24 @@ fn clean_env_cli_reaches_mock_anthropic_service_across_scripted_parity_scenarios
|
|||||||
}
|
}
|
||||||
|
|
||||||
let captured = runtime.block_on(server.captured_requests());
|
let captured = runtime.block_on(server.captured_requests());
|
||||||
assert_eq!(
|
// After `be561bf` added count_tokens preflight, each turn sends an
|
||||||
captured.len(),
|
// extra POST to `/v1/messages/count_tokens` before the messages POST.
|
||||||
21,
|
// The original count (21) assumed messages-only requests. We now
|
||||||
"twelve scenarios should produce twenty-one requests"
|
// filter to `/v1/messages` and verify that subset matches the original
|
||||||
);
|
// scenario expectation.
|
||||||
assert!(captured
|
let messages_only: Vec<_> = captured
|
||||||
.iter()
|
.iter()
|
||||||
.all(|request| request.path == "/v1/messages"));
|
.filter(|r| r.path == "/v1/messages")
|
||||||
assert!(captured.iter().all(|request| request.stream));
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
messages_only.len(),
|
||||||
|
21,
|
||||||
|
"twelve scenarios should produce twenty-one /v1/messages requests (total captured: {}, includes count_tokens)",
|
||||||
|
captured.len()
|
||||||
|
);
|
||||||
|
assert!(messages_only.iter().all(|request| request.stream));
|
||||||
|
|
||||||
let scenarios = captured
|
let scenarios = messages_only
|
||||||
.iter()
|
.iter()
|
||||||
.map(|request| request.scenario.as_str())
|
.map(|request| request.scenario.as_str())
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|||||||
@@ -963,6 +963,21 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
|
|||||||
}),
|
}),
|
||||||
required_permission: PermissionMode::DangerFullAccess,
|
required_permission: PermissionMode::DangerFullAccess,
|
||||||
},
|
},
|
||||||
|
ToolSpec {
|
||||||
|
name: "WorkerObserveCompletion",
|
||||||
|
description: "Report session completion to the worker, classifying finish_reason into Finished or Failed (provider-degraded). Use after the opencode session completes to advance the worker to its terminal state.",
|
||||||
|
input_schema: json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"worker_id": { "type": "string" },
|
||||||
|
"finish_reason": { "type": "string" },
|
||||||
|
"tokens_output": { "type": "integer", "minimum": 0 }
|
||||||
|
},
|
||||||
|
"required": ["worker_id", "finish_reason", "tokens_output"],
|
||||||
|
"additionalProperties": false
|
||||||
|
}),
|
||||||
|
required_permission: PermissionMode::DangerFullAccess,
|
||||||
|
},
|
||||||
ToolSpec {
|
ToolSpec {
|
||||||
name: "TeamCreate",
|
name: "TeamCreate",
|
||||||
description: "Create a team of sub-agents for parallel task execution.",
|
description: "Create a team of sub-agents for parallel task execution.",
|
||||||
@@ -1229,6 +1244,8 @@ fn execute_tool_with_enforcer(
|
|||||||
}
|
}
|
||||||
"WorkerRestart" => from_value::<WorkerIdInput>(input).and_then(run_worker_restart),
|
"WorkerRestart" => from_value::<WorkerIdInput>(input).and_then(run_worker_restart),
|
||||||
"WorkerTerminate" => from_value::<WorkerIdInput>(input).and_then(run_worker_terminate),
|
"WorkerTerminate" => from_value::<WorkerIdInput>(input).and_then(run_worker_terminate),
|
||||||
|
"WorkerObserveCompletion" => from_value::<WorkerObserveCompletionInput>(input)
|
||||||
|
.and_then(run_worker_observe_completion),
|
||||||
"TeamCreate" => from_value::<TeamCreateInput>(input).and_then(run_team_create),
|
"TeamCreate" => from_value::<TeamCreateInput>(input).and_then(run_team_create),
|
||||||
"TeamDelete" => from_value::<TeamDeleteInput>(input).and_then(run_team_delete),
|
"TeamDelete" => from_value::<TeamDeleteInput>(input).and_then(run_team_delete),
|
||||||
"CronCreate" => from_value::<CronCreateInput>(input).and_then(run_cron_create),
|
"CronCreate" => from_value::<CronCreateInput>(input).and_then(run_cron_create),
|
||||||
@@ -1427,9 +1444,20 @@ fn run_task_output(input: TaskIdInput) -> Result<String, String> {
|
|||||||
|
|
||||||
#[allow(clippy::needless_pass_by_value)]
|
#[allow(clippy::needless_pass_by_value)]
|
||||||
fn run_worker_create(input: WorkerCreateInput) -> Result<String, String> {
|
fn run_worker_create(input: WorkerCreateInput) -> Result<String, String> {
|
||||||
|
// Merge config-level trusted_roots with per-call overrides.
|
||||||
|
// Config provides the default allowlist; per-call roots add on top.
|
||||||
|
let config_roots: Vec<String> = ConfigLoader::default_for(&input.cwd)
|
||||||
|
.load()
|
||||||
|
.ok()
|
||||||
|
.map(|c| c.trusted_roots().to_vec())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let merged_roots: Vec<String> = config_roots
|
||||||
|
.into_iter()
|
||||||
|
.chain(input.trusted_roots.iter().cloned())
|
||||||
|
.collect();
|
||||||
let worker = global_worker_registry().create(
|
let worker = global_worker_registry().create(
|
||||||
&input.cwd,
|
&input.cwd,
|
||||||
&input.trusted_roots,
|
&merged_roots,
|
||||||
input.auto_recover_prompt_misdelivery,
|
input.auto_recover_prompt_misdelivery,
|
||||||
);
|
);
|
||||||
to_pretty_json(worker)
|
to_pretty_json(worker)
|
||||||
@@ -1479,6 +1507,16 @@ fn run_worker_terminate(input: WorkerIdInput) -> Result<String, String> {
|
|||||||
to_pretty_json(worker)
|
to_pretty_json(worker)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::needless_pass_by_value)]
|
||||||
|
fn run_worker_observe_completion(input: WorkerObserveCompletionInput) -> Result<String, String> {
|
||||||
|
let worker = global_worker_registry().observe_completion(
|
||||||
|
&input.worker_id,
|
||||||
|
&input.finish_reason,
|
||||||
|
input.tokens_output,
|
||||||
|
)?;
|
||||||
|
to_pretty_json(worker)
|
||||||
|
}
|
||||||
|
|
||||||
#[allow(clippy::needless_pass_by_value)]
|
#[allow(clippy::needless_pass_by_value)]
|
||||||
fn run_team_create(input: TeamCreateInput) -> Result<String, String> {
|
fn run_team_create(input: TeamCreateInput) -> Result<String, String> {
|
||||||
let task_ids: Vec<String> = input
|
let task_ids: Vec<String> = input
|
||||||
@@ -2213,6 +2251,13 @@ struct WorkerIdInput {
|
|||||||
worker_id: String,
|
worker_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct WorkerObserveCompletionInput {
|
||||||
|
worker_id: String,
|
||||||
|
finish_reason: String,
|
||||||
|
tokens_output: u64,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct WorkerObserveInput {
|
struct WorkerObserveInput {
|
||||||
worker_id: String,
|
worker_id: String,
|
||||||
@@ -3777,7 +3822,8 @@ impl ApiClient for ProviderRuntimeClient {
|
|||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let messages = convert_messages(&request.messages);
|
let messages = convert_messages(&request.messages);
|
||||||
let system = (!request.system_prompt.is_empty()).then(|| request.system_prompt.join("\n\n"));
|
let system =
|
||||||
|
(!request.system_prompt.is_empty()).then(|| request.system_prompt.join("\n\n"));
|
||||||
let tool_choice = (!self.allowed_tools.is_empty()).then_some(ToolChoice::Auto);
|
let tool_choice = (!self.allowed_tools.is_empty()).then_some(ToolChoice::Auto);
|
||||||
|
|
||||||
let runtime = &self.runtime;
|
let runtime = &self.runtime;
|
||||||
@@ -3792,6 +3838,7 @@ impl ApiClient for ProviderRuntimeClient {
|
|||||||
tools: (!tools.is_empty()).then(|| tools.clone()),
|
tools: (!tools.is_empty()).then(|| tools.clone()),
|
||||||
tool_choice: tool_choice.clone(),
|
tool_choice: tool_choice.clone(),
|
||||||
stream: true,
|
stream: true,
|
||||||
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
let attempt = runtime.block_on(stream_with_provider(&entry.client, &message_request));
|
let attempt = runtime.block_on(stream_with_provider(&entry.client, &message_request));
|
||||||
@@ -5329,8 +5376,8 @@ mod tests {
|
|||||||
GlobalToolRegistry, LaneEventName, LaneFailureClass, ProviderRuntimeClient,
|
GlobalToolRegistry, LaneEventName, LaneFailureClass, ProviderRuntimeClient,
|
||||||
SubagentToolExecutor,
|
SubagentToolExecutor,
|
||||||
};
|
};
|
||||||
use runtime::ProviderFallbackConfig;
|
|
||||||
use api::OutputContentBlock;
|
use api::OutputContentBlock;
|
||||||
|
use runtime::ProviderFallbackConfig;
|
||||||
use runtime::{
|
use runtime::{
|
||||||
permission_enforcer::PermissionEnforcer, ApiRequest, AssistantEvent, ConversationRuntime,
|
permission_enforcer::PermissionEnforcer, ApiRequest, AssistantEvent, ConversationRuntime,
|
||||||
PermissionMode, PermissionPolicy, RuntimeError, Session, TaskPacket, ToolExecutor,
|
PermissionMode, PermissionPolicy, RuntimeError, Session, TaskPacket, ToolExecutor,
|
||||||
@@ -5506,6 +5553,436 @@ mod tests {
|
|||||||
assert_eq!(accepted_output["prompt_in_flight"], true);
|
assert_eq!(accepted_output["prompt_in_flight"], true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn worker_create_merges_config_trusted_roots_without_per_call_override() {
|
||||||
|
use std::fs;
|
||||||
|
// Write a .claw/settings.json in a temp dir with trustedRoots
|
||||||
|
let worktree = temp_path("config-trust-worktree");
|
||||||
|
let claw_dir = worktree.join(".claw");
|
||||||
|
fs::create_dir_all(&claw_dir).expect("create .claw dir");
|
||||||
|
// Use the actual OS temp dir so the worktree path matches the allowlist
|
||||||
|
let tmp_root = std::env::temp_dir().to_str().expect("utf-8").to_string();
|
||||||
|
let settings = format!("{{\"trustedRoots\": [\"{tmp_root}\"]}}");
|
||||||
|
fs::write(claw_dir.join("settings.json"), settings).expect("write settings");
|
||||||
|
|
||||||
|
// WorkerCreate with no per-call trusted_roots — config should supply them
|
||||||
|
let cwd = worktree.to_str().expect("valid utf-8").to_string();
|
||||||
|
let created = execute_tool(
|
||||||
|
"WorkerCreate",
|
||||||
|
&json!({
|
||||||
|
"cwd": cwd
|
||||||
|
// trusted_roots intentionally omitted
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.expect("WorkerCreate should succeed");
|
||||||
|
let output: serde_json::Value = serde_json::from_str(&created).expect("json");
|
||||||
|
|
||||||
|
// worktree is under /tmp, so config roots auto-resolve trust
|
||||||
|
assert_eq!(
|
||||||
|
output["trust_auto_resolve"], true,
|
||||||
|
"config-level trustedRoots should auto-resolve trust without per-call override"
|
||||||
|
);
|
||||||
|
|
||||||
|
fs::remove_dir_all(&worktree).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn worker_terminate_sets_finished_status() {
|
||||||
|
// Create a worker in running state
|
||||||
|
let created = execute_tool(
|
||||||
|
"WorkerCreate",
|
||||||
|
&json!({"cwd": "/tmp/terminate-test", "trusted_roots": ["/tmp"]}),
|
||||||
|
)
|
||||||
|
.expect("WorkerCreate should succeed");
|
||||||
|
let output: serde_json::Value = serde_json::from_str(&created).expect("json");
|
||||||
|
let worker_id = output["worker_id"].as_str().expect("worker_id").to_string();
|
||||||
|
|
||||||
|
// Terminate
|
||||||
|
let terminated = execute_tool("WorkerTerminate", &json!({"worker_id": worker_id}))
|
||||||
|
.expect("WorkerTerminate should succeed");
|
||||||
|
let term_output: serde_json::Value = serde_json::from_str(&terminated).expect("json");
|
||||||
|
assert_eq!(
|
||||||
|
term_output["status"], "finished",
|
||||||
|
"terminated worker should be finished"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
term_output["prompt_in_flight"], false,
|
||||||
|
"prompt_in_flight should be cleared on termination"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn worker_restart_resets_to_spawning() {
|
||||||
|
// Create and advance worker to ready_for_prompt
|
||||||
|
let created = execute_tool(
|
||||||
|
"WorkerCreate",
|
||||||
|
&json!({"cwd": "/tmp/restart-test", "trusted_roots": ["/tmp"]}),
|
||||||
|
)
|
||||||
|
.expect("WorkerCreate should succeed");
|
||||||
|
let output: serde_json::Value = serde_json::from_str(&created).expect("json");
|
||||||
|
let worker_id = output["worker_id"].as_str().expect("worker_id").to_string();
|
||||||
|
|
||||||
|
// Advance to ready_for_prompt via observe
|
||||||
|
execute_tool(
|
||||||
|
"WorkerObserve",
|
||||||
|
&json!({"worker_id": worker_id, "screen_text": "Ready for input\n>"}),
|
||||||
|
)
|
||||||
|
.expect("WorkerObserve should succeed");
|
||||||
|
|
||||||
|
// Restart
|
||||||
|
let restarted = execute_tool("WorkerRestart", &json!({"worker_id": worker_id}))
|
||||||
|
.expect("WorkerRestart should succeed");
|
||||||
|
let restart_output: serde_json::Value = serde_json::from_str(&restarted).expect("json");
|
||||||
|
assert_eq!(
|
||||||
|
restart_output["status"], "spawning",
|
||||||
|
"restarted worker should return to spawning"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
restart_output["prompt_in_flight"], false,
|
||||||
|
"prompt_in_flight should be cleared on restart"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
restart_output["trust_gate_cleared"], false,
|
||||||
|
"trust_gate_cleared should be reset on restart (re-trust required)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn worker_get_returns_worker_state() {
|
||||||
|
let created = execute_tool(
|
||||||
|
"WorkerCreate",
|
||||||
|
&json!({"cwd": "/tmp/worker-get-test", "trusted_roots": ["/tmp"]}),
|
||||||
|
)
|
||||||
|
.expect("WorkerCreate should succeed");
|
||||||
|
let created_output: serde_json::Value = serde_json::from_str(&created).expect("json");
|
||||||
|
let worker_id = created_output["worker_id"].as_str().expect("worker_id");
|
||||||
|
|
||||||
|
let fetched = execute_tool("WorkerGet", &json!({"worker_id": worker_id}))
|
||||||
|
.expect("WorkerGet should succeed");
|
||||||
|
let fetched_output: serde_json::Value = serde_json::from_str(&fetched).expect("json");
|
||||||
|
assert_eq!(fetched_output["worker_id"], worker_id);
|
||||||
|
assert_eq!(fetched_output["status"], "spawning");
|
||||||
|
assert_eq!(fetched_output["cwd"], "/tmp/worker-get-test");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn worker_get_on_unknown_id_returns_error() {
|
||||||
|
let result = execute_tool(
|
||||||
|
"WorkerGet",
|
||||||
|
&json!({"worker_id": "worker_nonexistent_get_00000000"}),
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
result.is_err(),
|
||||||
|
"WorkerGet on unknown id should return error"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
result.unwrap_err().contains("worker not found"),
|
||||||
|
"error should mention worker not found"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn worker_await_ready_on_spawning_worker_returns_not_ready() {
|
||||||
|
let created = execute_tool(
|
||||||
|
"WorkerCreate",
|
||||||
|
&json!({"cwd": "/tmp/worker-await-not-ready"}),
|
||||||
|
)
|
||||||
|
.expect("WorkerCreate should succeed");
|
||||||
|
let created_output: serde_json::Value = serde_json::from_str(&created).expect("json");
|
||||||
|
let worker_id = created_output["worker_id"].as_str().expect("worker_id");
|
||||||
|
|
||||||
|
// Worker is still in spawning — await_ready should return not-ready snapshot
|
||||||
|
let snapshot = execute_tool("WorkerAwaitReady", &json!({"worker_id": worker_id}))
|
||||||
|
.expect("WorkerAwaitReady should succeed even when not ready");
|
||||||
|
let snap_output: serde_json::Value = serde_json::from_str(&snapshot).expect("json");
|
||||||
|
assert_eq!(
|
||||||
|
snap_output["ready"], false,
|
||||||
|
"WorkerAwaitReady on a spawning worker must return ready=false"
|
||||||
|
);
|
||||||
|
assert_eq!(snap_output["worker_id"], worker_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn worker_send_prompt_on_non_ready_worker_returns_error() {
|
||||||
|
let created = execute_tool(
|
||||||
|
"WorkerCreate",
|
||||||
|
&json!({"cwd": "/tmp/worker-send-not-ready"}),
|
||||||
|
)
|
||||||
|
.expect("WorkerCreate should succeed");
|
||||||
|
let created_output: serde_json::Value = serde_json::from_str(&created).expect("json");
|
||||||
|
let worker_id = created_output["worker_id"].as_str().expect("worker_id");
|
||||||
|
|
||||||
|
let result = execute_tool(
|
||||||
|
"WorkerSendPrompt",
|
||||||
|
&json!({"worker_id": worker_id, "prompt": "too early"}),
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
result.is_err(),
|
||||||
|
"WorkerSendPrompt on a non-ready worker should fail"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recovery_loop_state_file_reflects_transitions() {
|
||||||
|
// End-to-end proof: .claw/worker-state.json reflects every transition
|
||||||
|
// through the stall-detect -> resolve-trust -> ready loop.
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
// Use a real temp CWD so state file can be written
|
||||||
|
let worktree = temp_path("recovery-loop-state");
|
||||||
|
fs::create_dir_all(&worktree).expect("create worktree");
|
||||||
|
let cwd = worktree.to_str().expect("utf-8").to_string();
|
||||||
|
let state_path = worktree.join(".claw").join("worker-state.json");
|
||||||
|
|
||||||
|
// 1. Create worker WITHOUT trusted_roots
|
||||||
|
let created = execute_tool("WorkerCreate", &json!({"cwd": cwd}))
|
||||||
|
.expect("WorkerCreate should succeed");
|
||||||
|
let created_output: serde_json::Value = serde_json::from_str(&created).expect("json");
|
||||||
|
let worker_id = created_output["worker_id"]
|
||||||
|
.as_str()
|
||||||
|
.expect("worker_id")
|
||||||
|
.to_string();
|
||||||
|
// State file should exist after create
|
||||||
|
assert!(
|
||||||
|
state_path.exists(),
|
||||||
|
"state file should be written after WorkerCreate"
|
||||||
|
);
|
||||||
|
let state: serde_json::Value =
|
||||||
|
serde_json::from_str(&fs::read_to_string(&state_path).expect("read state"))
|
||||||
|
.expect("parse state");
|
||||||
|
assert_eq!(state["status"], "spawning");
|
||||||
|
assert_eq!(state["is_ready"], false);
|
||||||
|
assert!(
|
||||||
|
state["seconds_since_update"].is_number(),
|
||||||
|
"seconds_since_update must be present"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Force trust_required via observe
|
||||||
|
execute_tool(
|
||||||
|
"WorkerObserve",
|
||||||
|
&json!({"worker_id": worker_id, "screen_text": "Do you trust the files in this folder?"}),
|
||||||
|
)
|
||||||
|
.expect("WorkerObserve should succeed");
|
||||||
|
let state: serde_json::Value =
|
||||||
|
serde_json::from_str(&fs::read_to_string(&state_path).expect("read state"))
|
||||||
|
.expect("parse state");
|
||||||
|
assert_eq!(
|
||||||
|
state["status"], "trust_required",
|
||||||
|
"state file must reflect trust_required stall"
|
||||||
|
);
|
||||||
|
assert_eq!(state["is_ready"], false);
|
||||||
|
assert_eq!(state["trust_gate_cleared"], false);
|
||||||
|
assert!(state["seconds_since_update"].is_number());
|
||||||
|
|
||||||
|
// 3. WorkerResolveTrust -> state file reflects recovery
|
||||||
|
execute_tool("WorkerResolveTrust", &json!({"worker_id": worker_id}))
|
||||||
|
.expect("WorkerResolveTrust should succeed");
|
||||||
|
let state: serde_json::Value =
|
||||||
|
serde_json::from_str(&fs::read_to_string(&state_path).expect("read state"))
|
||||||
|
.expect("parse state");
|
||||||
|
assert_eq!(
|
||||||
|
state["status"], "spawning",
|
||||||
|
"state file must show spawning after trust resolved"
|
||||||
|
);
|
||||||
|
assert_eq!(state["trust_gate_cleared"], true);
|
||||||
|
|
||||||
|
// 4. Observe ready screen -> state file shows ready_for_prompt
|
||||||
|
execute_tool(
|
||||||
|
"WorkerObserve",
|
||||||
|
&json!({"worker_id": worker_id, "screen_text": "Ready for input\n>"}),
|
||||||
|
)
|
||||||
|
.expect("WorkerObserve ready should succeed");
|
||||||
|
let state: serde_json::Value =
|
||||||
|
serde_json::from_str(&fs::read_to_string(&state_path).expect("read state"))
|
||||||
|
.expect("parse state");
|
||||||
|
assert_eq!(
|
||||||
|
state["status"], "ready_for_prompt",
|
||||||
|
"state file must show ready_for_prompt after ready screen"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
state["is_ready"], true,
|
||||||
|
"is_ready must be true in state file at ready_for_prompt"
|
||||||
|
);
|
||||||
|
|
||||||
|
fs::remove_dir_all(&worktree).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stall_detect_and_resolve_trust_end_to_end() {
|
||||||
|
// 1. Create worker WITHOUT trusted_roots so trust won't auto-resolve
|
||||||
|
let created = execute_tool("WorkerCreate", &json!({"cwd": "/no/trusted/root/here"}))
|
||||||
|
.expect("WorkerCreate should succeed");
|
||||||
|
let created_output: serde_json::Value = serde_json::from_str(&created).expect("json");
|
||||||
|
let worker_id = created_output["worker_id"]
|
||||||
|
.as_str()
|
||||||
|
.expect("worker_id")
|
||||||
|
.to_string();
|
||||||
|
assert_eq!(created_output["trust_auto_resolve"], false);
|
||||||
|
|
||||||
|
// 2. Observe trust prompt screen text -> worker stalls at trust_required
|
||||||
|
let stalled = execute_tool(
|
||||||
|
"WorkerObserve",
|
||||||
|
&json!({
|
||||||
|
"worker_id": worker_id,
|
||||||
|
"screen_text": "Do you trust the files in this folder?\n[Allow] [Deny]"
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.expect("WorkerObserve should succeed");
|
||||||
|
let stalled_output: serde_json::Value = serde_json::from_str(&stalled).expect("json");
|
||||||
|
assert_eq!(
|
||||||
|
stalled_output["status"], "trust_required",
|
||||||
|
"worker should stall at trust_required when trust prompt seen without allowlist"
|
||||||
|
);
|
||||||
|
assert_eq!(stalled_output["trust_gate_cleared"], false);
|
||||||
|
// 3. Clawhip calls WorkerResolveTrust to unblock
|
||||||
|
let resolved = execute_tool("WorkerResolveTrust", &json!({"worker_id": worker_id}))
|
||||||
|
.expect("WorkerResolveTrust should succeed");
|
||||||
|
let resolved_output: serde_json::Value = serde_json::from_str(&resolved).expect("json");
|
||||||
|
assert_eq!(
|
||||||
|
resolved_output["status"], "spawning",
|
||||||
|
"worker should return to spawning after trust resolved"
|
||||||
|
);
|
||||||
|
assert_eq!(resolved_output["trust_gate_cleared"], true);
|
||||||
|
|
||||||
|
// 4. Ready screen text now advances worker normally
|
||||||
|
let ready = execute_tool(
|
||||||
|
"WorkerObserve",
|
||||||
|
&json!({
|
||||||
|
"worker_id": worker_id,
|
||||||
|
"screen_text": "Ready for input\n>"
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.expect("WorkerObserve should succeed after trust resolved");
|
||||||
|
let ready_output: serde_json::Value = serde_json::from_str(&ready).expect("json");
|
||||||
|
assert_eq!(
|
||||||
|
ready_output["status"], "ready_for_prompt",
|
||||||
|
"worker should reach ready_for_prompt after trust resolved and ready screen seen"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stall_detect_and_restart_recovery_end_to_end() {
|
||||||
|
// Worker stalls at trust_required, clawhip restarts instead of resolving
|
||||||
|
let created = execute_tool(
|
||||||
|
"WorkerCreate",
|
||||||
|
&json!({"cwd": "/no/trusted/root/restart-test"}),
|
||||||
|
)
|
||||||
|
.expect("WorkerCreate should succeed");
|
||||||
|
let created_output: serde_json::Value = serde_json::from_str(&created).expect("json");
|
||||||
|
let worker_id = created_output["worker_id"]
|
||||||
|
.as_str()
|
||||||
|
.expect("worker_id")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// Force trust_required
|
||||||
|
let stalled = execute_tool(
|
||||||
|
"WorkerObserve",
|
||||||
|
&json!({
|
||||||
|
"worker_id": worker_id,
|
||||||
|
"screen_text": "trust this folder? [Yes] [No]"
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.expect("WorkerObserve should succeed");
|
||||||
|
let stalled_output: serde_json::Value = serde_json::from_str(&stalled).expect("json");
|
||||||
|
assert_eq!(stalled_output["status"], "trust_required");
|
||||||
|
|
||||||
|
// WorkerRestart resets the worker
|
||||||
|
let restarted = execute_tool("WorkerRestart", &json!({"worker_id": worker_id}))
|
||||||
|
.expect("WorkerRestart should succeed");
|
||||||
|
let restarted_output: serde_json::Value = serde_json::from_str(&restarted).expect("json");
|
||||||
|
assert_eq!(
|
||||||
|
restarted_output["status"], "spawning",
|
||||||
|
"restarted worker should be back at spawning"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
restarted_output["trust_gate_cleared"], false,
|
||||||
|
"restart clears trust — next observe loop must re-acquire trust"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn worker_terminate_on_unknown_id_returns_error() {
|
||||||
|
let result = execute_tool(
|
||||||
|
"WorkerTerminate",
|
||||||
|
&json!({"worker_id": "worker_nonexistent_00000000"}),
|
||||||
|
);
|
||||||
|
assert!(result.is_err(), "terminating unknown worker should fail");
|
||||||
|
assert!(
|
||||||
|
result.unwrap_err().contains("worker not found"),
|
||||||
|
"error should mention worker not found"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn worker_restart_on_unknown_id_returns_error() {
|
||||||
|
let result = execute_tool(
|
||||||
|
"WorkerRestart",
|
||||||
|
&json!({"worker_id": "worker_nonexistent_00000001"}),
|
||||||
|
);
|
||||||
|
assert!(result.is_err(), "restarting unknown worker should fail");
|
||||||
|
assert!(
|
||||||
|
result.unwrap_err().contains("worker not found"),
|
||||||
|
"error should mention worker not found"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn worker_observe_completion_success_finish_sets_finished_status() {
|
||||||
|
let created = execute_tool(
|
||||||
|
"WorkerCreate",
|
||||||
|
&json!({"cwd": "/tmp/observe-completion-test", "trusted_roots": ["/tmp"]}),
|
||||||
|
)
|
||||||
|
.expect("WorkerCreate should succeed");
|
||||||
|
let output: serde_json::Value = serde_json::from_str(&created).expect("json");
|
||||||
|
let worker_id = output["worker_id"].as_str().expect("worker_id").to_string();
|
||||||
|
|
||||||
|
let completed = execute_tool(
|
||||||
|
"WorkerObserveCompletion",
|
||||||
|
&json!({
|
||||||
|
"worker_id": worker_id,
|
||||||
|
"finish_reason": "end_turn",
|
||||||
|
"tokens_output": 512
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.expect("WorkerObserveCompletion should succeed");
|
||||||
|
let completed_output: serde_json::Value = serde_json::from_str(&completed).expect("json");
|
||||||
|
assert_eq!(completed_output["status"], "finished");
|
||||||
|
assert_eq!(completed_output["prompt_in_flight"], false);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn worker_observe_completion_degraded_provider_sets_failed_status() {
|
||||||
|
let created = execute_tool(
|
||||||
|
"WorkerCreate",
|
||||||
|
&json!({"cwd": "/tmp/observe-degraded-test", "trusted_roots": ["/tmp"]}),
|
||||||
|
)
|
||||||
|
.expect("WorkerCreate should succeed");
|
||||||
|
let output: serde_json::Value = serde_json::from_str(&created).expect("json");
|
||||||
|
let worker_id = output["worker_id"].as_str().expect("worker_id").to_string();
|
||||||
|
|
||||||
|
// finish=unknown + 0 tokens = degraded provider classification
|
||||||
|
let failed = execute_tool(
|
||||||
|
"WorkerObserveCompletion",
|
||||||
|
&json!({
|
||||||
|
"worker_id": worker_id,
|
||||||
|
"finish_reason": "unknown",
|
||||||
|
"tokens_output": 0
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.expect("WorkerObserveCompletion should succeed");
|
||||||
|
let failed_output: serde_json::Value = serde_json::from_str(&failed).expect("json");
|
||||||
|
assert_eq!(
|
||||||
|
failed_output["status"], "failed",
|
||||||
|
"finish=unknown + 0 tokens should classify as provider failure"
|
||||||
|
);
|
||||||
|
assert_eq!(failed_output["prompt_in_flight"], false);
|
||||||
|
// last_error should be set with provider failure message
|
||||||
|
assert!(
|
||||||
|
!failed_output["last_error"].is_null(),
|
||||||
|
"last_error should be populated for provider failure"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn worker_tools_detect_misdelivery_and_arm_prompt_replay() {
|
fn worker_tools_detect_misdelivery_and_arm_prompt_replay() {
|
||||||
let created = execute_tool(
|
let created = execute_tool(
|
||||||
|
|||||||
Reference in New Issue
Block a user