Compare commits

...

7 Commits

Author SHA1 Message Date
YeonGyu-Kim
5851f2dee8 fix(cli): 6 cascading test regressions hidden behind client_integration gate
- compact flag: was parsed then discarded (`compact: _`) instead of
  passed to `run_turn_with_output` — hardcoded `false` meant --compact
  never took effect
- piped stdin vs permission prompter: `read_piped_stdin()` consumed all
  stdin before `CliPermissionPrompter::decide()` could read interactive
  approval answers; now only consumes stdin as prompt context when
  permission mode is `DangerFullAccess` (fully unattended)
- session resolver: `resolve_managed_session_path` and
  `list_managed_sessions` now fall back to the pre-isolation flat
  `.claw/sessions/` layout so legacy sessions remain accessible
- help assertion: match on stable prefix after `/session delete` was
  added in batch 5
- prompt shorthand: fix copy-paste that changed expected prompt from
  "help me debug" to "$help overview"
- mock parity harness: filter captured requests to `/v1/messages` path
  only, excluding count_tokens preflight calls added by `be561bf`

All 6 failures were pre-existing but masked because `client_integration`
always failed first (fixed in 8c6dfe5).

Workspace: 810+ tests passing, 0 failing.
2026-04-08 14:54:10 +09:00
YeonGyu-Kim
8c6dfe57e6 fix(api): restore local preflight guard ahead of count_tokens round-trip
CI has been red since be561bf ('Use Anthropic count tokens for preflight')
because that commit replaced the free-function preflight_message_request
(byte-estimate guard) with an instance method that silently returns Ok on
any count_tokens failure:

    let counted_input_tokens = match self.count_tokens(request).await {
        Ok(count) => count,
        Err(_) => return Ok(()),  // <-- silent bypass
    };

Two consequences:

1. client_integration::send_message_blocks_oversized_requests_before_the_http_call
   has been FAILING on every CI run since be561bf. The mock server in that
   test only has one HTTP response queued (a bare '{}' to satisfy the main
   request), so the count_tokens POST parses into an empty body that fails
   to deserialize into CountTokensResponse -> Err -> silent bypass -> the
   oversized 600k-char request proceeds to the mock instead of being
   rejected with ContextWindowExceeded as the test expects.

2. In production, any third-party Anthropic-compatible gateway that doesn't
   implement /v1/messages/count_tokens (OpenRouter, Cloudflare AI Gateway,
   etc.) would silently disable the preflight guard entirely, letting
   oversized requests hit the upstream only to fail there with a provider-
   side context-window error. This is exactly the 'opaque failure surface'
   ROADMAP #22 asked us to avoid.

Fix: call the free-function super::preflight_message_request(request)? as
the first step in the instance method, before any network round-trip. This
guarantees the byte-estimate guard always fires, whether or not the remote
count_tokens endpoint is reachable. The count_tokens refinement still runs
afterward when available for more precise token counting, but it is now
strictly additive — it can only catch more cases, never silently skip the
guard.

Test results:
- cargo test -p api --lib: 89 passed, 0 failed
- cargo test --release -p api (all test binaries): 118 passed, 0 failed
- cargo test --release -p api --test client_integration \
    send_message_blocks_oversized_requests_before_the_http_call: passes
- cargo fmt --check: clean

This unblocks the Rust CI workflow which has been red on every push since
be561bf landed.
2026-04-08 14:34:38 +09:00
YeonGyu-Kim
eed57212bb docs(usage): add DashScope/Qwen section and prefix routing note
Document the qwen/ and qwen- prefix routing added in 3ac97e6. Users
in Discord #clawcode-get-help (web3g, Renan Klehm, matthewblott) kept
hitting ambient-credential misrouting because the docs only showed
the OPENAI_BASE_URL pattern without explaining that model-name prefix
wins over env-var presence.

