mirror of
https://github.com/instructkr/claw-code.git
synced 2026-07-08 18:59:14 +02:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 861edfc1dc | |||
| f982f24926 | |||
| 8d866073c5 | |||
| 4251c85855 | |||
| 2a642871ad | |||
| cd83c0ff68 | |||
| ce360e0ff3 | |||
| c980c3c01e | |||
| ce22d8fb4f | |||
| be561bfdeb | |||
| c1883d0f66 | |||
| 1fc5a1c457 | |||
| 549ad7c3af | |||
| ecadc5554a | |||
| 8ff9c1b15a | |||
| 6bd464bbe7 |
+17
@@ -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`.
|
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`.
|
||||||
|
|
||||||
|
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**
|
**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
|
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
|
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
|
||||||
|
|||||||
@@ -109,6 +109,50 @@ cd rust
|
|||||||
./target/debug/claw logout
|
./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
|
## Common operational commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+152
-15
@@ -35,7 +35,12 @@ pub enum ApiError {
|
|||||||
InvalidApiKeyEnv(VarError),
|
InvalidApiKeyEnv(VarError),
|
||||||
Http(reqwest::Error),
|
Http(reqwest::Error),
|
||||||
Io(std::io::Error),
|
Io(std::io::Error),
|
||||||
Json(serde_json::Error),
|
Json {
|
||||||
|
provider: String,
|
||||||
|
model: String,
|
||||||
|
body_snippet: String,
|
||||||
|
source: serde_json::Error,
|
||||||
|
},
|
||||||
Api {
|
Api {
|
||||||
status: reqwest::StatusCode,
|
status: reqwest::StatusCode,
|
||||||
error_type: Option<String>,
|
error_type: Option<String>,
|
||||||
@@ -64,6 +69,25 @@ impl ApiError {
|
|||||||
Self::MissingCredentials { provider, env_vars }
|
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]
|
#[must_use]
|
||||||
pub fn is_retryable(&self) -> bool {
|
pub fn is_retryable(&self) -> bool {
|
||||||
match self {
|
match self {
|
||||||
@@ -76,7 +100,7 @@ impl ApiError {
|
|||||||
| Self::Auth(_)
|
| Self::Auth(_)
|
||||||
| Self::InvalidApiKeyEnv(_)
|
| Self::InvalidApiKeyEnv(_)
|
||||||
| Self::Io(_)
|
| Self::Io(_)
|
||||||
| Self::Json(_)
|
| Self::Json { .. }
|
||||||
| Self::InvalidSseFrame(_)
|
| Self::InvalidSseFrame(_)
|
||||||
| Self::BackoffOverflow { .. } => false,
|
| Self::BackoffOverflow { .. } => false,
|
||||||
}
|
}
|
||||||
@@ -94,7 +118,7 @@ impl ApiError {
|
|||||||
| Self::InvalidApiKeyEnv(_)
|
| Self::InvalidApiKeyEnv(_)
|
||||||
| Self::Http(_)
|
| Self::Http(_)
|
||||||
| Self::Io(_)
|
| Self::Io(_)
|
||||||
| Self::Json(_)
|
| Self::Json { .. }
|
||||||
| Self::InvalidSseFrame(_)
|
| Self::InvalidSseFrame(_)
|
||||||
| Self::BackoffOverflow { .. } => None,
|
| Self::BackoffOverflow { .. } => None,
|
||||||
}
|
}
|
||||||
@@ -103,7 +127,11 @@ impl ApiError {
|
|||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn safe_failure_class(&self) -> &'static str {
|
pub fn safe_failure_class(&self) -> &'static str {
|
||||||
match self {
|
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(_) => {
|
Self::MissingCredentials { .. } | Self::ExpiredOAuthToken | Self::Auth(_) => {
|
||||||
"provider_auth"
|
"provider_auth"
|
||||||
}
|
}
|
||||||
@@ -116,7 +144,7 @@ impl ApiError {
|
|||||||
Self::Http(_) | Self::InvalidSseFrame(_) | Self::BackoffOverflow { .. } => {
|
Self::Http(_) | Self::InvalidSseFrame(_) | Self::BackoffOverflow { .. } => {
|
||||||
"provider_transport"
|
"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::InvalidApiKeyEnv(_)
|
||||||
| Self::Http(_)
|
| Self::Http(_)
|
||||||
| Self::Io(_)
|
| Self::Io(_)
|
||||||
| Self::Json(_)
|
| Self::Json { .. }
|
||||||
| Self::InvalidSseFrame(_)
|
| Self::InvalidSseFrame(_)
|
||||||
| Self::BackoffOverflow { .. } => false,
|
| Self::BackoffOverflow { .. } => false,
|
||||||
}
|
}
|
||||||
@@ -166,7 +194,7 @@ impl ApiError {
|
|||||||
| Self::InvalidApiKeyEnv(_)
|
| Self::InvalidApiKeyEnv(_)
|
||||||
| Self::Http(_)
|
| Self::Http(_)
|
||||||
| Self::Io(_)
|
| Self::Io(_)
|
||||||
| Self::Json(_)
|
| Self::Json { .. }
|
||||||
| Self::InvalidSseFrame(_)
|
| Self::InvalidSseFrame(_)
|
||||||
| Self::BackoffOverflow { .. } => false,
|
| Self::BackoffOverflow { .. } => false,
|
||||||
}
|
}
|
||||||
@@ -176,11 +204,27 @@ impl ApiError {
|
|||||||
impl Display for ApiError {
|
impl Display for ApiError {
|
||||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
Self::MissingCredentials { provider, env_vars } => write!(
|
Self::MissingCredentials { provider, env_vars } => {
|
||||||
f,
|
write!(
|
||||||
"missing {provider} credentials; export {} before calling the {provider} API",
|
f,
|
||||||
env_vars.join(" or ")
|
"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 {
|
Self::ContextWindowExceeded {
|
||||||
model,
|
model,
|
||||||
estimated_input_tokens,
|
estimated_input_tokens,
|
||||||
@@ -203,7 +247,15 @@ impl Display for ApiError {
|
|||||||
}
|
}
|
||||||
Self::Http(error) => write!(f, "http error: {error}"),
|
Self::Http(error) => write!(f, "http error: {error}"),
|
||||||
Self::Io(error) => write!(f, "io 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 {
|
Self::Api {
|
||||||
status,
|
status,
|
||||||
error_type,
|
error_type,
|
||||||
@@ -258,7 +310,12 @@ impl From<std::io::Error> for ApiError {
|
|||||||
|
|
||||||
impl From<serde_json::Error> for ApiError {
|
impl From<serde_json::Error> for ApiError {
|
||||||
fn from(value: serde_json::Error) -> Self {
|
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))
|
.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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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]
|
#[test]
|
||||||
fn detects_generic_fatal_wrapper_and_classifies_it_as_provider_internal() {
|
fn detects_generic_fatal_wrapper_and_classifies_it_as_provider_internal() {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ use telemetry::{AnalyticsEvent, AnthropicRequestProfile, ClientIdentity, Session
|
|||||||
use crate::error::ApiError;
|
use crate::error::ApiError;
|
||||||
use crate::prompt_cache::{PromptCache, PromptCacheRecord, PromptCacheStats};
|
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::sse::SseParser;
|
||||||
use crate::types::{MessageDeltaEvent, MessageRequest, MessageResponse, StreamEvent, Usage};
|
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 http_response = self.send_with_retry(&request).await?;
|
||||||
let request_id = request_id_from_headers(response.headers());
|
let request_id = request_id_from_headers(http_response.headers());
|
||||||
let mut response = response
|
let body = http_response.text().await.map_err(ApiError::from)?;
|
||||||
.json::<MessageResponse>()
|
let mut response = serde_json::from_str::<MessageResponse>(&body).map_err(|error| {
|
||||||
.await
|
ApiError::json_deserialize("Anthropic", &request.model, &body, error)
|
||||||
.map_err(ApiError::from)?;
|
})?;
|
||||||
if response.request_id.is_none() {
|
if response.request_id.is_none() {
|
||||||
response.request_id = request_id;
|
response.request_id = request_id;
|
||||||
}
|
}
|
||||||
@@ -339,14 +339,14 @@ impl AnthropicClient {
|
|||||||
&self,
|
&self,
|
||||||
request: &MessageRequest,
|
request: &MessageRequest,
|
||||||
) -> Result<MessageStream, ApiError> {
|
) -> Result<MessageStream, ApiError> {
|
||||||
preflight_message_request(request)?;
|
self.preflight_message_request(request).await?;
|
||||||
let response = self
|
let response = self
|
||||||
.send_with_retry(&request.clone().with_streaming())
|
.send_with_retry(&request.clone().with_streaming())
|
||||||
.await?;
|
.await?;
|
||||||
Ok(MessageStream {
|
Ok(MessageStream {
|
||||||
request_id: request_id_from_headers(response.headers()),
|
request_id: request_id_from_headers(response.headers()),
|
||||||
response,
|
response,
|
||||||
parser: SseParser::new(),
|
parser: SseParser::new().with_context("Anthropic", request.model.clone()),
|
||||||
pending: VecDeque::new(),
|
pending: VecDeque::new(),
|
||||||
done: false,
|
done: false,
|
||||||
request: request.clone(),
|
request: request.clone(),
|
||||||
@@ -371,10 +371,10 @@ impl AnthropicClient {
|
|||||||
.await
|
.await
|
||||||
.map_err(ApiError::from)?;
|
.map_err(ApiError::from)?;
|
||||||
let response = expect_success(response).await?;
|
let response = expect_success(response).await?;
|
||||||
response
|
let body = response.text().await.map_err(ApiError::from)?;
|
||||||
.json::<OAuthTokenSet>()
|
serde_json::from_str::<OAuthTokenSet>(&body).map_err(|error| {
|
||||||
.await
|
ApiError::json_deserialize("Anthropic OAuth (exchange)", "n/a", &body, error)
|
||||||
.map_err(ApiError::from)
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn refresh_oauth_token(
|
pub async fn refresh_oauth_token(
|
||||||
@@ -391,10 +391,10 @@ impl AnthropicClient {
|
|||||||
.await
|
.await
|
||||||
.map_err(ApiError::from)?;
|
.map_err(ApiError::from)?;
|
||||||
let response = expect_success(response).await?;
|
let response = expect_success(response).await?;
|
||||||
response
|
let body = response.text().await.map_err(ApiError::from)?;
|
||||||
.json::<OAuthTokenSet>()
|
serde_json::from_str::<OAuthTokenSet>(&body).map_err(|error| {
|
||||||
.await
|
ApiError::json_deserialize("Anthropic OAuth (refresh)", "n/a", &body, error)
|
||||||
.map_err(ApiError::from)
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send_with_retry(
|
async fn send_with_retry(
|
||||||
@@ -466,18 +466,74 @@ impl AnthropicClient {
|
|||||||
request: &MessageRequest,
|
request: &MessageRequest,
|
||||||
) -> Result<reqwest::Response, ApiError> {
|
) -> Result<reqwest::Response, ApiError> {
|
||||||
let request_url = format!("{}/v1/messages", self.base_url.trim_end_matches('/'));
|
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
|
let request_builder = self
|
||||||
.http
|
.http
|
||||||
.post(&request_url)
|
.post(request_url)
|
||||||
.header("content-type", "application/json");
|
.header("content-type", "application/json");
|
||||||
let mut request_builder = self.auth.apply(request_builder);
|
let mut request_builder = self.auth.apply(request_builder);
|
||||||
for (header_name, header_value) in self.request_profile.header_pairs() {
|
for (header_name, header_value) in self.request_profile.header_pairs() {
|
||||||
request_builder = request_builder.header(header_name, header_value);
|
request_builder = request_builder.header(header_name, header_value);
|
||||||
}
|
}
|
||||||
|
request_builder
|
||||||
|
}
|
||||||
|
|
||||||
let request_body = self.request_profile.render_json_body(request)?;
|
async fn preflight_message_request(&self, request: &MessageRequest) -> Result<(), ApiError> {
|
||||||
request_builder = request_builder.json(&request_body);
|
let Some(limit) = model_token_limit(&request.model) else {
|
||||||
request_builder.send().await.map_err(ApiError::from)
|
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) {
|
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> {
|
fn read_env_non_empty(key: &str) -> Result<Option<String>, ApiError> {
|
||||||
match std::env::var(key) {
|
match std::env::var(key) {
|
||||||
Ok(value) if !value.is_empty() => Ok(Some(value)),
|
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)),
|
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)
|
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)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct AnthropicErrorEnvelope {
|
struct AnthropicErrorEnvelope {
|
||||||
error: AnthropicErrorBody,
|
error: AnthropicErrorBody,
|
||||||
@@ -1247,4 +1313,73 @@ mod tests {
|
|||||||
Some("Bearer proxy-token")
|
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")
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -258,6 +258,61 @@ fn estimate_serialized_tokens<T: Serialize>(value: &T) -> u32 {
|
|||||||
.map_or(0, |bytes| (bytes.len() / 4 + 1) as 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
@@ -268,8 +323,8 @@ mod tests {
|
|||||||
};
|
};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
detect_provider_kind, max_tokens_for_model, model_token_limit, preflight_message_request,
|
detect_provider_kind, load_dotenv_file, max_tokens_for_model, model_token_limit,
|
||||||
resolve_model_alias, ProviderKind,
|
parse_dotenv, preflight_message_request, resolve_model_alias, ProviderKind,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -375,4 +430,85 @@ mod tests {
|
|||||||
preflight_message_request(&request)
|
preflight_message_request(&request)
|
||||||
.expect("models without context metadata should skip the guarded preflight");
|
.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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,7 +131,15 @@ impl OpenAiCompatClient {
|
|||||||
preflight_message_request(&request)?;
|
preflight_message_request(&request)?;
|
||||||
let response = self.send_with_retry(&request).await?;
|
let response = self.send_with_retry(&request).await?;
|
||||||
let request_id = request_id_from_headers(response.headers());
|
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)?;
|
let mut normalized = normalize_response(&request.model, payload)?;
|
||||||
if normalized.request_id.is_none() {
|
if normalized.request_id.is_none() {
|
||||||
normalized.request_id = request_id;
|
normalized.request_id = request_id;
|
||||||
@@ -150,7 +158,10 @@ 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::new(),
|
parser: OpenAiSseParser::with_context(
|
||||||
|
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()),
|
||||||
@@ -282,11 +293,17 @@ impl MessageStream {
|
|||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
struct OpenAiSseParser {
|
struct OpenAiSseParser {
|
||||||
buffer: Vec<u8>,
|
buffer: Vec<u8>,
|
||||||
|
provider: String,
|
||||||
|
model: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OpenAiSseParser {
|
impl OpenAiSseParser {
|
||||||
fn new() -> Self {
|
fn with_context(provider: impl Into<String>, model: impl Into<String>) -> Self {
|
||||||
Self::default()
|
Self {
|
||||||
|
buffer: Vec::new(),
|
||||||
|
provider: provider.into(),
|
||||||
|
model: model.into(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn push(&mut self, chunk: &[u8]) -> Result<Vec<ChatCompletionChunk>, ApiError> {
|
fn push(&mut self, chunk: &[u8]) -> Result<Vec<ChatCompletionChunk>, ApiError> {
|
||||||
@@ -294,7 +311,7 @@ impl OpenAiSseParser {
|
|||||||
let mut events = Vec::new();
|
let mut events = Vec::new();
|
||||||
|
|
||||||
while let Some(frame) = next_sse_frame(&mut self.buffer) {
|
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);
|
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())
|
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();
|
let trimmed = frame.trim();
|
||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
@@ -857,15 +878,15 @@ fn parse_sse_frame(frame: &str) -> Result<Option<ChatCompletionChunk>, ApiError>
|
|||||||
if payload == "[DONE]" {
|
if payload == "[DONE]" {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
serde_json::from_str(&payload)
|
serde_json::from_str::<ChatCompletionChunk>(&payload)
|
||||||
.map(Some)
|
.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> {
|
fn read_env_non_empty(key: &str) -> Result<Option<String>, ApiError> {
|
||||||
match std::env::var(key) {
|
match std::env::var(key) {
|
||||||
Ok(value) if !value.is_empty() => Ok(Some(value)),
|
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)),
|
Err(error) => Err(ApiError::from(error)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ use crate::types::StreamEvent;
|
|||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
pub struct SseParser {
|
pub struct SseParser {
|
||||||
buffer: Vec<u8>,
|
buffer: Vec<u8>,
|
||||||
|
provider: Option<String>,
|
||||||
|
model: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SseParser {
|
impl SseParser {
|
||||||
@@ -12,12 +14,23 @@ impl SseParser {
|
|||||||
Self::default()
|
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> {
|
pub fn push(&mut self, chunk: &[u8]) -> Result<Vec<StreamEvent>, ApiError> {
|
||||||
self.buffer.extend_from_slice(chunk);
|
self.buffer.extend_from_slice(chunk);
|
||||||
let mut events = Vec::new();
|
let mut events = Vec::new();
|
||||||
|
|
||||||
while let Some(frame) = self.next_frame() {
|
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);
|
events.push(event);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -31,12 +44,18 @@ impl SseParser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let trailing = std::mem::take(&mut self.buffer);
|
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]),
|
Some(event) => Ok(vec![event]),
|
||||||
None => Ok(Vec::new()),
|
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> {
|
fn next_frame(&mut self) -> Option<String> {
|
||||||
let separator = self
|
let separator = self
|
||||||
.buffer
|
.buffer
|
||||||
@@ -61,6 +80,14 @@ impl SseParser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn parse_frame(frame: &str) -> Result<Option<StreamEvent>, ApiError> {
|
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();
|
let trimmed = frame.trim();
|
||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
@@ -97,7 +124,7 @@ pub fn parse_frame(frame: &str) -> Result<Option<StreamEvent>, ApiError> {
|
|||||||
|
|
||||||
serde_json::from_str::<StreamEvent>(&payload)
|
serde_json::from_str::<StreamEvent>(&payload)
|
||||||
.map(Some)
|
.map(Some)
|
||||||
.map_err(ApiError::from)
|
.map_err(|error| ApiError::json_deserialize(provider, model, &payload, error))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[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(),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ pub struct MessageResponse {
|
|||||||
pub stop_reason: Option<String>,
|
pub stop_reason: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub stop_sequence: Option<String>,
|
pub stop_sequence: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
pub usage: Usage,
|
pub usage: Usage,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub request_id: Option<String>,
|
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 {
|
pub struct Usage {
|
||||||
|
#[serde(default)]
|
||||||
pub input_tokens: u32,
|
pub input_tokens: u32,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub cache_creation_input_tokens: u32,
|
pub cache_creation_input_tokens: u32,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub cache_read_input_tokens: u32,
|
pub cache_read_input_tokens: u32,
|
||||||
|
#[serde(default)]
|
||||||
pub output_tokens: u32,
|
pub output_tokens: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,6 +197,7 @@ pub struct MessageStartEvent {
|
|||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct MessageDeltaEvent {
|
pub struct MessageDeltaEvent {
|
||||||
pub delta: MessageDelta,
|
pub delta: MessageDelta,
|
||||||
|
#[serde(default)]
|
||||||
pub usage: Usage,
|
pub usage: Usage,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -97,9 +97,9 @@ async fn send_message_posts_json_and_parses_response() {
|
|||||||
assert!(body.get("stream").is_none());
|
assert!(body.get("stream").is_none());
|
||||||
assert_eq!(body["tools"][0]["name"], json!("get_weather"));
|
assert_eq!(body["tools"][0]["name"], json!("get_weather"));
|
||||||
assert_eq!(body["tool_choice"]["type"], json!("auto"));
|
assert_eq!(body["tool_choice"]["type"], json!("auto"));
|
||||||
assert_eq!(
|
assert!(
|
||||||
body["betas"],
|
body.get("betas").is_none(),
|
||||||
json!(["claude-code-20250219", "prompt-caching-scope-2026-01-05"])
|
"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 =
|
let body: serde_json::Value =
|
||||||
serde_json::from_str(&request.body).expect("request body should be json");
|
serde_json::from_str(&request.body).expect("request body should be json");
|
||||||
assert_eq!(body["metadata"]["source"], json!("clawd-code"));
|
assert_eq!(body["metadata"]["source"], json!("clawd-code"));
|
||||||
assert_eq!(
|
assert!(
|
||||||
body["betas"],
|
body.get("betas").is_none(),
|
||||||
json!([
|
"betas must travel via the anthropic-beta header, not the request body"
|
||||||
"claude-code-20250219",
|
|
||||||
"prompt-caching-scope-2026-01-05",
|
|
||||||
"tools-2026-04-01"
|
|
||||||
])
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let events = sink.events();
|
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);
|
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]
|
#[tokio::test]
|
||||||
#[allow(clippy::await_holding_lock)]
|
#[allow(clippy::await_holding_lock)]
|
||||||
async fn stream_message_parses_sse_events_with_tool_use() {
|
async fn stream_message_parses_sse_events_with_tool_use() {
|
||||||
|
|||||||
@@ -2293,10 +2293,53 @@ pub fn classify_skills_slash_command(args: Option<&str>) -> SkillSlashDispatch {
|
|||||||
Some(args) if args == "install" || args.starts_with("install ") => {
|
Some(args) if args == "install" || args.starts_with("install ") => {
|
||||||
SkillSlashDispatch::Local
|
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> {
|
pub fn resolve_skill_path(cwd: &Path, skill: &str) -> std::io::Result<PathBuf> {
|
||||||
let requested = skill.trim().trim_start_matches('/').trim_start_matches('$');
|
let requested = skill.trim().trim_start_matches('/').trim_start_matches('$');
|
||||||
if requested.is_empty() {
|
if requested.is_empty() {
|
||||||
@@ -4301,6 +4344,10 @@ mod tests {
|
|||||||
classify_skills_slash_command(Some("help overview")),
|
classify_skills_slash_command(Some("help overview")),
|
||||||
SkillSlashDispatch::Invoke("$help overview".to_string())
|
SkillSlashDispatch::Invoke("$help overview".to_string())
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_skills_slash_command(Some("/test")),
|
||||||
|
SkillSlashDispatch::Invoke("$test".to_string())
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
classify_skills_slash_command(Some("install ./skill-pack")),
|
classify_skills_slash_command(Some("install ./skill-pack")),
|
||||||
SkillSlashDispatch::Local
|
SkillSlashDispatch::Local
|
||||||
|
|||||||
@@ -71,6 +71,13 @@ struct SessionPersistence {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Persisted conversational state for the runtime and CLI session manager.
|
/// 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)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Session {
|
pub struct Session {
|
||||||
pub version: u32,
|
pub version: u32,
|
||||||
@@ -80,6 +87,7 @@ pub struct Session {
|
|||||||
pub messages: Vec<ConversationMessage>,
|
pub messages: Vec<ConversationMessage>,
|
||||||
pub compaction: Option<SessionCompaction>,
|
pub compaction: Option<SessionCompaction>,
|
||||||
pub fork: Option<SessionFork>,
|
pub fork: Option<SessionFork>,
|
||||||
|
pub workspace_root: Option<PathBuf>,
|
||||||
persistence: Option<SessionPersistence>,
|
persistence: Option<SessionPersistence>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,6 +100,7 @@ impl PartialEq for Session {
|
|||||||
&& self.messages == other.messages
|
&& self.messages == other.messages
|
||||||
&& self.compaction == other.compaction
|
&& self.compaction == other.compaction
|
||||||
&& self.fork == other.fork
|
&& self.fork == other.fork
|
||||||
|
&& self.workspace_root == other.workspace_root
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,6 +150,7 @@ impl Session {
|
|||||||
messages: Vec::new(),
|
messages: Vec::new(),
|
||||||
compaction: None,
|
compaction: None,
|
||||||
fork: None,
|
fork: None,
|
||||||
|
workspace_root: None,
|
||||||
persistence: None,
|
persistence: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -151,6 +161,22 @@ impl Session {
|
|||||||
self
|
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]
|
#[must_use]
|
||||||
pub fn persistence_path(&self) -> Option<&Path> {
|
pub fn persistence_path(&self) -> Option<&Path> {
|
||||||
self.persistence.as_ref().map(|value| value.path.as_path())
|
self.persistence.as_ref().map(|value| value.path.as_path())
|
||||||
@@ -225,6 +251,7 @@ impl Session {
|
|||||||
parent_session_id: self.session_id.clone(),
|
parent_session_id: self.session_id.clone(),
|
||||||
branch_name: normalize_optional_string(branch_name),
|
branch_name: normalize_optional_string(branch_name),
|
||||||
}),
|
}),
|
||||||
|
workspace_root: self.workspace_root.clone(),
|
||||||
persistence: None,
|
persistence: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -262,6 +289,12 @@ impl Session {
|
|||||||
if let Some(fork) = &self.fork {
|
if let Some(fork) = &self.fork {
|
||||||
object.insert("fork".to_string(), fork.to_json());
|
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))
|
Ok(JsonValue::Object(object))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,6 +335,10 @@ impl Session {
|
|||||||
.map(SessionCompaction::from_json)
|
.map(SessionCompaction::from_json)
|
||||||
.transpose()?;
|
.transpose()?;
|
||||||
let fork = object.get("fork").map(SessionFork::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 {
|
Ok(Self {
|
||||||
version,
|
version,
|
||||||
session_id,
|
session_id,
|
||||||
@@ -310,6 +347,7 @@ impl Session {
|
|||||||
messages,
|
messages,
|
||||||
compaction,
|
compaction,
|
||||||
fork,
|
fork,
|
||||||
|
workspace_root,
|
||||||
persistence: None,
|
persistence: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -322,6 +360,7 @@ impl Session {
|
|||||||
let mut messages = Vec::new();
|
let mut messages = Vec::new();
|
||||||
let mut compaction = None;
|
let mut compaction = None;
|
||||||
let mut fork = None;
|
let mut fork = None;
|
||||||
|
let mut workspace_root = None;
|
||||||
|
|
||||||
for (line_number, raw_line) in contents.lines().enumerate() {
|
for (line_number, raw_line) in contents.lines().enumerate() {
|
||||||
let line = raw_line.trim();
|
let line = raw_line.trim();
|
||||||
@@ -356,6 +395,10 @@ impl Session {
|
|||||||
created_at_ms = Some(required_u64(object, "created_at_ms")?);
|
created_at_ms = Some(required_u64(object, "created_at_ms")?);
|
||||||
updated_at_ms = Some(required_u64(object, "updated_at_ms")?);
|
updated_at_ms = Some(required_u64(object, "updated_at_ms")?);
|
||||||
fork = object.get("fork").map(SessionFork::from_json).transpose()?;
|
fork = object.get("fork").map(SessionFork::from_json).transpose()?;
|
||||||
|
workspace_root = object
|
||||||
|
.get("workspace_root")
|
||||||
|
.and_then(JsonValue::as_str)
|
||||||
|
.map(PathBuf::from);
|
||||||
}
|
}
|
||||||
"message" => {
|
"message" => {
|
||||||
let message_value = object.get("message").ok_or_else(|| {
|
let message_value = object.get("message").ok_or_else(|| {
|
||||||
@@ -389,6 +432,7 @@ impl Session {
|
|||||||
messages,
|
messages,
|
||||||
compaction,
|
compaction,
|
||||||
fork,
|
fork,
|
||||||
|
workspace_root,
|
||||||
persistence: None,
|
persistence: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -449,6 +493,12 @@ impl Session {
|
|||||||
if let Some(fork) = &self.fork {
|
if let Some(fork) = &self.fork {
|
||||||
object.insert("fork".to_string(), fork.to_json());
|
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))
|
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")))
|
.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> {
|
fn normalize_optional_string(value: Option<String>) -> Option<String> {
|
||||||
value.and_then(|value| {
|
value.and_then(|value| {
|
||||||
let trimmed = value.trim();
|
let trimmed = value.trim();
|
||||||
@@ -1206,6 +1265,29 @@ mod tests {
|
|||||||
assert!(error.to_string().contains("unsupported block type"));
|
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 {
|
fn temp_session_path(label: &str) -> PathBuf {
|
||||||
let nanos = SystemTime::now()
|
let nanos = SystemTime::now()
|
||||||
.duration_since(UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
|
|||||||
@@ -24,10 +24,10 @@ use std::thread::{self, JoinHandle};
|
|||||||
use std::time::{Duration, Instant, UNIX_EPOCH};
|
use std::time::{Duration, Instant, UNIX_EPOCH};
|
||||||
|
|
||||||
use api::{
|
use api::{
|
||||||
oauth_token_is_expired, resolve_startup_auth_source, AnthropicClient, AuthSource,
|
detect_provider_kind, oauth_token_is_expired, resolve_startup_auth_source, AnthropicClient,
|
||||||
ContentBlockDelta, InputContentBlock, InputMessage, MessageRequest, MessageResponse,
|
AuthSource, ContentBlockDelta, InputContentBlock, InputMessage, MessageRequest,
|
||||||
OutputContentBlock, PromptCache, StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition,
|
MessageResponse, OutputContentBlock, PromptCache, ProviderKind, StreamEvent as ApiStreamEvent,
|
||||||
ToolResultContentBlock,
|
ToolChoice, ToolDefinition, ToolResultContentBlock,
|
||||||
};
|
};
|
||||||
|
|
||||||
use commands::{
|
use commands::{
|
||||||
@@ -815,6 +815,46 @@ fn config_permission_mode_for_current_dir() -> Option<PermissionMode> {
|
|||||||
.map(permission_mode_from_resolved)
|
.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(
|
fn filter_tool_specs(
|
||||||
tool_registry: &GlobalToolRegistry,
|
tool_registry: &GlobalToolRegistry,
|
||||||
allowed_tools: Option<&AllowedToolSet>,
|
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}");
|
println!("Listening for callback on {redirect_uri}");
|
||||||
}
|
}
|
||||||
if let Err(error) = open_browser(&authorize_url) {
|
if let Err(error) = open_browser(&authorize_url) {
|
||||||
eprintln!("warning: failed to open browser automatically: {error}");
|
emit_login_browser_open_failure(
|
||||||
println!("Open this URL manually:\n{authorize_url}");
|
output_format,
|
||||||
|
&authorize_url,
|
||||||
|
&error,
|
||||||
|
&mut io::stdout(),
|
||||||
|
&mut io::stderr(),
|
||||||
|
)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let callback = wait_for_oauth_callback(callback_port)?;
|
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(())
|
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>> {
|
fn run_logout(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
clear_oauth_credentials()?;
|
clear_oauth_credentials()?;
|
||||||
match output_format {
|
match output_format {
|
||||||
@@ -2420,10 +2482,12 @@ fn run_repl(
|
|||||||
allowed_tools: Option<AllowedToolSet>,
|
allowed_tools: Option<AllowedToolSet>,
|
||||||
permission_mode: PermissionMode,
|
permission_mode: PermissionMode,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> 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 =
|
let mut editor =
|
||||||
input::LineEditor::new("> ", cli.repl_completion_candidates().unwrap_or_default());
|
input::LineEditor::new("> ", cli.repl_completion_candidates().unwrap_or_default());
|
||||||
println!("{}", cli.startup_banner());
|
println!("{}", cli.startup_banner());
|
||||||
|
println!("{}", format_connected_line(&cli.model));
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
editor.set_completions(cli.repl_completion_candidates().unwrap_or_default());
|
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>> {
|
fn resolve_cli_auth_source() -> Result<AuthSource, Box<dyn std::error::Error>> {
|
||||||
Ok(resolve_startup_auth_source(|| {
|
let cwd = env::current_dir()?;
|
||||||
let cwd = env::current_dir().map_err(api::ApiError::from)?;
|
Ok(resolve_cli_auth_source_for_cwd(&cwd, default_oauth_config)?)
|
||||||
let config = ConfigLoader::default_for(&cwd).load().map_err(|error| {
|
}
|
||||||
api::ApiError::Auth(format!("failed to load runtime OAuth config: {error}"))
|
|
||||||
})?;
|
fn resolve_cli_auth_source_for_cwd<F>(
|
||||||
Ok(config.oauth().cloned())
|
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 {
|
impl ApiClient for AnthropicRuntimeClient {
|
||||||
@@ -5632,6 +5712,7 @@ impl ApiClient for AnthropicRuntimeClient {
|
|||||||
let mut markdown_stream = MarkdownStreamState::default();
|
let mut markdown_stream = MarkdownStreamState::default();
|
||||||
let mut events = Vec::new();
|
let mut events = Vec::new();
|
||||||
let mut pending_tool: Option<(String, String, String)> = None;
|
let mut pending_tool: Option<(String, String, String)> = None;
|
||||||
|
let mut block_has_thinking_summary = false;
|
||||||
let mut saw_stop = false;
|
let mut saw_stop = false;
|
||||||
|
|
||||||
while let Some(event) = stream.next_event().await.map_err(|error| {
|
while let Some(event) = stream.next_event().await.map_err(|error| {
|
||||||
@@ -5640,7 +5721,14 @@ impl ApiClient for AnthropicRuntimeClient {
|
|||||||
match event {
|
match event {
|
||||||
ApiStreamEvent::MessageStart(start) => {
|
ApiStreamEvent::MessageStart(start) => {
|
||||||
for block in start.message.content {
|
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) => {
|
ApiStreamEvent::ContentBlockStart(start) => {
|
||||||
@@ -5650,6 +5738,7 @@ impl ApiClient for AnthropicRuntimeClient {
|
|||||||
&mut events,
|
&mut events,
|
||||||
&mut pending_tool,
|
&mut pending_tool,
|
||||||
true,
|
true,
|
||||||
|
&mut block_has_thinking_summary,
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
ApiStreamEvent::ContentBlockDelta(delta) => match delta.delta {
|
ApiStreamEvent::ContentBlockDelta(delta) => match delta.delta {
|
||||||
@@ -5671,10 +5760,16 @@ impl ApiClient for AnthropicRuntimeClient {
|
|||||||
input.push_str(&partial_json);
|
input.push_str(&partial_json);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ContentBlockDelta::ThinkingDelta { .. }
|
ContentBlockDelta::ThinkingDelta { .. } => {
|
||||||
| ContentBlockDelta::SignatureDelta { .. } => {}
|
if !block_has_thinking_summary {
|
||||||
|
render_thinking_block_summary(out, None, false)?;
|
||||||
|
block_has_thinking_summary = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ContentBlockDelta::SignatureDelta { .. } => {}
|
||||||
},
|
},
|
||||||
ApiStreamEvent::ContentBlockStop(_) => {
|
ApiStreamEvent::ContentBlockStop(_) => {
|
||||||
|
block_has_thinking_summary = false;
|
||||||
if let Some(rendered) = markdown_stream.flush(&renderer) {
|
if let Some(rendered) = markdown_stream.flush(&renderer) {
|
||||||
write!(out, "{rendered}")
|
write!(out, "{rendered}")
|
||||||
.and_then(|()| out.flush())
|
.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 {
|
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)
|
format_context_window_blocked_error(session_id, error)
|
||||||
} else if error.is_generic_fatal_wrapper() {
|
} else if error.is_generic_fatal_wrapper() {
|
||||||
let mut qualifiers = vec![format!("session {session_id}")];
|
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,
|
context_window_tokens,
|
||||||
} => {
|
} => {
|
||||||
lines.push(format!(" Model {model}"));
|
lines.push(format!(" Model {model}"));
|
||||||
lines.push(format!(" Estimated input {estimated_input_tokens}"));
|
lines.push(format!(" Input estimate ~{estimated_input_tokens} tokens (heuristic)"));
|
||||||
lines.push(format!(" Requested output {requested_output_tokens}"));
|
lines.push(format!(" Requested output {requested_output_tokens} tokens"));
|
||||||
lines.push(format!(" Estimated total {estimated_total_tokens}"));
|
lines.push(format!(" Total estimate ~{estimated_total_tokens} tokens (heuristic)"));
|
||||||
lines.push(format!(" Context window {context_window_tokens}"));
|
lines.push(format!(" Context window {context_window_tokens} tokens"));
|
||||||
}
|
}
|
||||||
api::ApiError::Api { message, body, .. } => {
|
api::ApiError::Api { message, body, .. } => {
|
||||||
let detail = message.as_deref().unwrap_or(body).trim();
|
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
|
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(
|
fn push_output_block(
|
||||||
block: OutputContentBlock,
|
block: OutputContentBlock,
|
||||||
out: &mut (impl Write + ?Sized),
|
out: &mut (impl Write + ?Sized),
|
||||||
events: &mut Vec<AssistantEvent>,
|
events: &mut Vec<AssistantEvent>,
|
||||||
pending_tool: &mut Option<(String, String, String)>,
|
pending_tool: &mut Option<(String, String, String)>,
|
||||||
streaming_tool_input: bool,
|
streaming_tool_input: bool,
|
||||||
|
block_has_thinking_summary: &mut bool,
|
||||||
) -> Result<(), RuntimeError> {
|
) -> Result<(), RuntimeError> {
|
||||||
match block {
|
match block {
|
||||||
OutputContentBlock::Text { text } => {
|
OutputContentBlock::Text { text } => {
|
||||||
@@ -6440,7 +6553,14 @@ fn push_output_block(
|
|||||||
};
|
};
|
||||||
*pending_tool = Some((id, name, initial_input));
|
*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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -6453,7 +6573,15 @@ fn response_to_events(
|
|||||||
let mut pending_tool = None;
|
let mut pending_tool = None;
|
||||||
|
|
||||||
for block in response.content {
|
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() {
|
if let Some((id, name, input)) = pending_tool.take() {
|
||||||
events.push(AssistantEvent::ToolUse { id, name, input });
|
events.push(AssistantEvent::ToolUse { id, name, input });
|
||||||
}
|
}
|
||||||
@@ -6819,17 +6947,17 @@ mod tests {
|
|||||||
build_runtime_plugin_state_with_loader, build_runtime_with_plugin_state,
|
build_runtime_plugin_state_with_loader, build_runtime_with_plugin_state,
|
||||||
create_managed_session_handle, describe_tool_progress, filter_tool_specs,
|
create_managed_session_handle, describe_tool_progress, filter_tool_specs,
|
||||||
format_bughunter_report, format_commit_preflight_report, format_commit_skipped_report,
|
format_bughunter_report, format_commit_preflight_report, format_commit_skipped_report,
|
||||||
format_compact_report, format_cost_report, format_internal_prompt_progress_line,
|
format_compact_report, format_connected_line, format_cost_report,
|
||||||
format_issue_report, format_model_report, format_model_switch_report,
|
format_internal_prompt_progress_line, format_issue_report, format_model_report,
|
||||||
format_permissions_report, format_permissions_switch_report, format_pr_report,
|
format_model_switch_report, format_permissions_report, format_permissions_switch_report,
|
||||||
format_resume_report, format_status_report, format_tool_call_start, format_tool_result,
|
format_pr_report, format_resume_report, format_status_report, format_tool_call_start,
|
||||||
format_ultraplan_report, format_unknown_slash_command,
|
format_tool_result, 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,
|
||||||
normalize_permission_mode, parse_args, parse_git_status_branch,
|
normalize_permission_mode, parse_args, parse_git_status_branch,
|
||||||
parse_git_status_metadata_for, parse_git_workspace_summary, permission_policy,
|
parse_git_status_metadata_for, parse_git_workspace_summary, permission_policy,
|
||||||
print_help_to, push_output_block, render_config_report, render_diff_report,
|
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,
|
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,
|
resume_supported_slash_commands, run_resume_command,
|
||||||
slash_command_completion_candidates_with_sessions, status_context, validate_no_args,
|
slash_command_completion_candidates_with_sessions, status_context, validate_no_args,
|
||||||
write_mcp_server_fixture, CliAction, CliOutputFormat, CliToolExecutor, GitWorkspaceSummary,
|
write_mcp_server_fixture, CliAction, CliOutputFormat, CliToolExecutor, GitWorkspaceSummary,
|
||||||
@@ -6841,14 +6969,17 @@ mod tests {
|
|||||||
PluginManager, PluginManagerConfig, PluginTool, PluginToolDefinition, PluginToolPermission,
|
PluginManager, PluginManagerConfig, PluginTool, PluginToolDefinition, PluginToolPermission,
|
||||||
};
|
};
|
||||||
use runtime::{
|
use runtime::{
|
||||||
AssistantEvent, ConfigLoader, ContentBlock, ConversationMessage, MessageRole,
|
load_oauth_credentials, save_oauth_credentials, AssistantEvent, ConfigLoader, ContentBlock,
|
||||||
PermissionMode, Session, ToolExecutor,
|
ConversationMessage, MessageRole, OAuthConfig, PermissionMode, Session, ToolExecutor,
|
||||||
};
|
};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::net::TcpListener;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
use std::sync::{Mutex, MutexGuard, OnceLock};
|
use std::sync::{Mutex, MutexGuard, OnceLock};
|
||||||
|
use std::thread;
|
||||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
use tools::GlobalToolRegistry;
|
use tools::GlobalToolRegistry;
|
||||||
|
|
||||||
@@ -6940,7 +7071,14 @@ mod tests {
|
|||||||
rendered.contains("Model claude-sonnet-4-6"),
|
rendered.contains("Model claude-sonnet-4-6"),
|
||||||
"{rendered}"
|
"{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("Compact /compact"), "{rendered}");
|
||||||
assert!(
|
assert!(
|
||||||
rendered.contains("Resume compact claw --resume session-issue-32 /compact"),
|
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 {
|
fn temp_dir() -> PathBuf {
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
@@ -7018,6 +7189,36 @@ mod tests {
|
|||||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
.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 {
|
fn with_current_dir<T>(cwd: &Path, f: impl FnOnce() -> T) -> T {
|
||||||
let _guard = cwd_lock()
|
let _guard = cwd_lock()
|
||||||
.lock()
|
.lock()
|
||||||
@@ -7156,6 +7357,78 @@ mod tests {
|
|||||||
assert_eq!(resolved, PermissionMode::ReadOnly);
|
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]
|
#[test]
|
||||||
fn parses_prompt_subcommand() {
|
fn parses_prompt_subcommand() {
|
||||||
let _guard = env_lock();
|
let _guard = env_lock();
|
||||||
@@ -7554,6 +7827,17 @@ mod tests {
|
|||||||
output_format: CliOutputFormat::Text,
|
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()])
|
let error = parse_args(&["/status".to_string()])
|
||||||
.expect_err("/status should remain REPL-only when invoked directly");
|
.expect_err("/status should remain REPL-only when invoked directly");
|
||||||
assert!(error.contains("interactive-only"));
|
assert!(error.contains("interactive-only"));
|
||||||
@@ -7826,6 +8110,73 @@ mod tests {
|
|||||||
std::env::remove_var("ANTHROPIC_API_KEY");
|
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]
|
#[test]
|
||||||
fn resume_supported_command_list_matches_expected_surface() {
|
fn resume_supported_command_list_matches_expected_surface() {
|
||||||
let names = resume_supported_slash_commands()
|
let names = resume_supported_slash_commands()
|
||||||
@@ -8636,6 +8987,7 @@ UU conflicted.rs",
|
|||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
let mut events = Vec::new();
|
let mut events = Vec::new();
|
||||||
let mut pending_tool = None;
|
let mut pending_tool = None;
|
||||||
|
let mut block_has_thinking_summary = false;
|
||||||
|
|
||||||
push_output_block(
|
push_output_block(
|
||||||
OutputContentBlock::Text {
|
OutputContentBlock::Text {
|
||||||
@@ -8645,6 +8997,7 @@ UU conflicted.rs",
|
|||||||
&mut events,
|
&mut events,
|
||||||
&mut pending_tool,
|
&mut pending_tool,
|
||||||
false,
|
false,
|
||||||
|
&mut block_has_thinking_summary,
|
||||||
)
|
)
|
||||||
.expect("text block should render");
|
.expect("text block should render");
|
||||||
|
|
||||||
@@ -8658,6 +9011,7 @@ UU conflicted.rs",
|
|||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
let mut events = Vec::new();
|
let mut events = Vec::new();
|
||||||
let mut pending_tool = None;
|
let mut pending_tool = None;
|
||||||
|
let mut block_has_thinking_summary = false;
|
||||||
|
|
||||||
push_output_block(
|
push_output_block(
|
||||||
OutputContentBlock::ToolUse {
|
OutputContentBlock::ToolUse {
|
||||||
@@ -8669,6 +9023,7 @@ UU conflicted.rs",
|
|||||||
&mut events,
|
&mut events,
|
||||||
&mut pending_tool,
|
&mut pending_tool,
|
||||||
true,
|
true,
|
||||||
|
&mut block_has_thinking_summary,
|
||||||
)
|
)
|
||||||
.expect("tool block should accumulate");
|
.expect("tool block should accumulate");
|
||||||
|
|
||||||
@@ -8750,7 +9105,7 @@ UU conflicted.rs",
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn response_to_events_ignores_thinking_blocks() {
|
fn response_to_events_renders_collapsed_thinking_summary() {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
let events = response_to_events(
|
let events = response_to_events(
|
||||||
MessageResponse {
|
MessageResponse {
|
||||||
@@ -8785,7 +9140,34 @@ UU conflicted.rs",
|
|||||||
&events[0],
|
&events[0],
|
||||||
AssistantEvent::TextDelta(text) if text == "Final answer"
|
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]
|
#[test]
|
||||||
|
|||||||
@@ -2974,7 +2974,263 @@ fn todo_store_path() -> Result<std::path::PathBuf, String> {
|
|||||||
|
|
||||||
fn resolve_skill_path(skill: &str) -> 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())?;
|
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";
|
const DEFAULT_AGENT_MODEL: &str = "claude-opus-4-6";
|
||||||
|
|||||||
Reference in New Issue
Block a user