Compare commits

...

16 Commits

Author SHA1 Message Date
YeonGyu-Kim 861edfc1dc fix(runtime): document phantom completion root cause + add workspace_root to session (#41)
Global session store causes cross-worktree confusion in parallel lanes.
Added workspace_root field to session metadata and documented root cause
in ROADMAP.md.
2026-04-07 14:22:41 +09:00
YeonGyu-Kim f982f24926 fix(api): Windows env hint + .env file loading fallback
When API key missing on Windows, hint about setx. Load .env from CWD
as fallback with simple key=value parser.
2026-04-07 14:22:41 +09:00
YeonGyu-Kim 8d866073c5 feat(cli): show active model and provider in startup banner
Prints 'Connected: <model> via <provider>' before REPL prompt.
2026-04-07 14:22:26 +09:00
YeonGyu-Kim 4251c85855 fix(cli): add section headers to OMC output for agent type grouping
voloshko: flat wall of text. Now groups output with section separators
by agent type (Explore, Implementation, Verification).
2026-04-07 14:22:06 +09:00
YeonGyu-Kim 2a642871ad fix(api): enrich JSON parse errors with response body, provider, and model
Raw 'json_error: no field X' now includes truncated response body,
provider name, and model ID for debugging context.
2026-04-07 14:22:05 +09:00
YeonGyu-Kim cd83c0ff68 fix(cli): detect OPENAI_BASE_URL during claw login and emit clear error
OAuth 401 was confusing. Now detects custom base URL and suggests
ANTHROPIC_API_KEY instead of OAuth login.
2026-04-07 14:22:05 +09:00
YeonGyu-Kim ce360e0ff3 fix(api): strip anthropic beta fields from non-beta requests
mikejiang: 'betas: Extra inputs are not permitted' 400 error.
Only include beta headers when request targets beta endpoint.
2026-04-07 14:22:05 +09:00
YeonGyu-Kim c980c3c01e docs: add local model quickstart section to USAGE.md
- Anthropic-compat (ANTHROPIC_BASE_URL + ANTHROPIC_AUTH_TOKEN)
- OpenAI-compat (OPENAI_BASE_URL + OPENAI_API_KEY)
- ollama example with concrete curl
- OpenRouter example with model selection

Addresses community requests for local model setup guidance.
2026-04-07 13:44:22 +09:00
YeonGyu-Kim ce22d8fb4f fix(api): add serde(default) to all usage/token parse paths in SSE stream
Sterling reported 'json_error: no field input/input_tokens' still firing
despite existing serde(default) in types.rs. Root cause: SSE streaming
path had a separate deserialization site that didn't use the same defaults.

- Add serde(default) to sse.rs UsageEvent deserialization
- Add serde(default) to types.rs Usage struct fields (input_tokens, output_tokens)
- Add regression test with empty-usage JSON response in streaming context
2026-04-07 13:44:22 +09:00
Yeachan-Heo be561bfdeb Use Anthropic count tokens for preflight 2026-04-06 09:38:21 +00:00
Yeachan-Heo c1883d0f66 Clarify heuristic context window estimates 2026-04-06 09:26:08 +00:00
Yeachan-Heo 1fc5a1c457 Fix slash skill invoke normalization 2026-04-06 09:24:06 +00:00
Yeachan-Heo 549ad7c3af Restore compatibility skill lookup fallback 2026-04-06 09:11:27 +00:00
Yeachan-Heo ecadc5554a fix(auth): harden OAuth fallback and collapse thinking output 2026-04-06 09:02:21 +00:00
Yeachan-Heo 8ff9c1b15a Preserve recovery guidance for retried context-window failures
The CLI already reframes direct preflight and provider oversized-request
errors, but retry-wrapped provider failures still fell back to the generic
retry-exhausted surface because the user-visible formatter keyed off the
safe failure class. Route formatting through nested context-window
detection so wrapped provider failures keep the same compact/reduce-scope
guidance.

Constraint: Keep the fix UX-scoped without widening broader failure classification behavior
Rejected: Reorder safe_failure_class for all RetriesExhausted errors | broader semantic change than needed for this issue
Confidence: high
Scope-risk: narrow
Directive: Keep context-window rendering keyed to nested error inspection so provider wrappers do not lose recovery guidance
Tested: cargo fmt --check; cargo test -p rusty-claude-cli context_window; cargo test -p api oversized
Not-tested: Full workspace test suite
2026-04-06 09:02:21 +00:00
Yeachan-Heo 6bd464bbe7 Make repeated provider crashes self-identifying after retry exhaustion
Generic fatal wrapper handling already preserved safe classes and trace ids for single provider failures, but repeated retry exhaustion still surfaced as provider_internal. Classify generic wrapped RetriesExhausted failures as provider_retry_exhausted so Jobdori-style repeat failures stay distinguishable from one-off provider crashes, and keep the display logic clippy-clean.

Constraint: Keep the change minimal and preserve existing user-visible error wording outside retry-exhaustion classification
Rejected: Broadly rework all provider error taxonomy | unnecessary for the targeted opaque-wrapper regression
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep retry exhaustion distinct from single-shot provider_internal wrappers when the nested error is the same generic fatal wrapper
Tested: cargo test -p api detects_generic_fatal_wrapper_and_classifies_it_as_provider_internal
Tested: cargo test -p api retries_exhausted_preserves_nested_request_id_and_failure_class
Tested: cargo test -p rusty-claude-cli opaque_provider_wrapper_surfaces_failure_class_session_and_trace
Tested: cargo test -p rusty-claude-cli retry_exhaustion_uses_retry_failure_class_for_generic_provider_wrapper
Tested: cargo test --workspace
Tested: cargo fmt --check
Tested: cargo clippy --workspace --all-targets -- -D warnings
Not-tested: Live OpenClaw/Anthropic service failure telemetry outside the local test harness
2026-04-06 09:01:38 +00:00
13 changed files with 1446 additions and 100 deletions
+17
View File
@@ -312,6 +312,23 @@ Priority order: P0 = blocks CI/green state, P1 = blocks integration wiring, P2 =
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.
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`.
41. **Phantom completions root cause: global session store has no per-worktree isolation**
**Root cause.** The session store under `~/.local/share/opencode` is global to the host. Every `opencode serve` instance — including the parallel lane workers spawned per worktree — reads and writes the same on-disk session directory. Sessions are keyed only by id and timestamp, not by the workspace they were created in, so there is no structural barrier between a session created in worktree `/tmp/b4-phantom-diag` and one created in `/tmp/b4-omc-flat`. Whichever serve instance picks up a given session id can drive it from whatever CWD that serve happens to be running in.
**Impact.** Parallel lanes silently cross wires. A lane reports a clean run — file edits, builds, tests — and the orchestrator marks the lane green, but the writes were applied against another worktree's CWD because a sibling `opencode serve` won the session race. The originating worktree shows no diff, the *other* worktree gains unexplained edits, and downstream consumers (clawhip lane events, PR pushes, merge gates) treat the empty originator as a successful no-op. These are the "phantom completions" we keep chasing: success messaging without any landed changes in the lane that claimed them, plus stray edits in unrelated lanes whose own runs never touched those files. Because the report path is happy, retries and recovery recipes never fire, so the lane silently wedges until a human notices the diff is empty.
**Proposed fix.** Bind every session to its workspace root + branch at creation time and refuse to drive it from any other CWD.
- At session creation, capture the canonical workspace root (resolved git worktree path) and the active branch and persist them on the session record.
- On every load (`opencode serve`, slash-command resume, lane recovery), validate that the current process CWD matches the persisted workspace root before any tool with side effects (file_ops, bash, git) is allowed to run. Mismatches surface as a typed `WorkspaceMismatch` failure class instead of silently writing to the wrong tree.
- Namespace the on-disk session path under the workspace fingerprint (e.g. `<session_store>/<workspace_hash>/<session_id>`) so two parallel `opencode serve` instances physically cannot collide on the same session id.
- Forks inherit the parent's workspace root by default; an explicit re-bind is required to move a session to a new worktree, and that re-bind is itself recorded as a structured event so the orchestrator can audit cross-worktree handoffs.
- Surface a `branch.workspace_mismatch` lane event so clawhip stops counting wrong-CWD writes as lane completions.
**Status.** A `workspace_root` field has been added to `Session` in `rust/crates/runtime/src/session.rs` (with builder, accessor, JSON + JSONL round-trip, fork inheritance, and given/when/then test coverage in `persists_workspace_root_round_trip_and_forks_inherit_it`). The CWD validation, the namespaced on-disk path, and the `branch.workspace_mismatch` lane event are still outstanding and tracked under this item.
**P3 — Swarm efficiency**
13. Swarm branch-lock protocol — **done**: `branch_lock::detect_branch_lock_collisions()` now detects same-branch/same-scope and nested-module collisions before parallel lanes drift into duplicate implementation
14. Commit provenance / worktree-aware push events — **done**: lane event provenance now includes branch/worktree/superseded/canonical lineage metadata, and manifest persistence de-dupes superseded commit events before downstream consumers render them
+44
View File
@@ -109,6 +109,50 @@ cd rust
./target/debug/claw logout
```
## Local Models
`claw` can talk to local servers and provider gateways through either Anthropic-compatible or OpenAI-compatible endpoints. Use `ANTHROPIC_BASE_URL` with `ANTHROPIC_AUTH_TOKEN` for Anthropic-compatible services, or `OPENAI_BASE_URL` with `OPENAI_API_KEY` for OpenAI-compatible services. OAuth is Anthropic-only, so when `OPENAI_BASE_URL` is set you should use API-key style auth instead of `claw login`.
### Anthropic-compatible endpoint
```bash
export ANTHROPIC_BASE_URL="http://127.0.0.1:8080"
export ANTHROPIC_AUTH_TOKEN="local-dev-token"
cd rust
./target/debug/claw --model "claude-sonnet-4-6" prompt "reply with the word ready"
```
### OpenAI-compatible endpoint
```bash
export OPENAI_BASE_URL="http://127.0.0.1:8000/v1"
export OPENAI_API_KEY="local-dev-token"
cd rust
./target/debug/claw --model "qwen2.5-coder" prompt "reply with the word ready"
```
### Ollama
```bash
export OPENAI_BASE_URL="http://127.0.0.1:11434/v1"
unset OPENAI_API_KEY
cd rust
./target/debug/claw --model "llama3.2" prompt "summarize this repository in one sentence"
```
### OpenRouter
```bash
export OPENAI_BASE_URL="https://openrouter.ai/api/v1"
export OPENAI_API_KEY="sk-or-v1-..."
cd rust
./target/debug/claw --model "openai/gpt-4.1-mini" prompt "summarize this repository in one sentence"
```
## Common operational commands
```bash
+152 -15
View File
@@ -35,7 +35,12 @@ pub enum ApiError {
InvalidApiKeyEnv(VarError),
Http(reqwest::Error),
Io(std::io::Error),
Json(serde_json::Error),
Json {
provider: String,
model: String,
body_snippet: String,
source: serde_json::Error,
},
Api {
status: reqwest::StatusCode,
error_type: Option<String>,
@@ -64,6 +69,25 @@ impl ApiError {
Self::MissingCredentials { provider, env_vars }
}
/// Build a `Self::Json` enriched with the provider name, the model that
/// was requested, and the first 200 characters of the raw response body so
/// that callers can diagnose deserialization failures without re-running
/// the request.
#[must_use]
pub fn json_deserialize(
provider: impl Into<String>,
model: impl Into<String>,
body: &str,
source: serde_json::Error,
) -> Self {
Self::Json {
provider: provider.into(),
model: model.into(),
body_snippet: truncate_body_snippet(body, 200),
source,
}
}
#[must_use]
pub fn is_retryable(&self) -> bool {
match self {
@@ -76,7 +100,7 @@ impl ApiError {
| Self::Auth(_)
| Self::InvalidApiKeyEnv(_)
| Self::Io(_)
| Self::Json(_)
| Self::Json { .. }
| Self::InvalidSseFrame(_)
| Self::BackoffOverflow { .. } => false,
}
@@ -94,7 +118,7 @@ impl ApiError {
| Self::InvalidApiKeyEnv(_)
| Self::Http(_)
| Self::Io(_)
| Self::Json(_)
| Self::Json { .. }
| Self::InvalidSseFrame(_)
| Self::BackoffOverflow { .. } => None,
}
@@ -103,7 +127,11 @@ impl ApiError {
#[must_use]
pub fn safe_failure_class(&self) -> &'static str {
match self {
Self::RetriesExhausted { .. } => "provider_retry_exhausted",
Self::RetriesExhausted { .. } if self.is_context_window_failure() => "context_window",
Self::RetriesExhausted { .. } if self.is_generic_fatal_wrapper() => {
"provider_retry_exhausted"
}
Self::RetriesExhausted { last_error, .. } => last_error.safe_failure_class(),
Self::MissingCredentials { .. } | Self::ExpiredOAuthToken | Self::Auth(_) => {
"provider_auth"
}
@@ -116,7 +144,7 @@ impl ApiError {
Self::Http(_) | Self::InvalidSseFrame(_) | Self::BackoffOverflow { .. } => {
"provider_transport"
}
Self::InvalidApiKeyEnv(_) | Self::Io(_) | Self::Json(_) => "runtime_io",
Self::InvalidApiKeyEnv(_) | Self::Io(_) | Self::Json { .. } => "runtime_io",
}
}
@@ -137,7 +165,7 @@ impl ApiError {
| Self::InvalidApiKeyEnv(_)
| Self::Http(_)
| Self::Io(_)
| Self::Json(_)
| Self::Json { .. }
| Self::InvalidSseFrame(_)
| Self::BackoffOverflow { .. } => false,
}
@@ -166,7 +194,7 @@ impl ApiError {
| Self::InvalidApiKeyEnv(_)
| Self::Http(_)
| Self::Io(_)
| Self::Json(_)
| Self::Json { .. }
| Self::InvalidSseFrame(_)
| Self::BackoffOverflow { .. } => false,
}
@@ -176,11 +204,27 @@ impl ApiError {
impl Display for ApiError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::MissingCredentials { provider, env_vars } => write!(
f,
"missing {provider} credentials; export {} before calling the {provider} API",
env_vars.join(" or ")
),
Self::MissingCredentials { provider, env_vars } => {
write!(
f,
"missing {provider} credentials; export {} before calling the {provider} API",
env_vars.join(" or ")
)?;
if cfg!(target_os = "windows") {
if let Some(primary) = env_vars.first() {
write!(
f,
" (on Windows, environment variables set in PowerShell only persist for the current session; use `setx {primary} <value>` to make it permanent, then open a new terminal, or place a `.env` file containing `{primary}=<value>` in the current working directory)"
)?;
} else {
write!(
f,
" (on Windows, environment variables set in PowerShell only persist for the current session; use `setx` to make them permanent, then open a new terminal, or place a `.env` file in the current working directory)"
)?;
}
}
Ok(())
}
Self::ContextWindowExceeded {
model,
estimated_input_tokens,
@@ -203,7 +247,15 @@ impl Display for ApiError {
}
Self::Http(error) => write!(f, "http error: {error}"),
Self::Io(error) => write!(f, "io error: {error}"),
Self::Json(error) => write!(f, "json error: {error}"),
Self::Json {
provider,
model,
body_snippet,
source,
} => write!(
f,
"failed to parse {provider} response for model {model}: {source}; first 200 chars of body: {body_snippet}"
),
Self::Api {
status,
error_type,
@@ -258,7 +310,12 @@ impl From<std::io::Error> for ApiError {
impl From<serde_json::Error> for ApiError {
fn from(value: serde_json::Error) -> Self {
Self::Json(value)
Self::Json {
provider: "unknown".to_string(),
model: "unknown".to_string(),
body_snippet: String::new(),
source: value,
}
}
}
@@ -282,9 +339,89 @@ fn looks_like_context_window_error(text: &str) -> bool {
.any(|marker| lowered.contains(marker))
}
/// Truncate `body` so the resulting snippet contains at most `max_chars`
/// characters (counted by Unicode scalar values, not bytes), preserving the
/// leading slice of the body that the caller most often needs to inspect.
fn truncate_body_snippet(body: &str, max_chars: usize) -> String {
let mut taken_chars = 0;
let mut byte_end = 0;
for (offset, character) in body.char_indices() {
if taken_chars >= max_chars {
break;
}
taken_chars += 1;
byte_end = offset + character.len_utf8();
}
if taken_chars >= max_chars && byte_end < body.len() {
format!("{}", &body[..byte_end])
} else {
body[..byte_end].to_string()
}
}
#[cfg(test)]
mod tests {
use super::ApiError;
use super::{truncate_body_snippet, ApiError};
#[test]
fn json_deserialize_error_includes_provider_model_and_truncated_body_snippet() {
let raw_body = format!("{}{}", "x".repeat(190), "_TAIL_PAST_200_CHARS_MARKER_");
let source = serde_json::from_str::<serde_json::Value>("{not json")
.expect_err("invalid json should fail to parse");
let error = ApiError::json_deserialize("Anthropic", "claude-opus-4-6", &raw_body, source);
let rendered = error.to_string();
assert!(
rendered.starts_with("failed to parse Anthropic response for model claude-opus-4-6: "),
"rendered error should lead with provider and model: {rendered}"
);
assert!(
rendered.contains("first 200 chars of body: "),
"rendered error should label the body snippet: {rendered}"
);
let snippet = rendered
.split("first 200 chars of body: ")
.nth(1)
.expect("snippet section should be present");
assert!(
snippet.starts_with(&"x".repeat(190)),
"snippet should preserve the leading characters of the body: {snippet}"
);
assert!(
snippet.ends_with('…'),
"snippet should signal truncation with an ellipsis: {snippet}"
);
assert!(
!snippet.contains("_TAIL_PAST_200_CHARS_MARKER_"),
"snippet should drop characters past the 200-char cap: {snippet}"
);
assert_eq!(error.safe_failure_class(), "runtime_io");
assert_eq!(error.request_id(), None);
assert!(!error.is_retryable());
}
#[test]
fn truncate_body_snippet_keeps_short_bodies_intact() {
assert_eq!(truncate_body_snippet("hello", 200), "hello");
assert_eq!(truncate_body_snippet("", 200), "");
}
#[test]
fn truncate_body_snippet_caps_long_bodies_at_max_chars() {
let body = "a".repeat(250);
let snippet = truncate_body_snippet(&body, 200);
assert_eq!(snippet.chars().count(), 201, "200 chars + ellipsis");
assert!(snippet.ends_with('…'));
assert!(snippet.starts_with(&"a".repeat(200)));
}
#[test]
fn truncate_body_snippet_does_not_split_multibyte_characters() {
let body = "한글한글한글한글한글한글";
let snippet = truncate_body_snippet(body, 4);
assert_eq!(snippet, "한글한글…");
}
#[test]
fn detects_generic_fatal_wrapper_and_classifies_it_as_provider_internal() {
+158 -23
View File
@@ -14,7 +14,7 @@ use telemetry::{AnalyticsEvent, AnthropicRequestProfile, ClientIdentity, Session
use crate::error::ApiError;
use crate::prompt_cache::{PromptCache, PromptCacheRecord, PromptCacheStats};
use super::{preflight_message_request, Provider, ProviderFuture};
use super::{model_token_limit, resolve_model_alias, Provider, ProviderFuture};
use crate::sse::SseParser;
use crate::types::{MessageDeltaEvent, MessageRequest, MessageResponse, StreamEvent, Usage};
@@ -294,14 +294,14 @@ impl AnthropicClient {
}
}
preflight_message_request(&request)?;
self.preflight_message_request(&request).await?;
let response = self.send_with_retry(&request).await?;
let request_id = request_id_from_headers(response.headers());
let mut response = response
.json::<MessageResponse>()
.await
.map_err(ApiError::from)?;
let http_response = self.send_with_retry(&request).await?;
let request_id = request_id_from_headers(http_response.headers());
let body = http_response.text().await.map_err(ApiError::from)?;
let mut response = serde_json::from_str::<MessageResponse>(&body).map_err(|error| {
ApiError::json_deserialize("Anthropic", &request.model, &body, error)
})?;
if response.request_id.is_none() {
response.request_id = request_id;
}
@@ -339,14 +339,14 @@ impl AnthropicClient {
&self,
request: &MessageRequest,
) -> Result<MessageStream, ApiError> {
preflight_message_request(request)?;
self.preflight_message_request(request).await?;
let response = self
.send_with_retry(&request.clone().with_streaming())
.await?;
Ok(MessageStream {
request_id: request_id_from_headers(response.headers()),
response,
parser: SseParser::new(),
parser: SseParser::new().with_context("Anthropic", request.model.clone()),
pending: VecDeque::new(),
done: false,
request: request.clone(),
@@ -371,10 +371,10 @@ impl AnthropicClient {
.await
.map_err(ApiError::from)?;
let response = expect_success(response).await?;
response
.json::<OAuthTokenSet>()
.await
.map_err(ApiError::from)
let body = response.text().await.map_err(ApiError::from)?;
serde_json::from_str::<OAuthTokenSet>(&body).map_err(|error| {
ApiError::json_deserialize("Anthropic OAuth (exchange)", "n/a", &body, error)
})
}
pub async fn refresh_oauth_token(
@@ -391,10 +391,10 @@ impl AnthropicClient {
.await
.map_err(ApiError::from)?;
let response = expect_success(response).await?;
response
.json::<OAuthTokenSet>()
.await
.map_err(ApiError::from)
let body = response.text().await.map_err(ApiError::from)?;
serde_json::from_str::<OAuthTokenSet>(&body).map_err(|error| {
ApiError::json_deserialize("Anthropic OAuth (refresh)", "n/a", &body, error)
})
}
async fn send_with_retry(
@@ -466,18 +466,74 @@ impl AnthropicClient {
request: &MessageRequest,
) -> Result<reqwest::Response, ApiError> {
let request_url = format!("{}/v1/messages", self.base_url.trim_end_matches('/'));
let mut request_body = self.request_profile.render_json_body(request)?;
strip_unsupported_beta_body_fields(&mut request_body);
let request_builder = self.build_request(&request_url).json(&request_body);
request_builder.send().await.map_err(ApiError::from)
}
fn build_request(&self, request_url: &str) -> reqwest::RequestBuilder {
let request_builder = self
.http
.post(&request_url)
.post(request_url)
.header("content-type", "application/json");
let mut request_builder = self.auth.apply(request_builder);
for (header_name, header_value) in self.request_profile.header_pairs() {
request_builder = request_builder.header(header_name, header_value);
}
request_builder
}
let request_body = self.request_profile.render_json_body(request)?;
request_builder = request_builder.json(&request_body);
request_builder.send().await.map_err(ApiError::from)
async fn preflight_message_request(&self, request: &MessageRequest) -> Result<(), ApiError> {
let Some(limit) = model_token_limit(&request.model) else {
return Ok(());
};
let counted_input_tokens = match self.count_tokens(request).await {
Ok(count) => count,
Err(_) => return Ok(()),
};
let estimated_total_tokens = counted_input_tokens.saturating_add(request.max_tokens);
if estimated_total_tokens > limit.context_window_tokens {
return Err(ApiError::ContextWindowExceeded {
model: resolve_model_alias(&request.model),
estimated_input_tokens: counted_input_tokens,
requested_output_tokens: request.max_tokens,
estimated_total_tokens,
context_window_tokens: limit.context_window_tokens,
});
}
Ok(())
}
async fn count_tokens(&self, request: &MessageRequest) -> Result<u32, ApiError> {
#[derive(serde::Deserialize)]
struct CountTokensResponse {
input_tokens: u32,
}
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)?;
strip_unsupported_beta_body_fields(&mut request_body);
let response = self
.build_request(&request_url)
.json(&request_body)
.send()
.await
.map_err(ApiError::from)?;
let response = expect_success(response).await?;
let body = response.text().await.map_err(ApiError::from)?;
let parsed = serde_json::from_str::<CountTokensResponse>(&body).map_err(|error| {
ApiError::json_deserialize(
"Anthropic count_tokens",
&request.model,
&body,
error,
)
})?;
Ok(parsed.input_tokens)
}
fn record_request_failure(&self, attempt: u32, error: &ApiError) {
@@ -676,7 +732,7 @@ fn now_unix_timestamp() -> u64 {
fn read_env_non_empty(key: &str) -> Result<Option<String>, ApiError> {
match std::env::var(key) {
Ok(value) if !value.is_empty() => Ok(Some(value)),
Ok(_) | Err(std::env::VarError::NotPresent) => Ok(None),
Ok(_) | Err(std::env::VarError::NotPresent) => Ok(super::dotenv_value(key)),
Err(error) => Err(ApiError::from(error)),
}
}
@@ -831,6 +887,16 @@ const fn is_retryable_status(status: reqwest::StatusCode) -> bool {
matches!(status.as_u16(), 408 | 409 | 429 | 500 | 502 | 503 | 504)
}
/// Remove beta-only body fields that the standard `/v1/messages` and
/// `/v1/messages/count_tokens` endpoints reject as `Extra inputs are not
/// permitted`. The `betas` opt-in is communicated via the `anthropic-beta`
/// HTTP header on these endpoints, never as a JSON body field.
fn strip_unsupported_beta_body_fields(body: &mut Value) {
if let Some(object) = body.as_object_mut() {
object.remove("betas");
}
}
#[derive(Debug, Deserialize)]
struct AnthropicErrorEnvelope {
error: AnthropicErrorBody,
@@ -1247,4 +1313,73 @@ mod tests {
Some("Bearer proxy-token")
);
}
#[test]
fn strip_unsupported_beta_body_fields_removes_betas_array() {
let mut body = serde_json::json!({
"model": "claude-sonnet-4-6",
"max_tokens": 1024,
"betas": ["claude-code-20250219", "prompt-caching-scope-2026-01-05"],
"metadata": {"source": "test"},
});
super::strip_unsupported_beta_body_fields(&mut body);
assert!(
body.get("betas").is_none(),
"betas body field must be stripped before sending to /v1/messages"
);
assert_eq!(
body.get("model").and_then(serde_json::Value::as_str),
Some("claude-sonnet-4-6")
);
assert_eq!(body["max_tokens"], serde_json::json!(1024));
assert_eq!(body["metadata"]["source"], serde_json::json!("test"));
}
#[test]
fn strip_unsupported_beta_body_fields_is_a_noop_when_betas_absent() {
let mut body = serde_json::json!({
"model": "claude-sonnet-4-6",
"max_tokens": 1024,
});
let original = body.clone();
super::strip_unsupported_beta_body_fields(&mut body);
assert_eq!(body, original);
}
#[test]
fn rendered_request_body_strips_betas_for_standard_messages_endpoint() {
let client = AnthropicClient::new("test-key").with_beta("tools-2026-04-01");
let request = MessageRequest {
model: "claude-sonnet-4-6".to_string(),
max_tokens: 64,
messages: vec![],
system: None,
tools: None,
tool_choice: None,
stream: false,
};
let mut rendered = client
.request_profile()
.render_json_body(&request)
.expect("body should render");
assert!(
rendered.get("betas").is_some(),
"render_json_body still emits betas; the strip helper guards the wire format",
);
super::strip_unsupported_beta_body_fields(&mut rendered);
assert!(
rendered.get("betas").is_none(),
"betas must not appear in /v1/messages request bodies"
);
assert_eq!(
rendered.get("model").and_then(serde_json::Value::as_str),
Some("claude-sonnet-4-6")
);
}
}
+138 -2
View File
@@ -258,6 +258,61 @@ fn estimate_serialized_tokens<T: Serialize>(value: &T) -> u32 {
.map_or(0, |bytes| (bytes.len() / 4 + 1) as u32)
}
/// Parse a `.env` file body into key/value pairs using a minimal `KEY=VALUE`
/// grammar. Lines that are blank, start with `#`, or do not contain `=` are
/// ignored. Surrounding double or single quotes are stripped from the value.
/// An optional leading `export ` prefix on the key is also stripped so files
/// shared with shell `source` workflows still parse cleanly.
pub(crate) fn parse_dotenv(content: &str) -> std::collections::HashMap<String, String> {
let mut values = std::collections::HashMap::new();
for raw_line in content.lines() {
let line = raw_line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((raw_key, raw_value)) = line.split_once('=') else {
continue;
};
let trimmed_key = raw_key.trim();
let key = trimmed_key
.strip_prefix("export ")
.map_or(trimmed_key, str::trim)
.to_string();
if key.is_empty() {
continue;
}
let trimmed_value = raw_value.trim();
let unquoted = if (trimmed_value.starts_with('"') && trimmed_value.ends_with('"')
|| trimmed_value.starts_with('\'') && trimmed_value.ends_with('\''))
&& trimmed_value.len() >= 2
{
&trimmed_value[1..trimmed_value.len() - 1]
} else {
trimmed_value
};
values.insert(key, unquoted.to_string());
}
values
}
/// Load and parse a `.env` file from the given path. Missing files yield
/// `None` instead of an error so callers can use this as a soft fallback.
pub(crate) fn load_dotenv_file(
path: &std::path::Path,
) -> Option<std::collections::HashMap<String, String>> {
let content = std::fs::read_to_string(path).ok()?;
Some(parse_dotenv(&content))
}
/// Look up `key` in a `.env` file located in the current working directory.
/// Returns `None` when the file is missing, the key is absent, or the value
/// is empty.
pub(crate) fn dotenv_value(key: &str) -> Option<String> {
let cwd = std::env::current_dir().ok()?;
let values = load_dotenv_file(&cwd.join(".env"))?;
values.get(key).filter(|value| !value.is_empty()).cloned()
}
#[cfg(test)]
mod tests {
use serde_json::json;
@@ -268,8 +323,8 @@ mod tests {
};
use super::{
detect_provider_kind, max_tokens_for_model, model_token_limit, preflight_message_request,
resolve_model_alias, ProviderKind,
detect_provider_kind, load_dotenv_file, max_tokens_for_model, model_token_limit,
parse_dotenv, preflight_message_request, resolve_model_alias, ProviderKind,
};
#[test]
@@ -375,4 +430,85 @@ mod tests {
preflight_message_request(&request)
.expect("models without context metadata should skip the guarded preflight");
}
#[test]
fn parse_dotenv_extracts_keys_handles_comments_quotes_and_export_prefix() {
// given
let body = "\
# this is a comment
ANTHROPIC_API_KEY=plain-value
XAI_API_KEY=\"quoted-value\"
OPENAI_API_KEY='single-quoted'
export GROK_API_KEY=exported-value
PADDED_KEY = padded-value
EMPTY_VALUE=
NO_EQUALS_LINE
";
// when
let values = parse_dotenv(body);
// then
assert_eq!(
values.get("ANTHROPIC_API_KEY").map(String::as_str),
Some("plain-value")
);
assert_eq!(
values.get("XAI_API_KEY").map(String::as_str),
Some("quoted-value")
);
assert_eq!(
values.get("OPENAI_API_KEY").map(String::as_str),
Some("single-quoted")
);
assert_eq!(
values.get("GROK_API_KEY").map(String::as_str),
Some("exported-value")
);
assert_eq!(
values.get("PADDED_KEY").map(String::as_str),
Some("padded-value")
);
assert_eq!(values.get("EMPTY_VALUE").map(String::as_str), Some(""));
assert!(!values.contains_key("NO_EQUALS_LINE"));
assert!(!values.contains_key("# this is a comment"));
}
#[test]
fn load_dotenv_file_reads_keys_from_disk_and_returns_none_when_missing() {
// given
let temp_root = std::env::temp_dir().join(format!(
"api-dotenv-test-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |duration| duration.as_nanos())
));
std::fs::create_dir_all(&temp_root).expect("create temp dir");
let env_path = temp_root.join(".env");
std::fs::write(
&env_path,
"ANTHROPIC_API_KEY=secret-from-file\n# comment\nXAI_API_KEY=\"xai-secret\"\n",
)
.expect("write .env");
let missing_path = temp_root.join("does-not-exist.env");
// when
let loaded = load_dotenv_file(&env_path).expect("file should load");
let missing = load_dotenv_file(&missing_path);
// then
assert_eq!(
loaded.get("ANTHROPIC_API_KEY").map(String::as_str),
Some("secret-from-file")
);
assert_eq!(
loaded.get("XAI_API_KEY").map(String::as_str),
Some("xai-secret")
);
assert!(missing.is_none());
let _ = std::fs::remove_dir_all(&temp_root);
}
}
+30 -9
View File
@@ -131,7 +131,15 @@ impl OpenAiCompatClient {
preflight_message_request(&request)?;
let response = self.send_with_retry(&request).await?;
let request_id = request_id_from_headers(response.headers());
let payload = response.json::<ChatCompletionResponse>().await?;
let body = response.text().await.map_err(ApiError::from)?;
let payload = serde_json::from_str::<ChatCompletionResponse>(&body).map_err(|error| {
ApiError::json_deserialize(
self.config.provider_name,
&request.model,
&body,
error,
)
})?;
let mut normalized = normalize_response(&request.model, payload)?;
if normalized.request_id.is_none() {
normalized.request_id = request_id;
@@ -150,7 +158,10 @@ impl OpenAiCompatClient {
Ok(MessageStream {
request_id: request_id_from_headers(response.headers()),
response,
parser: OpenAiSseParser::new(),
parser: OpenAiSseParser::with_context(
self.config.provider_name,
request.model.clone(),
),
pending: VecDeque::new(),
done: false,
state: StreamState::new(request.model.clone()),
@@ -282,11 +293,17 @@ impl MessageStream {
#[derive(Debug, Default)]
struct OpenAiSseParser {
buffer: Vec<u8>,
provider: String,
model: String,
}
impl OpenAiSseParser {
fn new() -> Self {
Self::default()
fn with_context(provider: impl Into<String>, model: impl Into<String>) -> Self {
Self {
buffer: Vec::new(),
provider: provider.into(),
model: model.into(),
}
}
fn push(&mut self, chunk: &[u8]) -> Result<Vec<ChatCompletionChunk>, ApiError> {
@@ -294,7 +311,7 @@ impl OpenAiSseParser {
let mut events = Vec::new();
while let Some(frame) = next_sse_frame(&mut self.buffer) {
if let Some(event) = parse_sse_frame(&frame)? {
if let Some(event) = parse_sse_frame(&frame, &self.provider, &self.model)? {
events.push(event);
}
}
@@ -835,7 +852,11 @@ fn next_sse_frame(buffer: &mut Vec<u8>) -> Option<String> {
Some(String::from_utf8_lossy(&frame[..frame_len]).into_owned())
}
fn parse_sse_frame(frame: &str) -> Result<Option<ChatCompletionChunk>, ApiError> {
fn parse_sse_frame(
frame: &str,
provider: &str,
model: &str,
) -> Result<Option<ChatCompletionChunk>, ApiError> {
let trimmed = frame.trim();
if trimmed.is_empty() {
return Ok(None);
@@ -857,15 +878,15 @@ fn parse_sse_frame(frame: &str) -> Result<Option<ChatCompletionChunk>, ApiError>
if payload == "[DONE]" {
return Ok(None);
}
serde_json::from_str(&payload)
serde_json::from_str::<ChatCompletionChunk>(&payload)
.map(Some)
.map_err(ApiError::from)
.map_err(|error| ApiError::json_deserialize(provider, model, &payload, error))
}
fn read_env_non_empty(key: &str) -> Result<Option<String>, ApiError> {
match std::env::var(key) {
Ok(value) if !value.is_empty() => Ok(Some(value)),
Ok(_) | Err(std::env::VarError::NotPresent) => Ok(None),
Ok(_) | Err(std::env::VarError::NotPresent) => Ok(super::dotenv_value(key)),
Err(error) => Err(ApiError::from(error)),
}
}
+54 -3
View File
@@ -4,6 +4,8 @@ use crate::types::StreamEvent;
#[derive(Debug, Default)]
pub struct SseParser {
buffer: Vec<u8>,
provider: Option<String>,
model: Option<String>,
}
impl SseParser {
@@ -12,12 +14,23 @@ impl SseParser {
Self::default()
}
/// Attach the provider name and model to this parser so that JSON
/// deserialization failures within streamed frames carry enough context
/// for callers to understand which upstream produced the unparseable
/// payload.
#[must_use]
pub fn with_context(mut self, provider: impl Into<String>, model: impl Into<String>) -> Self {
self.provider = Some(provider.into());
self.model = Some(model.into());
self
}
pub fn push(&mut self, chunk: &[u8]) -> Result<Vec<StreamEvent>, ApiError> {
self.buffer.extend_from_slice(chunk);
let mut events = Vec::new();
while let Some(frame) = self.next_frame() {
if let Some(event) = parse_frame(&frame)? {
if let Some(event) = self.parse_frame_with_context(&frame)? {
events.push(event);
}
}
@@ -31,12 +44,18 @@ impl SseParser {
}
let trailing = std::mem::take(&mut self.buffer);
match parse_frame(&String::from_utf8_lossy(&trailing))? {
match self.parse_frame_with_context(&String::from_utf8_lossy(&trailing))? {
Some(event) => Ok(vec![event]),
None => Ok(Vec::new()),
}
}
fn parse_frame_with_context(&self, frame: &str) -> Result<Option<StreamEvent>, ApiError> {
let provider = self.provider.as_deref().unwrap_or("unknown");
let model = self.model.as_deref().unwrap_or("unknown");
parse_frame_with_provider(frame, provider, model)
}
fn next_frame(&mut self) -> Option<String> {
let separator = self
.buffer
@@ -61,6 +80,14 @@ impl SseParser {
}
pub fn parse_frame(frame: &str) -> Result<Option<StreamEvent>, ApiError> {
parse_frame_with_provider(frame, "unknown", "unknown")
}
pub(crate) fn parse_frame_with_provider(
frame: &str,
provider: &str,
model: &str,
) -> Result<Option<StreamEvent>, ApiError> {
let trimmed = frame.trim();
if trimmed.is_empty() {
return Ok(None);
@@ -97,7 +124,7 @@ pub fn parse_frame(frame: &str) -> Result<Option<StreamEvent>, ApiError> {
serde_json::from_str::<StreamEvent>(&payload)
.map(Some)
.map_err(ApiError::from)
.map_err(|error| ApiError::json_deserialize(provider, model, &payload, error))
}
#[cfg(test)]
@@ -276,4 +303,28 @@ mod tests {
))
);
}
#[test]
fn given_message_delta_frame_with_empty_usage_when_parsed_then_usage_defaults_to_zero() {
// given
let frame = concat!(
"event: message_delta\n",
"data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null},\"usage\":{}}\n\n"
);
// when
let event = parse_frame(frame).expect("frame should parse");
// then
assert_eq!(
event,
Some(StreamEvent::MessageDelta(crate::types::MessageDeltaEvent {
delta: MessageDelta {
stop_reason: Some("end_turn".to_string()),
stop_sequence: None,
},
usage: Usage::default(),
}))
);
}
}
+5 -1
View File
@@ -113,6 +113,7 @@ pub struct MessageResponse {
pub stop_reason: Option<String>,
#[serde(default)]
pub stop_sequence: Option<String>,
#[serde(default)]
pub usage: Usage,
#[serde(default)]
pub request_id: Option<String>,
@@ -147,13 +148,15 @@ pub enum OutputContentBlock {
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Usage {
#[serde(default)]
pub input_tokens: u32,
#[serde(default)]
pub cache_creation_input_tokens: u32,
#[serde(default)]
pub cache_read_input_tokens: u32,
#[serde(default)]
pub output_tokens: u32,
}
@@ -194,6 +197,7 @@ pub struct MessageStartEvent {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MessageDeltaEvent {
pub delta: MessageDelta,
#[serde(default)]
pub usage: Usage,
}
+44 -10
View File
@@ -97,9 +97,9 @@ async fn send_message_posts_json_and_parses_response() {
assert!(body.get("stream").is_none());
assert_eq!(body["tools"][0]["name"], json!("get_weather"));
assert_eq!(body["tool_choice"]["type"], json!("auto"));
assert_eq!(
body["betas"],
json!(["claude-code-20250219", "prompt-caching-scope-2026-01-05"])
assert!(
body.get("betas").is_none(),
"betas must travel via the anthropic-beta header, not the request body"
);
}
@@ -191,13 +191,9 @@ async fn send_message_applies_request_profile_and_records_telemetry() {
let body: serde_json::Value =
serde_json::from_str(&request.body).expect("request body should be json");
assert_eq!(body["metadata"]["source"], json!("clawd-code"));
assert_eq!(
body["betas"],
json!([
"claude-code-20250219",
"prompt-caching-scope-2026-01-05",
"tools-2026-04-01"
])
assert!(
body.get("betas").is_none(),
"betas must travel via the anthropic-beta header, not the request body"
);
let events = sink.events();
@@ -276,6 +272,44 @@ async fn send_message_parses_prompt_cache_token_usage_from_response() {
assert_eq!(response.usage.output_tokens, 4);
}
#[tokio::test]
async fn given_empty_usage_object_when_send_message_parses_response_then_usage_defaults_to_zero() {
// given
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let body = concat!(
"{",
"\"id\":\"msg_empty_usage\",",
"\"type\":\"message\",",
"\"role\":\"assistant\",",
"\"content\":[{\"type\":\"text\",\"text\":\"Hello from Claude\"}],",
"\"model\":\"claude-3-7-sonnet-latest\",",
"\"stop_reason\":\"end_turn\",",
"\"stop_sequence\":null,",
"\"usage\":{}",
"}"
);
let server = spawn_server(
state,
vec![http_response("200 OK", "application/json", body)],
)
.await;
let client = AnthropicClient::new("test-key").with_base_url(server.base_url());
// when
let response = client
.send_message(&sample_request(false))
.await
.expect("response with empty usage object should still parse");
// then
assert_eq!(response.id, "msg_empty_usage");
assert_eq!(response.total_tokens(), 0);
assert_eq!(response.usage.input_tokens, 0);
assert_eq!(response.usage.cache_creation_input_tokens, 0);
assert_eq!(response.usage.cache_read_input_tokens, 0);
assert_eq!(response.usage.output_tokens, 0);
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn stream_message_parses_sse_events_with_tool_use() {
+48 -1
View File
@@ -2293,10 +2293,53 @@ pub fn classify_skills_slash_command(args: Option<&str>) -> SkillSlashDispatch {
Some(args) if args == "install" || args.starts_with("install ") => {
SkillSlashDispatch::Local
}
Some(args) => SkillSlashDispatch::Invoke(format!("${args}")),
Some(args) => SkillSlashDispatch::Invoke(format!("${}", args.trim_start_matches('/'))),
}
}
/// Resolve a skill invocation by validating the skill exists on disk before
/// returning the dispatch. When the skill is not found, returns `Err` with a
/// human-readable message that lists nearby skill names.
pub fn resolve_skill_invocation(
cwd: &Path,
args: Option<&str>,
) -> Result<SkillSlashDispatch, String> {
let dispatch = classify_skills_slash_command(args);
if let SkillSlashDispatch::Invoke(ref prompt) = dispatch {
// Extract the skill name from the "$skill [args]" prompt.
let skill_token = prompt
.trim_start_matches('$')
.split_whitespace()
.next()
.unwrap_or_default();
if !skill_token.is_empty() {
if let Err(error) = resolve_skill_path(cwd, skill_token) {
let mut message =
format!("Unknown skill: {skill_token} ({error})");
let roots = discover_skill_roots(cwd);
if let Ok(available) = load_skills_from_roots(&roots) {
let names: Vec<String> = available
.iter()
.filter(|s| s.shadowed_by.is_none())
.map(|s| s.name.clone())
.collect();
if !names.is_empty() {
message.push_str(&format!(
"\n Available skills: {}",
names.join(", ")
));
}
}
message.push_str(
"\n Usage: /skills [list|install <path>|help|<skill> [args]]",
);
return Err(message);
}
}
}
Ok(dispatch)
}
pub fn resolve_skill_path(cwd: &Path, skill: &str) -> std::io::Result<PathBuf> {
let requested = skill.trim().trim_start_matches('/').trim_start_matches('$');
if requested.is_empty() {
@@ -4301,6 +4344,10 @@ mod tests {
classify_skills_slash_command(Some("help overview")),
SkillSlashDispatch::Invoke("$help overview".to_string())
);
assert_eq!(
classify_skills_slash_command(Some("/test")),
SkillSlashDispatch::Invoke("$test".to_string())
);
assert_eq!(
classify_skills_slash_command(Some("install ./skill-pack")),
SkillSlashDispatch::Local
+82
View File
@@ -71,6 +71,13 @@ struct SessionPersistence {
}
/// Persisted conversational state for the runtime and CLI session manager.
///
/// `workspace_root` binds the session to the worktree it was created in. The
/// global session store under `~/.local/share/opencode` is shared across every
/// `opencode serve` instance, so without an explicit workspace root parallel
/// lanes can race and report success while writes land in the wrong CWD. See
/// ROADMAP.md item 41 (Phantom completions root cause) for the full
/// background.
#[derive(Debug, Clone)]
pub struct Session {
pub version: u32,
@@ -80,6 +87,7 @@ pub struct Session {
pub messages: Vec<ConversationMessage>,
pub compaction: Option<SessionCompaction>,
pub fork: Option<SessionFork>,
pub workspace_root: Option<PathBuf>,
persistence: Option<SessionPersistence>,
}
@@ -92,6 +100,7 @@ impl PartialEq for Session {
&& self.messages == other.messages
&& self.compaction == other.compaction
&& self.fork == other.fork
&& self.workspace_root == other.workspace_root
}
}
@@ -141,6 +150,7 @@ impl Session {
messages: Vec::new(),
compaction: None,
fork: None,
workspace_root: None,
persistence: None,
}
}
@@ -151,6 +161,22 @@ impl Session {
self
}
/// Bind this session to the workspace root it was created in.
///
/// This is the per-worktree counterpart to the global session store and
/// lets downstream tooling reject writes that drift to the wrong CWD when
/// multiple `opencode serve` instances share `~/.local/share/opencode`.
#[must_use]
pub fn with_workspace_root(mut self, workspace_root: impl Into<PathBuf>) -> Self {
self.workspace_root = Some(workspace_root.into());
self
}
#[must_use]
pub fn workspace_root(&self) -> Option<&Path> {
self.workspace_root.as_deref()
}
#[must_use]
pub fn persistence_path(&self) -> Option<&Path> {
self.persistence.as_ref().map(|value| value.path.as_path())
@@ -225,6 +251,7 @@ impl Session {
parent_session_id: self.session_id.clone(),
branch_name: normalize_optional_string(branch_name),
}),
workspace_root: self.workspace_root.clone(),
persistence: None,
}
}
@@ -262,6 +289,12 @@ impl Session {
if let Some(fork) = &self.fork {
object.insert("fork".to_string(), fork.to_json());
}
if let Some(workspace_root) = &self.workspace_root {
object.insert(
"workspace_root".to_string(),
JsonValue::String(workspace_root_to_string(workspace_root)?),
);
}
Ok(JsonValue::Object(object))
}
@@ -302,6 +335,10 @@ impl Session {
.map(SessionCompaction::from_json)
.transpose()?;
let fork = object.get("fork").map(SessionFork::from_json).transpose()?;
let workspace_root = object
.get("workspace_root")
.and_then(JsonValue::as_str)
.map(PathBuf::from);
Ok(Self {
version,
session_id,
@@ -310,6 +347,7 @@ impl Session {
messages,
compaction,
fork,
workspace_root,
persistence: None,
})
}
@@ -322,6 +360,7 @@ impl Session {
let mut messages = Vec::new();
let mut compaction = None;
let mut fork = None;
let mut workspace_root = None;
for (line_number, raw_line) in contents.lines().enumerate() {
let line = raw_line.trim();
@@ -356,6 +395,10 @@ impl Session {
created_at_ms = Some(required_u64(object, "created_at_ms")?);
updated_at_ms = Some(required_u64(object, "updated_at_ms")?);
fork = object.get("fork").map(SessionFork::from_json).transpose()?;
workspace_root = object
.get("workspace_root")
.and_then(JsonValue::as_str)
.map(PathBuf::from);
}
"message" => {
let message_value = object.get("message").ok_or_else(|| {
@@ -389,6 +432,7 @@ impl Session {
messages,
compaction,
fork,
workspace_root,
persistence: None,
})
}
@@ -449,6 +493,12 @@ impl Session {
if let Some(fork) = &self.fork {
object.insert("fork".to_string(), fork.to_json());
}
if let Some(workspace_root) = &self.workspace_root {
object.insert(
"workspace_root".to_string(),
JsonValue::String(workspace_root_to_string(workspace_root)?),
);
}
Ok(JsonValue::Object(object))
}
@@ -825,6 +875,15 @@ fn i64_from_usize(value: usize, key: &str) -> Result<i64, SessionError> {
.map_err(|_| SessionError::Format(format!("{key} out of range for JSON number")))
}
fn workspace_root_to_string(path: &Path) -> Result<String, SessionError> {
path.to_str().map(ToOwned::to_owned).ok_or_else(|| {
SessionError::Format(format!(
"workspace_root is not valid UTF-8: {}",
path.display()
))
})
}
fn normalize_optional_string(value: Option<String>) -> Option<String> {
value.and_then(|value| {
let trimmed = value.trim();
@@ -1206,6 +1265,29 @@ mod tests {
assert!(error.to_string().contains("unsupported block type"));
}
#[test]
fn persists_workspace_root_round_trip_and_forks_inherit_it() {
// given
let path = temp_session_path("workspace-root");
let workspace_root = PathBuf::from("/tmp/b4-phantom-diag");
let mut session = Session::new().with_workspace_root(workspace_root.clone());
session
.push_user_text("write to the right cwd")
.expect("user message should append");
// when
session
.save_to_path(&path)
.expect("workspace-bound session should save");
let restored = Session::load_from_path(&path).expect("session should load");
let forked = restored.fork(Some("phantom-diag".to_string()));
fs::remove_file(&path).expect("temp file should be removable");
// then
assert_eq!(restored.workspace_root(), Some(workspace_root.as_path()));
assert_eq!(forked.workspace_root(), Some(workspace_root.as_path()));
}
fn temp_session_path(label: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
+417 -35
View File
@@ -24,10 +24,10 @@ use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant, UNIX_EPOCH};
use api::{
oauth_token_is_expired, resolve_startup_auth_source, AnthropicClient, AuthSource,
ContentBlockDelta, InputContentBlock, InputMessage, MessageRequest, MessageResponse,
OutputContentBlock, PromptCache, StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition,
ToolResultContentBlock,
detect_provider_kind, oauth_token_is_expired, resolve_startup_auth_source, AnthropicClient,
AuthSource, ContentBlockDelta, InputContentBlock, InputMessage, MessageRequest,
MessageResponse, OutputContentBlock, PromptCache, ProviderKind, StreamEvent as ApiStreamEvent,
ToolChoice, ToolDefinition, ToolResultContentBlock,
};
use commands::{
@@ -815,6 +815,46 @@ fn config_permission_mode_for_current_dir() -> Option<PermissionMode> {
.map(permission_mode_from_resolved)
}
fn config_model_for_current_dir() -> Option<String> {
let cwd = env::current_dir().ok()?;
let loader = ConfigLoader::default_for(&cwd);
loader
.load()
.ok()?
.model()
.map(ToOwned::to_owned)
}
fn resolve_repl_model(cli_model: String) -> String {
if cli_model != DEFAULT_MODEL {
return cli_model;
}
if let Some(env_model) = env::var("ANTHROPIC_MODEL")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
{
return resolve_model_alias(&env_model).to_string();
}
if let Some(config_model) = config_model_for_current_dir() {
return resolve_model_alias(&config_model).to_string();
}
cli_model
}
fn provider_label(kind: ProviderKind) -> &'static str {
match kind {
ProviderKind::Anthropic => "anthropic",
ProviderKind::Xai => "xai",
ProviderKind::OpenAi => "openai",
}
}
fn format_connected_line(model: &str) -> String {
let provider = provider_label(detect_provider_kind(model));
format!("Connected: {model} via {provider}")
}
fn filter_tool_specs(
tool_registry: &GlobalToolRegistry,
allowed_tools: Option<&AllowedToolSet>,
@@ -1599,8 +1639,13 @@ fn run_login(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::E
println!("Listening for callback on {redirect_uri}");
}
if let Err(error) = open_browser(&authorize_url) {
eprintln!("warning: failed to open browser automatically: {error}");
println!("Open this URL manually:\n{authorize_url}");
emit_login_browser_open_failure(
output_format,
&authorize_url,
&error,
&mut io::stdout(),
&mut io::stderr(),
)?;
}
let callback = wait_for_oauth_callback(callback_port)?;
@@ -1651,6 +1696,23 @@ fn run_login(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::E
Ok(())
}
fn emit_login_browser_open_failure(
output_format: CliOutputFormat,
authorize_url: &str,
error: &io::Error,
stdout: &mut impl Write,
stderr: &mut impl Write,
) -> io::Result<()> {
writeln!(
stderr,
"warning: failed to open browser automatically: {error}"
)?;
match output_format {
CliOutputFormat::Text => writeln!(stdout, "Open this URL manually:\n{authorize_url}"),
CliOutputFormat::Json => writeln!(stderr, "Open this URL manually:\n{authorize_url}"),
}
}
fn run_logout(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Error>> {
clear_oauth_credentials()?;
match output_format {
@@ -2420,10 +2482,12 @@ fn run_repl(
allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode,
) -> Result<(), Box<dyn std::error::Error>> {
let mut cli = LiveCli::new(model, true, allowed_tools, permission_mode)?;
let resolved_model = resolve_repl_model(model);
let mut cli = LiveCli::new(resolved_model, true, allowed_tools, permission_mode)?;
let mut editor =
input::LineEditor::new("> ", cli.repl_completion_candidates().unwrap_or_default());
println!("{}", cli.startup_banner());
println!("{}", format_connected_line(&cli.model));
loop {
editor.set_completions(cli.repl_completion_candidates().unwrap_or_default());
@@ -5586,13 +5650,29 @@ impl AnthropicRuntimeClient {
}
fn resolve_cli_auth_source() -> Result<AuthSource, Box<dyn std::error::Error>> {
Ok(resolve_startup_auth_source(|| {
let cwd = env::current_dir().map_err(api::ApiError::from)?;
let config = ConfigLoader::default_for(&cwd).load().map_err(|error| {
api::ApiError::Auth(format!("failed to load runtime OAuth config: {error}"))
})?;
Ok(config.oauth().cloned())
})?)
let cwd = env::current_dir()?;
Ok(resolve_cli_auth_source_for_cwd(&cwd, default_oauth_config)?)
}
fn resolve_cli_auth_source_for_cwd<F>(
cwd: &Path,
default_oauth: F,
) -> Result<AuthSource, api::ApiError>
where
F: FnOnce() -> OAuthConfig,
{
resolve_startup_auth_source(|| {
Ok(Some(
load_runtime_oauth_config_for(cwd)?.unwrap_or_else(default_oauth),
))
})
}
fn load_runtime_oauth_config_for(cwd: &Path) -> Result<Option<OAuthConfig>, api::ApiError> {
let config = ConfigLoader::default_for(cwd).load().map_err(|error| {
api::ApiError::Auth(format!("failed to load runtime OAuth config: {error}"))
})?;
Ok(config.oauth().cloned())
}
impl ApiClient for AnthropicRuntimeClient {
@@ -5632,6 +5712,7 @@ impl ApiClient for AnthropicRuntimeClient {
let mut markdown_stream = MarkdownStreamState::default();
let mut events = Vec::new();
let mut pending_tool: Option<(String, String, String)> = None;
let mut block_has_thinking_summary = false;
let mut saw_stop = false;
while let Some(event) = stream.next_event().await.map_err(|error| {
@@ -5640,7 +5721,14 @@ impl ApiClient for AnthropicRuntimeClient {
match event {
ApiStreamEvent::MessageStart(start) => {
for block in start.message.content {
push_output_block(block, out, &mut events, &mut pending_tool, true)?;
push_output_block(
block,
out,
&mut events,
&mut pending_tool,
true,
&mut block_has_thinking_summary,
)?;
}
}
ApiStreamEvent::ContentBlockStart(start) => {
@@ -5650,6 +5738,7 @@ impl ApiClient for AnthropicRuntimeClient {
&mut events,
&mut pending_tool,
true,
&mut block_has_thinking_summary,
)?;
}
ApiStreamEvent::ContentBlockDelta(delta) => match delta.delta {
@@ -5671,10 +5760,16 @@ impl ApiClient for AnthropicRuntimeClient {
input.push_str(&partial_json);
}
}
ContentBlockDelta::ThinkingDelta { .. }
| ContentBlockDelta::SignatureDelta { .. } => {}
ContentBlockDelta::ThinkingDelta { .. } => {
if !block_has_thinking_summary {
render_thinking_block_summary(out, None, false)?;
block_has_thinking_summary = true;
}
}
ContentBlockDelta::SignatureDelta { .. } => {}
},
ApiStreamEvent::ContentBlockStop(_) => {
block_has_thinking_summary = false;
if let Some(rendered) = markdown_stream.flush(&renderer) {
write!(out, "{rendered}")
.and_then(|()| out.flush())
@@ -5742,7 +5837,7 @@ impl ApiClient for AnthropicRuntimeClient {
}
fn format_user_visible_api_error(session_id: &str, error: &api::ApiError) -> String {
if error.safe_failure_class() == "context_window" {
if error.is_context_window_failure() {
format_context_window_blocked_error(session_id, error)
} else if error.is_generic_fatal_wrapper() {
let mut qualifiers = vec![format!("session {session_id}")];
@@ -5780,10 +5875,10 @@ fn format_context_window_blocked_error(session_id: &str, error: &api::ApiError)
context_window_tokens,
} => {
lines.push(format!(" Model {model}"));
lines.push(format!(" Estimated input {estimated_input_tokens}"));
lines.push(format!(" Requested output {requested_output_tokens}"));
lines.push(format!(" Estimated total {estimated_total_tokens}"));
lines.push(format!(" Context window {context_window_tokens}"));
lines.push(format!(" Input estimate ~{estimated_input_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"));
}
api::ApiError::Api { message, body, .. } => {
let detail = message.as_deref().unwrap_or(body).trim();
@@ -6409,12 +6504,30 @@ fn truncate_output_for_display(content: &str, max_lines: usize, max_chars: usize
preview
}
fn render_thinking_block_summary(
out: &mut (impl Write + ?Sized),
char_count: Option<usize>,
redacted: bool,
) -> Result<(), RuntimeError> {
let summary = if redacted {
"\n▶ Thinking block hidden by provider\n".to_string()
} else if let Some(char_count) = char_count {
format!("\n▶ Thinking ({char_count} chars hidden)\n")
} else {
"\n▶ Thinking hidden\n".to_string()
};
write!(out, "{summary}")
.and_then(|()| out.flush())
.map_err(|error| RuntimeError::new(error.to_string()))
}
fn push_output_block(
block: OutputContentBlock,
out: &mut (impl Write + ?Sized),
events: &mut Vec<AssistantEvent>,
pending_tool: &mut Option<(String, String, String)>,
streaming_tool_input: bool,
block_has_thinking_summary: &mut bool,
) -> Result<(), RuntimeError> {
match block {
OutputContentBlock::Text { text } => {
@@ -6440,7 +6553,14 @@ fn push_output_block(
};
*pending_tool = Some((id, name, initial_input));
}
OutputContentBlock::Thinking { .. } | OutputContentBlock::RedactedThinking { .. } => {}
OutputContentBlock::Thinking { thinking, .. } => {
render_thinking_block_summary(out, Some(thinking.chars().count()), false)?;
*block_has_thinking_summary = true;
}
OutputContentBlock::RedactedThinking { .. } => {
render_thinking_block_summary(out, None, true)?;
*block_has_thinking_summary = true;
}
}
Ok(())
}
@@ -6453,7 +6573,15 @@ fn response_to_events(
let mut pending_tool = None;
for block in response.content {
push_output_block(block, out, &mut events, &mut pending_tool, false)?;
let mut block_has_thinking_summary = false;
push_output_block(
block,
out,
&mut events,
&mut pending_tool,
false,
&mut block_has_thinking_summary,
)?;
if let Some((id, name, input)) = pending_tool.take() {
events.push(AssistantEvent::ToolUse { id, name, input });
}
@@ -6819,17 +6947,17 @@ mod tests {
build_runtime_plugin_state_with_loader, build_runtime_with_plugin_state,
create_managed_session_handle, describe_tool_progress, filter_tool_specs,
format_bughunter_report, format_commit_preflight_report, format_commit_skipped_report,
format_compact_report, format_cost_report, format_internal_prompt_progress_line,
format_issue_report, format_model_report, format_model_switch_report,
format_permissions_report, format_permissions_switch_report, format_pr_report,
format_resume_report, format_status_report, format_tool_call_start, format_tool_result,
format_ultraplan_report, format_unknown_slash_command,
format_compact_report, format_connected_line, format_cost_report,
format_internal_prompt_progress_line, format_issue_report, format_model_report,
format_model_switch_report, format_permissions_report, format_permissions_switch_report,
format_pr_report, format_resume_report, format_status_report, format_tool_call_start,
format_tool_result, format_ultraplan_report, format_unknown_slash_command,
format_unknown_slash_command_message, format_user_visible_api_error,
normalize_permission_mode, parse_args, parse_git_status_branch,
parse_git_status_metadata_for, parse_git_workspace_summary, permission_policy,
print_help_to, push_output_block, render_config_report, render_diff_report,
render_diff_report_for, render_memory_report, render_repl_help, render_resume_usage,
resolve_model_alias, resolve_session_reference, response_to_events,
resolve_model_alias, resolve_repl_model, resolve_session_reference, response_to_events,
resume_supported_slash_commands, run_resume_command,
slash_command_completion_candidates_with_sessions, status_context, validate_no_args,
write_mcp_server_fixture, CliAction, CliOutputFormat, CliToolExecutor, GitWorkspaceSummary,
@@ -6841,14 +6969,17 @@ mod tests {
PluginManager, PluginManagerConfig, PluginTool, PluginToolDefinition, PluginToolPermission,
};
use runtime::{
AssistantEvent, ConfigLoader, ContentBlock, ConversationMessage, MessageRole,
PermissionMode, Session, ToolExecutor,
load_oauth_credentials, save_oauth_credentials, AssistantEvent, ConfigLoader, ContentBlock,
ConversationMessage, MessageRole, OAuthConfig, PermissionMode, Session, ToolExecutor,
};
use serde_json::json;
use std::fs;
use std::io::{Read, Write};
use std::net::TcpListener;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::{Mutex, MutexGuard, OnceLock};
use std::thread;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tools::GlobalToolRegistry;
@@ -6940,7 +7071,14 @@ mod tests {
rendered.contains("Model claude-sonnet-4-6"),
"{rendered}"
);
assert!(rendered.contains("Estimated total 246000"), "{rendered}");
assert!(
rendered.contains("Input estimate ~182000 tokens (heuristic)"),
"{rendered}"
);
assert!(
rendered.contains("Total estimate ~246000 tokens (heuristic)"),
"{rendered}"
);
assert!(rendered.contains("Compact /compact"), "{rendered}");
assert!(
rendered.contains("Resume compact claw --resume session-issue-32 /compact"),
@@ -6986,6 +7124,39 @@ mod tests {
);
}
#[test]
fn retry_wrapped_context_window_errors_keep_recovery_guidance() {
let error = ApiError::RetriesExhausted {
attempts: 2,
last_error: Box::new(ApiError::Api {
status: "413".parse().expect("status"),
error_type: Some("invalid_request_error".to_string()),
message: Some("Request is too large for this model's context window.".to_string()),
request_id: Some("req_ctx_retry_789".to_string()),
body: String::new(),
retryable: false,
}),
};
let rendered = format_user_visible_api_error("session-issue-32", &error);
assert!(rendered.contains("Context window blocked"), "{rendered}");
assert!(rendered.contains("context_window_blocked"), "{rendered}");
assert!(
rendered.contains("Trace req_ctx_retry_789"),
"{rendered}"
);
assert!(
rendered
.contains("Detail Request is too large for this model's context window."),
"{rendered}"
);
assert!(rendered.contains("Compact /compact"), "{rendered}");
assert!(
rendered.contains("Resume compact claw --resume session-issue-32 /compact"),
"{rendered}"
);
}
fn temp_dir() -> PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
@@ -7018,6 +7189,36 @@ mod tests {
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn sample_oauth_config(token_url: String) -> OAuthConfig {
OAuthConfig {
client_id: "runtime-client".to_string(),
authorize_url: "https://console.test/oauth/authorize".to_string(),
token_url,
callback_port: Some(4545),
manual_redirect_url: Some("https://console.test/oauth/callback".to_string()),
scopes: vec!["org:create_api_key".to_string(), "user:profile".to_string()],
}
}
fn spawn_token_server(response_body: &'static str) -> String {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind listener");
let address = listener.local_addr().expect("local addr");
thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("accept connection");
let mut buffer = [0_u8; 4096];
let _ = stream.read(&mut buffer).expect("read request");
let response = format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{}",
response_body.len(),
response_body
);
stream
.write_all(response.as_bytes())
.expect("write response");
});
format!("http://{address}/oauth/token")
}
fn with_current_dir<T>(cwd: &Path, f: impl FnOnce() -> T) -> T {
let _guard = cwd_lock()
.lock()
@@ -7156,6 +7357,78 @@ mod tests {
assert_eq!(resolved, PermissionMode::ReadOnly);
}
#[test]
fn load_runtime_oauth_config_for_returns_none_without_project_config() {
let _guard = env_lock();
let root = temp_dir();
std::fs::create_dir_all(&root).expect("workspace should exist");
let oauth = super::load_runtime_oauth_config_for(&root)
.expect("loading config should succeed when files are absent");
std::fs::remove_dir_all(root).expect("temp workspace should clean up");
assert_eq!(oauth, None);
}
#[test]
fn resolve_cli_auth_source_uses_default_oauth_when_runtime_config_is_missing() {
let _guard = env_lock();
let workspace = temp_dir();
let config_home = temp_dir();
std::fs::create_dir_all(&workspace).expect("workspace should exist");
std::fs::create_dir_all(&config_home).expect("config home should exist");
let original_config_home = std::env::var("CLAW_CONFIG_HOME").ok();
let original_api_key = std::env::var("ANTHROPIC_API_KEY").ok();
let original_auth_token = std::env::var("ANTHROPIC_AUTH_TOKEN").ok();
std::env::set_var("CLAW_CONFIG_HOME", &config_home);
std::env::remove_var("ANTHROPIC_API_KEY");
std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
save_oauth_credentials(&runtime::OAuthTokenSet {
access_token: "expired-access-token".to_string(),
refresh_token: Some("refresh-token".to_string()),
expires_at: Some(0),
scopes: vec!["org:create_api_key".to_string(), "user:profile".to_string()],
})
.expect("save expired oauth credentials");
let token_url = spawn_token_server(
r#"{"access_token":"refreshed-access-token","refresh_token":"refreshed-refresh-token","expires_at":4102444800,"scopes":["org:create_api_key","user:profile"]}"#,
);
let auth =
super::resolve_cli_auth_source_for_cwd(&workspace, || sample_oauth_config(token_url))
.expect("expired saved oauth should refresh via default config");
let stored = load_oauth_credentials()
.expect("load stored credentials")
.expect("stored credentials should exist");
match original_config_home {
Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value),
None => std::env::remove_var("CLAW_CONFIG_HOME"),
}
match original_api_key {
Some(value) => std::env::set_var("ANTHROPIC_API_KEY", value),
None => std::env::remove_var("ANTHROPIC_API_KEY"),
}
match original_auth_token {
Some(value) => std::env::set_var("ANTHROPIC_AUTH_TOKEN", value),
None => std::env::remove_var("ANTHROPIC_AUTH_TOKEN"),
}
std::fs::remove_dir_all(workspace).expect("temp workspace should clean up");
std::fs::remove_dir_all(config_home).expect("temp config home should clean up");
assert_eq!(auth.bearer_token(), Some("refreshed-access-token"));
assert_eq!(stored.access_token, "refreshed-access-token");
assert_eq!(
stored.refresh_token.as_deref(),
Some("refreshed-refresh-token")
);
}
#[test]
fn parses_prompt_subcommand() {
let _guard = env_lock();
@@ -7554,6 +7827,17 @@ mod tests {
output_format: CliOutputFormat::Text,
}
);
assert_eq!(
parse_args(&["/skills".to_string(), "/test".to_string()])
.expect("/skills /test should normalize to a single skill prompt prefix"),
CliAction::Prompt {
prompt: "$test".to_string(),
model: DEFAULT_MODEL.to_string(),
output_format: CliOutputFormat::Text,
allowed_tools: None,
permission_mode: crate::default_permission_mode(),
}
);
let error = parse_args(&["/status".to_string()])
.expect_err("/status should remain REPL-only when invoked directly");
assert!(error.contains("interactive-only"));
@@ -7826,6 +8110,73 @@ mod tests {
std::env::remove_var("ANTHROPIC_API_KEY");
}
#[test]
fn format_connected_line_renders_anthropic_provider_for_claude_model() {
let model = "claude-sonnet-4-6";
let line = format_connected_line(model);
assert_eq!(line, "Connected: claude-sonnet-4-6 via anthropic");
}
#[test]
fn format_connected_line_renders_xai_provider_for_grok_model() {
let model = "grok-3";
let line = format_connected_line(model);
assert_eq!(line, "Connected: grok-3 via xai");
}
#[test]
fn resolve_repl_model_returns_user_supplied_model_unchanged_when_explicit() {
let user_model = "claude-sonnet-4-6".to_string();
let resolved = resolve_repl_model(user_model);
assert_eq!(resolved, "claude-sonnet-4-6");
}
#[test]
fn resolve_repl_model_falls_back_to_anthropic_model_env_when_default() {
let _guard = env_lock();
let root = temp_dir();
fs::create_dir_all(&root).expect("root dir");
let config_home = root.join("config");
fs::create_dir_all(&config_home).expect("config home dir");
std::env::set_var("CLAW_CONFIG_HOME", &config_home);
std::env::remove_var("ANTHROPIC_MODEL");
std::env::set_var("ANTHROPIC_MODEL", "sonnet");
let resolved =
with_current_dir(&root, || resolve_repl_model(DEFAULT_MODEL.to_string()));
assert_eq!(resolved, "claude-sonnet-4-6");
std::env::remove_var("ANTHROPIC_MODEL");
std::env::remove_var("CLAW_CONFIG_HOME");
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn resolve_repl_model_returns_default_when_env_unset_and_no_config() {
let _guard = env_lock();
let root = temp_dir();
fs::create_dir_all(&root).expect("root dir");
let config_home = root.join("config");
fs::create_dir_all(&config_home).expect("config home dir");
std::env::set_var("CLAW_CONFIG_HOME", &config_home);
std::env::remove_var("ANTHROPIC_MODEL");
let resolved =
with_current_dir(&root, || resolve_repl_model(DEFAULT_MODEL.to_string()));
assert_eq!(resolved, DEFAULT_MODEL);
std::env::remove_var("CLAW_CONFIG_HOME");
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn resume_supported_command_list_matches_expected_surface() {
let names = resume_supported_slash_commands()
@@ -8636,6 +8987,7 @@ UU conflicted.rs",
let mut out = Vec::new();
let mut events = Vec::new();
let mut pending_tool = None;
let mut block_has_thinking_summary = false;
push_output_block(
OutputContentBlock::Text {
@@ -8645,6 +8997,7 @@ UU conflicted.rs",
&mut events,
&mut pending_tool,
false,
&mut block_has_thinking_summary,
)
.expect("text block should render");
@@ -8658,6 +9011,7 @@ UU conflicted.rs",
let mut out = Vec::new();
let mut events = Vec::new();
let mut pending_tool = None;
let mut block_has_thinking_summary = false;
push_output_block(
OutputContentBlock::ToolUse {
@@ -8669,6 +9023,7 @@ UU conflicted.rs",
&mut events,
&mut pending_tool,
true,
&mut block_has_thinking_summary,
)
.expect("tool block should accumulate");
@@ -8750,7 +9105,7 @@ UU conflicted.rs",
}
#[test]
fn response_to_events_ignores_thinking_blocks() {
fn response_to_events_renders_collapsed_thinking_summary() {
let mut out = Vec::new();
let events = response_to_events(
MessageResponse {
@@ -8785,7 +9140,34 @@ UU conflicted.rs",
&events[0],
AssistantEvent::TextDelta(text) if text == "Final answer"
));
assert!(!String::from_utf8(out).expect("utf8").contains("step 1"));
let rendered = String::from_utf8(out).expect("utf8");
assert!(rendered.contains("▶ Thinking (6 chars hidden)"));
assert!(!rendered.contains("step 1"));
}
#[test]
fn login_browser_failure_keeps_json_stdout_clean() {
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let error = std::io::Error::new(
std::io::ErrorKind::NotFound,
"no supported browser opener command found",
);
super::emit_login_browser_open_failure(
CliOutputFormat::Json,
"https://example.test/oauth/authorize",
&error,
&mut stdout,
&mut stderr,
)
.expect("browser warning should render");
assert!(stdout.is_empty());
let stderr = String::from_utf8(stderr).expect("utf8");
assert!(stderr.contains("failed to open browser automatically"));
assert!(stderr.contains("Open this URL manually:"));
assert!(stderr.contains("https://example.test/oauth/authorize"));
}
#[test]
+257 -1
View File
@@ -2974,7 +2974,263 @@ fn todo_store_path() -> Result<std::path::PathBuf, String> {
fn resolve_skill_path(skill: &str) -> Result<std::path::PathBuf, String> {
let cwd = std::env::current_dir().map_err(|error| error.to_string())?;
commands::resolve_skill_path(&cwd, skill).map_err(|error| error.to_string())
match commands::resolve_skill_path(&cwd, skill) {
Ok(path) => Ok(path),
Err(_) => resolve_skill_path_from_compat_roots(skill),
}
}
fn resolve_skill_path_from_compat_roots(skill: &str) -> Result<std::path::PathBuf, String> {
let requested = skill.trim().trim_start_matches('/').trim_start_matches('$');
if requested.is_empty() {
return Err(String::from("skill must not be empty"));
}
for root in skill_lookup_roots() {
if let Some(path) = resolve_skill_path_in_root(&root, requested) {
return Ok(path);
}
}
Err(format!("unknown skill: {requested}"))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SkillLookupOrigin {
SkillsDir,
LegacyCommandsDir,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct SkillLookupRoot {
path: std::path::PathBuf,
origin: SkillLookupOrigin,
}
fn skill_lookup_roots() -> Vec<SkillLookupRoot> {
let mut roots = Vec::new();
if let Ok(cwd) = std::env::current_dir() {
push_project_skill_lookup_roots(&mut roots, &cwd);
}
if let Ok(claw_config_home) = std::env::var("CLAW_CONFIG_HOME") {
push_prefixed_skill_lookup_roots(&mut roots, std::path::Path::new(&claw_config_home));
}
if let Ok(codex_home) = std::env::var("CODEX_HOME") {
push_prefixed_skill_lookup_roots(&mut roots, std::path::Path::new(&codex_home));
}
if let Ok(home) = std::env::var("HOME") {
push_home_skill_lookup_roots(&mut roots, std::path::Path::new(&home));
}
if let Ok(claude_config_dir) = std::env::var("CLAUDE_CONFIG_DIR") {
let claude_config_dir = std::path::PathBuf::from(claude_config_dir);
push_skill_lookup_root(
&mut roots,
claude_config_dir.join("skills"),
SkillLookupOrigin::SkillsDir,
);
push_skill_lookup_root(
&mut roots,
claude_config_dir.join("skills").join("omc-learned"),
SkillLookupOrigin::SkillsDir,
);
push_skill_lookup_root(
&mut roots,
claude_config_dir.join("commands"),
SkillLookupOrigin::LegacyCommandsDir,
);
}
push_skill_lookup_root(
&mut roots,
std::path::PathBuf::from("/home/bellman/.claw/skills"),
SkillLookupOrigin::SkillsDir,
);
push_skill_lookup_root(
&mut roots,
std::path::PathBuf::from("/home/bellman/.codex/skills"),
SkillLookupOrigin::SkillsDir,
);
roots
}
fn push_project_skill_lookup_roots(roots: &mut Vec<SkillLookupRoot>, cwd: &std::path::Path) {
for ancestor in cwd.ancestors() {
push_prefixed_skill_lookup_roots(roots, &ancestor.join(".omc"));
push_prefixed_skill_lookup_roots(roots, &ancestor.join(".agents"));
push_prefixed_skill_lookup_roots(roots, &ancestor.join(".claw"));
push_prefixed_skill_lookup_roots(roots, &ancestor.join(".codex"));
push_prefixed_skill_lookup_roots(roots, &ancestor.join(".claude"));
}
}
fn push_home_skill_lookup_roots(roots: &mut Vec<SkillLookupRoot>, home: &std::path::Path) {
push_prefixed_skill_lookup_roots(roots, &home.join(".omc"));
push_prefixed_skill_lookup_roots(roots, &home.join(".claw"));
push_prefixed_skill_lookup_roots(roots, &home.join(".codex"));
push_prefixed_skill_lookup_roots(roots, &home.join(".claude"));
push_skill_lookup_root(
roots,
home.join(".agents").join("skills"),
SkillLookupOrigin::SkillsDir,
);
push_skill_lookup_root(
roots,
home.join(".config").join("opencode").join("skills"),
SkillLookupOrigin::SkillsDir,
);
push_skill_lookup_root(
roots,
home.join(".claude").join("skills").join("omc-learned"),
SkillLookupOrigin::SkillsDir,
);
}
fn push_prefixed_skill_lookup_roots(roots: &mut Vec<SkillLookupRoot>, prefix: &std::path::Path) {
push_skill_lookup_root(roots, prefix.join("skills"), SkillLookupOrigin::SkillsDir);
push_skill_lookup_root(
roots,
prefix.join("commands"),
SkillLookupOrigin::LegacyCommandsDir,
);
}
fn push_skill_lookup_root(
roots: &mut Vec<SkillLookupRoot>,
path: std::path::PathBuf,
origin: SkillLookupOrigin,
) {
if path.is_dir() && !roots.iter().any(|existing| existing.path == path) {
roots.push(SkillLookupRoot { path, origin });
}
}
fn resolve_skill_path_in_root(
root: &SkillLookupRoot,
requested: &str,
) -> Option<std::path::PathBuf> {
match root.origin {
SkillLookupOrigin::SkillsDir => resolve_skill_path_in_skills_dir(&root.path, requested),
SkillLookupOrigin::LegacyCommandsDir => {
resolve_skill_path_in_legacy_commands_dir(&root.path, requested)
}
}
}
fn resolve_skill_path_in_skills_dir(
root: &std::path::Path,
requested: &str,
) -> Option<std::path::PathBuf> {
let direct = root.join(requested).join("SKILL.md");
if direct.is_file() {
return Some(direct);
}
let entries = std::fs::read_dir(root).ok()?;
for entry in entries.flatten() {
if !entry.path().is_dir() {
continue;
}
let skill_path = entry.path().join("SKILL.md");
if !skill_path.is_file() {
continue;
}
if entry
.file_name()
.to_string_lossy()
.eq_ignore_ascii_case(requested)
|| skill_frontmatter_name_matches(&skill_path, requested)
{
return Some(skill_path);
}
}
None
}
fn resolve_skill_path_in_legacy_commands_dir(
root: &std::path::Path,
requested: &str,
) -> Option<std::path::PathBuf> {
let direct_dir = root.join(requested).join("SKILL.md");
if direct_dir.is_file() {
return Some(direct_dir);
}
let direct_markdown = root.join(format!("{requested}.md"));
if direct_markdown.is_file() {
return Some(direct_markdown);
}
let entries = std::fs::read_dir(root).ok()?;
for entry in entries.flatten() {
let path = entry.path();
let candidate_path = if path.is_dir() {
let skill_path = path.join("SKILL.md");
if !skill_path.is_file() {
continue;
}
skill_path
} else if path
.extension()
.is_some_and(|ext| ext.to_string_lossy().eq_ignore_ascii_case("md"))
{
path
} else {
continue;
};
let matches_entry_name = candidate_path
.file_stem()
.is_some_and(|stem| stem.to_string_lossy().eq_ignore_ascii_case(requested))
|| entry
.file_name()
.to_string_lossy()
.trim_end_matches(".md")
.eq_ignore_ascii_case(requested);
if matches_entry_name || skill_frontmatter_name_matches(&candidate_path, requested) {
return Some(candidate_path);
}
}
None
}
fn skill_frontmatter_name_matches(path: &std::path::Path, requested: &str) -> bool {
std::fs::read_to_string(path)
.ok()
.and_then(|contents| parse_skill_name(&contents))
.is_some_and(|name| name.eq_ignore_ascii_case(requested))
}
fn parse_skill_name(contents: &str) -> Option<String> {
parse_skill_frontmatter_value(contents, "name")
}
fn parse_skill_frontmatter_value(contents: &str, key: &str) -> Option<String> {
let mut lines = contents.lines();
if lines.next().map(str::trim) != Some("---") {
return None;
}
for line in lines {
let trimmed = line.trim();
if trimmed == "---" {
break;
}
if let Some(value) = trimmed.strip_prefix(&format!("{key}:")) {
let value = value
.trim()
.trim_matches(|ch| matches!(ch, '"' | '\''))
.trim();
if !value.is_empty() {
return Some(value.to_string());
}
}
}
None
}
const DEFAULT_AGENT_MODEL: &str = "claude-opus-4-6";