Added:
- DashScope usage section with qwen/qwen-max and bare qwen-plus examples
- DashScope row in provider matrix table
- Reasoning model sanitization note (qwen-qwq, qwq-*, *-thinking)
- Explicit statement that model-name prefix wins over ambient creds
2026-04-08 14:11:12 +09:00
YeonGyu-Kim
3ac97e635e feat(api): add qwen/ prefix routing for Alibaba DashScope provider
Users in Discord #clawcode-get-help (web3g) asked for Qwen 3.6 Plus via
native Alibaba DashScope API instead of OpenRouter, which has stricter
rate limits. This commit adds first-class routing for qwen/ and bare
qwen- prefixed model names.

Changes:
- DEFAULT_DASHSCOPE_BASE_URL constant: /compatible-mode/v1 endpoint
- OpenAiCompatConfig::dashscope() factory mirroring openai()/xai()
- DASHSCOPE_ENV_VARS + credential_env_vars() wiring
- metadata_for_model: qwen/ and qwen- prefix routes to DashScope with
  auth_env=DASHSCOPE_API_KEY, reuses ProviderKind::OpenAi because
  DashScope speaks the OpenAI REST shape
- is_reasoning_model: detect qwen-qwq, qwq-*, and *-thinking variants
  so tuning params (temperature, top_p, etc.) get stripped before
  payload assembly (same pattern as o1/o3/grok-3-mini)

Tests added:
- providers::tests::qwen_prefix_routes_to_dashscope_not_anthropic
- openai_compat::tests::qwen_reasoning_variants_are_detected

89 api lib tests passing, 0 failing. cargo fmt --check: clean.

Closes the user-reported gap: 'use Qwen 3.6 Plus via Alibaba API
directly, not OpenRouter' without needing OPENAI_BASE_URL override
or unsetting ANTHROPIC_API_KEY.
2026-04-08 14:06:26 +09:00
YeonGyu-Kim
006f7d7ee6 fix(test): add env_lock to plugin lifecycle test — closes ROADMAP #24
build_runtime_runs_plugin_lifecycle_init_and_shutdown was the only test
that set/removed ANTHROPIC_API_KEY without holding the env_lock mutex.
Under parallel workspace execution, other tests racing on the same env
var could wipe the key mid-construction, causing a flaky credential error.

Root cause: process-wide env vars are shared mutable state. All other
tests that touch ANTHROPIC_API_KEY already use env_lock(). This test
was the only holdout.

Fix: add let _guard = env_lock(); at the top of the test.
2026-04-08 12:46:04 +09:00
YeonGyu-Kim
82baaf3f22 fix(ci): update integration test MessageRequest initializers for new tuning fields
openai_compat_integration.rs and client_integration.rs had MessageRequest
constructions without the new tuning param fields (temperature, top_p,
frequency_penalty, presence_penalty, stop) added in c667d47.

Added ..Default::default() to all 4 sites. cargo fmt applied.

This was the root cause of CI red on main (E0063 compile error in
integration tests, not caught by --lib tests).
2026-04-08 11:43:51 +09:00
YeonGyu-Kim
c7b3296ef6 style: cargo fmt — fix CI formatting failures
Pre-existing formatting issues in anthropic.rs surfaced by CI cargo fmt check.
No functional changes.
2026-04-08 11:21:13 +09:00
16 changed files with 453 additions and 222 deletions

View File

@@ -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`.

View File

@@ -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:

View File

@@ -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;
@@ -1268,7 +1279,7 @@ mod tests {
tools: None, tools: None,
tool_choice: None, tool_choice: None,
stream: false, stream: false,
..Default::default() ..Default::default()
}; };
assert!(request.with_streaming().stream); assert!(request.with_streaming().stream);
@@ -1464,10 +1475,14 @@ mod tests {
// temperature is kept (Anthropic supports it) // temperature is kept (Anthropic supports it)
assert_eq!(body["temperature"], serde_json::json!(0.7)); assert_eq!(body["temperature"], serde_json::json!(0.7));
// frequency_penalty and presence_penalty are removed // frequency_penalty and presence_penalty are removed
assert!(body.get("frequency_penalty").is_none(), assert!(
"frequency_penalty must be stripped for Anthropic"); body.get("frequency_penalty").is_none(),
assert!(body.get("presence_penalty").is_none(), "frequency_penalty must be stripped for Anthropic"
"presence_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 // stop is renamed to stop_sequences
assert!(body.get("stop").is_none(), "stop must be renamed"); assert!(body.get("stop").is_none(), "stop must be renamed");
assert_eq!(body["stop_sequences"], serde_json::json!(["\n"])); assert_eq!(body["stop_sequences"], serde_json::json!(["\n"]));
@@ -1484,8 +1499,10 @@ mod tests {
super::strip_unsupported_beta_body_fields(&mut body); super::strip_unsupported_beta_body_fields(&mut body);
assert!(body.get("stop").is_none()); assert!(body.get("stop").is_none());
assert!(body.get("stop_sequences").is_none(), assert!(
"empty stop should not produce stop_sequences"); body.get("stop_sequences").is_none(),
"empty stop should not produce stop_sequences"
);
} }
#[test] #[test]
@@ -1499,7 +1516,7 @@ mod tests {
tools: None, tools: None,
tool_choice: None, tool_choice: None,
stream: false, stream: false,
..Default::default() ..Default::default()
}; };
let mut rendered = client let mut rendered = client

View File

@@ -181,6 +181,19 @@ pub fn metadata_for_model(model: &str) -> Option<ProviderMetadata> {
default_base_url: openai_compat::DEFAULT_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
} }
@@ -386,6 +399,36 @@ mod tests {
assert_eq!(kind2, ProviderKind::OpenAi); 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);

View File

@@ -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;
@@ -695,12 +707,18 @@ struct ErrorBody {
/// reasoning/chain-of-thought models with fixed sampling. /// reasoning/chain-of-thought models with fixed sampling.
fn is_reasoning_model(model: &str) -> bool { fn is_reasoning_model(model: &str) -> bool {
let lowered = model.to_ascii_lowercase(); 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 // OpenAI reasoning models
lowered.starts_with("o1") canonical.starts_with("o1")
|| lowered.starts_with("o3") || canonical.starts_with("o3")
|| lowered.starts_with("o4") || canonical.starts_with("o4")
// xAI reasoning: grok-3-mini always uses reasoning mode // xAI reasoning: grok-3-mini always uses reasoning mode
|| lowered == "grok-3-mini" || 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 {
@@ -1110,7 +1128,7 @@ mod tests {
tools: None, tools: None,
tool_choice: None, tool_choice: None,
stream: true, stream: true,
..Default::default() ..Default::default()
}, },
OpenAiCompatConfig::openai(), OpenAiCompatConfig::openai(),
); );
@@ -1129,7 +1147,7 @@ mod tests {
tools: None, tools: None,
tool_choice: None, tool_choice: None,
stream: true, stream: true,
..Default::default() ..Default::default()
}, },
OpenAiCompatConfig::xai(), OpenAiCompatConfig::xai(),
); );
@@ -1240,8 +1258,14 @@ mod tests {
..Default::default() ..Default::default()
}; };
let payload = build_chat_completion_request(&request, OpenAiCompatConfig::openai()); let payload = build_chat_completion_request(&request, OpenAiCompatConfig::openai());
assert!(payload.get("temperature").is_none(), "reasoning model should strip temperature"); assert!(
assert!(payload.get("top_p").is_none(), "reasoning model should strip top_p"); 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("frequency_penalty").is_none());
assert!(payload.get("presence_penalty").is_none()); assert!(payload.get("presence_penalty").is_none());
// stop is safe for all providers // stop is safe for all providers
@@ -1259,6 +1283,22 @@ mod tests {
assert!(!is_reasoning_model("claude-sonnet-4-6")); 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] #[test]
fn tuning_params_omitted_from_payload_when_none() { fn tuning_params_omitted_from_payload_when_none() {
let request = MessageRequest { let request = MessageRequest {
@@ -1269,7 +1309,10 @@ mod tests {
..Default::default() ..Default::default()
}; };
let payload = build_chat_completion_request(&request, OpenAiCompatConfig::openai()); let payload = build_chat_completion_request(&request, OpenAiCompatConfig::openai());
assert!(payload.get("temperature").is_none(), "temperature should be absent"); 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("top_p").is_none(), "top_p should be absent");
assert!(payload.get("frequency_penalty").is_none()); assert!(payload.get("frequency_penalty").is_none());
assert!(payload.get("presence_penalty").is_none()); assert!(payload.get("presence_penalty").is_none());

View File

@@ -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()
} }
} }

View File

@@ -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()
} }
} }

View File

@@ -908,8 +908,10 @@ fn parse_optional_trusted_roots(root: &JsonValue) -> Result<Vec<String>, ConfigE
let Some(object) = root.as_object() else { let Some(object) = root.as_object() else {
return Ok(Vec::new()); return Ok(Vec::new());
}; };
Ok(optional_string_array(object, "trustedRoots", "merged settings.trustedRoots")? Ok(
.unwrap_or_default()) 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> {

View File

@@ -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,
}; };

View File

@@ -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(),

View File

@@ -253,7 +253,6 @@ fn read_git_status(cwd: &Path) -> Option<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();
@@ -715,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"));

View File

@@ -1441,8 +1441,12 @@ mod tests {
/// Called by external consumers (e.g. clawhip) to enumerate sessions for a CWD. /// Called by external consumers (e.g. clawhip) to enumerate sessions for a CWD.
#[allow(dead_code)] #[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())
} }
@@ -1481,7 +1485,10 @@ mod workspace_sessions_dir_tests {
let dir_a = workspace_sessions_dir(&tmp_a).expect("dir a"); let dir_a = workspace_sessions_dir(&tmp_a).expect("dir a");
let dir_b = workspace_sessions_dir(&tmp_b).expect("dir b"); let dir_b = workspace_sessions_dir(&tmp_b).expect("dir b");
assert_ne!(dir_a, dir_b, "different CWDs must produce different session dirs"); 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_a).ok();
fs::remove_dir_all(&tmp_b).ok(); fs::remove_dir_all(&tmp_b).ok();

View File

@@ -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]

View File

@@ -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;
@@ -6391,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;
@@ -6400,9 +6457,7 @@ impl ApiClient for AnthropicRuntimeClient {
} }
} }
Err(RuntimeError::new( Err(RuntimeError::new("post-tool continuation nudge exhausted"))
"post-tool continuation nudge exhausted",
))
}) })
} }
} }
@@ -6416,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 {
@@ -6442,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(
@@ -6629,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, .. } => {
@@ -7604,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"
@@ -7669,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\""
@@ -7723,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::{
@@ -8586,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,
} }
@@ -9003,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,
@@ -9324,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>]"
)); ));
@@ -9417,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");
@@ -9437,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);
@@ -10803,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.

View File

@@ -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<_>>();

View File

@@ -1244,10 +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" => { "WorkerObserveCompletion" => from_value::<WorkerObserveCompletionInput>(input)
from_value::<WorkerObserveCompletionInput>(input) .and_then(run_worker_observe_completion),
.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),
@@ -1510,9 +1508,7 @@ fn run_worker_terminate(input: WorkerIdInput) -> Result<String, String> {
} }
#[allow(clippy::needless_pass_by_value)] #[allow(clippy::needless_pass_by_value)]
fn run_worker_observe_completion( fn run_worker_observe_completion(input: WorkerObserveCompletionInput) -> Result<String, String> {
input: WorkerObserveCompletionInput,
) -> Result<String, String> {
let worker = global_worker_registry().observe_completion( let worker = global_worker_registry().observe_completion(
&input.worker_id, &input.worker_id,
&input.finish_reason, &input.finish_reason,
@@ -3826,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;
@@ -5379,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,
@@ -5566,11 +5563,7 @@ mod tests {
// Use the actual OS temp dir so the worktree path matches the allowlist // 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 tmp_root = std::env::temp_dir().to_str().expect("utf-8").to_string();
let settings = format!("{{\"trustedRoots\": [\"{tmp_root}\"]}}"); let settings = format!("{{\"trustedRoots\": [\"{tmp_root}\"]}}");
fs::write( fs::write(claw_dir.join("settings.json"), settings).expect("write settings");
claw_dir.join("settings.json"),
settings,
)
.expect("write settings");
// WorkerCreate with no per-call trusted_roots — config should supply them // WorkerCreate with no per-call trusted_roots — config should supply them
let cwd = worktree.to_str().expect("valid utf-8").to_string(); let cwd = worktree.to_str().expect("valid utf-8").to_string();
@@ -5605,13 +5598,13 @@ mod tests {
let worker_id = output["worker_id"].as_str().expect("worker_id").to_string(); let worker_id = output["worker_id"].as_str().expect("worker_id").to_string();
// Terminate // Terminate
let terminated = execute_tool( let terminated = execute_tool("WorkerTerminate", &json!({"worker_id": worker_id}))
"WorkerTerminate", .expect("WorkerTerminate should succeed");
&json!({"worker_id": worker_id}),
)
.expect("WorkerTerminate should succeed");
let term_output: serde_json::Value = serde_json::from_str(&terminated).expect("json"); 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["status"], "finished",
"terminated worker should be finished"
);
assert_eq!( assert_eq!(
term_output["prompt_in_flight"], false, term_output["prompt_in_flight"], false,
"prompt_in_flight should be cleared on termination" "prompt_in_flight should be cleared on termination"
@@ -5637,11 +5630,8 @@ mod tests {
.expect("WorkerObserve should succeed"); .expect("WorkerObserve should succeed");
// Restart // Restart
let restarted = execute_tool( let restarted = execute_tool("WorkerRestart", &json!({"worker_id": worker_id}))
"WorkerRestart", .expect("WorkerRestart should succeed");
&json!({"worker_id": worker_id}),
)
.expect("WorkerRestart should succeed");
let restart_output: serde_json::Value = serde_json::from_str(&restarted).expect("json"); let restart_output: serde_json::Value = serde_json::from_str(&restarted).expect("json");
assert_eq!( assert_eq!(
restart_output["status"], "spawning", restart_output["status"], "spawning",
@@ -5667,11 +5657,8 @@ mod tests {
let created_output: serde_json::Value = serde_json::from_str(&created).expect("json"); 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 worker_id = created_output["worker_id"].as_str().expect("worker_id");
let fetched = execute_tool( let fetched = execute_tool("WorkerGet", &json!({"worker_id": worker_id}))
"WorkerGet", .expect("WorkerGet should succeed");
&json!({"worker_id": worker_id}),
)
.expect("WorkerGet should succeed");
let fetched_output: serde_json::Value = serde_json::from_str(&fetched).expect("json"); 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["worker_id"], worker_id);
assert_eq!(fetched_output["status"], "spawning"); assert_eq!(fetched_output["status"], "spawning");
@@ -5705,11 +5692,8 @@ mod tests {
let worker_id = created_output["worker_id"].as_str().expect("worker_id"); let worker_id = created_output["worker_id"].as_str().expect("worker_id");
// Worker is still in spawning — await_ready should return not-ready snapshot // Worker is still in spawning — await_ready should return not-ready snapshot
let snapshot = execute_tool( let snapshot = execute_tool("WorkerAwaitReady", &json!({"worker_id": worker_id}))
"WorkerAwaitReady", .expect("WorkerAwaitReady should succeed even when not ready");
&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"); let snap_output: serde_json::Value = serde_json::from_str(&snapshot).expect("json");
assert_eq!( assert_eq!(
snap_output["ready"], false, snap_output["ready"], false,
@@ -5751,21 +5735,27 @@ mod tests {
let state_path = worktree.join(".claw").join("worker-state.json"); let state_path = worktree.join(".claw").join("worker-state.json");
// 1. Create worker WITHOUT trusted_roots // 1. Create worker WITHOUT trusted_roots
let created = execute_tool( let created = execute_tool("WorkerCreate", &json!({"cwd": cwd}))
"WorkerCreate", .expect("WorkerCreate should succeed");
&json!({"cwd": cwd}),
)
.expect("WorkerCreate should succeed");
let created_output: serde_json::Value = serde_json::from_str(&created).expect("json"); 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(); let worker_id = created_output["worker_id"]
.as_str()
.expect("worker_id")
.to_string();
// State file should exist after create // State file should exist after create
assert!(state_path.exists(), "state file should be written after WorkerCreate"); assert!(
let state: serde_json::Value = serde_json::from_str( state_path.exists(),
&fs::read_to_string(&state_path).expect("read state") "state file should be written after WorkerCreate"
).expect("parse state"); );
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["status"], "spawning");
assert_eq!(state["is_ready"], false); assert_eq!(state["is_ready"], false);
assert!(state["seconds_since_update"].is_number(), "seconds_since_update must be present"); assert!(
state["seconds_since_update"].is_number(),
"seconds_since_update must be present"
);
// 2. Force trust_required via observe // 2. Force trust_required via observe
execute_tool( execute_tool(
@@ -5773,26 +5763,27 @@ mod tests {
&json!({"worker_id": worker_id, "screen_text": "Do you trust the files in this folder?"}), &json!({"worker_id": worker_id, "screen_text": "Do you trust the files in this folder?"}),
) )
.expect("WorkerObserve should succeed"); .expect("WorkerObserve should succeed");
let state: serde_json::Value = serde_json::from_str( let state: serde_json::Value =
&fs::read_to_string(&state_path).expect("read state") serde_json::from_str(&fs::read_to_string(&state_path).expect("read state"))
).expect("parse state"); .expect("parse state");
assert_eq!(state["status"], "trust_required", assert_eq!(
"state file must reflect trust_required stall"); state["status"], "trust_required",
"state file must reflect trust_required stall"
);
assert_eq!(state["is_ready"], false); assert_eq!(state["is_ready"], false);
assert_eq!(state["trust_gate_cleared"], false); assert_eq!(state["trust_gate_cleared"], false);
assert!(state["seconds_since_update"].is_number()); assert!(state["seconds_since_update"].is_number());
// 3. WorkerResolveTrust -> state file reflects recovery // 3. WorkerResolveTrust -> state file reflects recovery
execute_tool( execute_tool("WorkerResolveTrust", &json!({"worker_id": worker_id}))
"WorkerResolveTrust", .expect("WorkerResolveTrust should succeed");
&json!({"worker_id": worker_id}), let state: serde_json::Value =
) serde_json::from_str(&fs::read_to_string(&state_path).expect("read state"))
.expect("WorkerResolveTrust should succeed"); .expect("parse state");
let state: serde_json::Value = serde_json::from_str( assert_eq!(
&fs::read_to_string(&state_path).expect("read state") state["status"], "spawning",
).expect("parse state"); "state file must show spawning after trust resolved"
assert_eq!(state["status"], "spawning", );
"state file must show spawning after trust resolved");
assert_eq!(state["trust_gate_cleared"], true); assert_eq!(state["trust_gate_cleared"], true);
// 4. Observe ready screen -> state file shows ready_for_prompt // 4. Observe ready screen -> state file shows ready_for_prompt
@@ -5801,13 +5792,17 @@ mod tests {
&json!({"worker_id": worker_id, "screen_text": "Ready for input\n>"}), &json!({"worker_id": worker_id, "screen_text": "Ready for input\n>"}),
) )
.expect("WorkerObserve ready should succeed"); .expect("WorkerObserve ready should succeed");
let state: serde_json::Value = serde_json::from_str( let state: serde_json::Value =
&fs::read_to_string(&state_path).expect("read state") serde_json::from_str(&fs::read_to_string(&state_path).expect("read state"))
).expect("parse state"); .expect("parse state");
assert_eq!(state["status"], "ready_for_prompt", assert_eq!(
"state file must show ready_for_prompt after ready screen"); state["status"], "ready_for_prompt",
assert_eq!(state["is_ready"], true, "state file must show ready_for_prompt after ready screen"
"is_ready must be true in state file at ready_for_prompt"); );
assert_eq!(
state["is_ready"], true,
"is_ready must be true in state file at ready_for_prompt"
);
fs::remove_dir_all(&worktree).ok(); fs::remove_dir_all(&worktree).ok();
} }
@@ -5815,13 +5810,13 @@ mod tests {
#[test] #[test]
fn stall_detect_and_resolve_trust_end_to_end() { fn stall_detect_and_resolve_trust_end_to_end() {
// 1. Create worker WITHOUT trusted_roots so trust won't auto-resolve // 1. Create worker WITHOUT trusted_roots so trust won't auto-resolve
let created = execute_tool( let created = execute_tool("WorkerCreate", &json!({"cwd": "/no/trusted/root/here"}))
"WorkerCreate", .expect("WorkerCreate should succeed");
&json!({"cwd": "/no/trusted/root/here"}),
)
.expect("WorkerCreate should succeed");
let created_output: serde_json::Value = serde_json::from_str(&created).expect("json"); 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(); let worker_id = created_output["worker_id"]
.as_str()
.expect("worker_id")
.to_string();
assert_eq!(created_output["trust_auto_resolve"], false); assert_eq!(created_output["trust_auto_resolve"], false);
// 2. Observe trust prompt screen text -> worker stalls at trust_required // 2. Observe trust prompt screen text -> worker stalls at trust_required
@@ -5840,11 +5835,8 @@ mod tests {
); );
assert_eq!(stalled_output["trust_gate_cleared"], false); assert_eq!(stalled_output["trust_gate_cleared"], false);
// 3. Clawhip calls WorkerResolveTrust to unblock // 3. Clawhip calls WorkerResolveTrust to unblock
let resolved = execute_tool( let resolved = execute_tool("WorkerResolveTrust", &json!({"worker_id": worker_id}))
"WorkerResolveTrust", .expect("WorkerResolveTrust should succeed");
&json!({"worker_id": worker_id}),
)
.expect("WorkerResolveTrust should succeed");
let resolved_output: serde_json::Value = serde_json::from_str(&resolved).expect("json"); let resolved_output: serde_json::Value = serde_json::from_str(&resolved).expect("json");
assert_eq!( assert_eq!(
resolved_output["status"], "spawning", resolved_output["status"], "spawning",
@@ -5877,7 +5869,10 @@ mod tests {
) )
.expect("WorkerCreate should succeed"); .expect("WorkerCreate should succeed");
let created_output: serde_json::Value = serde_json::from_str(&created).expect("json"); 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(); let worker_id = created_output["worker_id"]
.as_str()
.expect("worker_id")
.to_string();
// Force trust_required // Force trust_required
let stalled = execute_tool( let stalled = execute_tool(
@@ -5892,17 +5887,15 @@ mod tests {
assert_eq!(stalled_output["status"], "trust_required"); assert_eq!(stalled_output["status"], "trust_required");
// WorkerRestart resets the worker // WorkerRestart resets the worker
let restarted = execute_tool( let restarted = execute_tool("WorkerRestart", &json!({"worker_id": worker_id}))
"WorkerRestart", .expect("WorkerRestart should succeed");
&json!({"worker_id": worker_id}),
)
.expect("WorkerRestart should succeed");
let restarted_output: serde_json::Value = serde_json::from_str(&restarted).expect("json"); let restarted_output: serde_json::Value = serde_json::from_str(&restarted).expect("json");
assert_eq!( assert_eq!(
restarted_output["status"], "spawning", restarted_output["status"], "spawning",
"restarted worker should be back at spawning" "restarted worker should be back at spawning"
); );
assert_eq!(restarted_output["trust_gate_cleared"], false, assert_eq!(
restarted_output["trust_gate_cleared"], false,
"restart clears trust — next observe loop must re-acquire trust" "restart clears trust — next observe loop must re-acquire trust"
); );
} }