mirror of
https://github.com/instructkr/claw-code.git
synced 2026-06-10 11:09:40 -04:00
Compare commits
1 Commits
ac45bbec15
...
fix/linux-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61fd7cfec5 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -5,8 +5,3 @@ archive/
|
|||||||
# Claude Code local artifacts
|
# Claude Code local artifacts
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
.claude/sessions/
|
.claude/sessions/
|
||||||
# Claw Code local artifacts
|
|
||||||
.claw/settings.local.json
|
|
||||||
.claw/sessions/
|
|
||||||
.clawhip/
|
|
||||||
status-help.txt
|
|
||||||
|
|||||||
60
README.md
60
README.md
@@ -33,8 +33,6 @@ The canonical implementation lives in [`rust/`](./rust), and the current source
|
|||||||
|
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
> Start with [`USAGE.md`](./USAGE.md) for build, auth, CLI, session, and parity-harness workflows. Make `claw doctor` your first health check after building, use [`rust/README.md`](./rust/README.md) for crate-level details, read [`PARITY.md`](./PARITY.md) for the current Rust-port checkpoint, and see [`docs/container.md`](./docs/container.md) for the container-first workflow.
|
> Start with [`USAGE.md`](./USAGE.md) for build, auth, CLI, session, and parity-harness workflows. Make `claw doctor` your first health check after building, use [`rust/README.md`](./rust/README.md) for crate-level details, read [`PARITY.md`](./PARITY.md) for the current Rust-port checkpoint, and see [`docs/container.md`](./docs/container.md) for the container-first workflow.
|
||||||
>
|
|
||||||
> **ACP / Zed status:** `claw-code` does not ship an ACP/Zed daemon entrypoint yet. Run `claw acp` (or `claw --acp`) for the current status instead of guessing from source layout; `claw acp serve` is currently a discoverability alias only, and real ACP support remains tracked separately in `ROADMAP.md`.
|
|
||||||
|
|
||||||
## Current repository shape
|
## Current repository shape
|
||||||
|
|
||||||
@@ -47,59 +45,21 @@ The canonical implementation lives in [`rust/`](./rust), and the current source
|
|||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> [!WARNING]
|
|
||||||
> **`cargo install claw-code` installs the wrong thing.** The `claw-code` crate on crates.io is a deprecated stub that places `claw-code-deprecated.exe` — not `claw`. Running it only prints `"claw-code has been renamed to agent-code"`. **Do not use `cargo install claw-code`.** Either build from source (this repo) or install the upstream binary:
|
|
||||||
> ```bash
|
|
||||||
> cargo install agent-code # upstream binary — installs 'agent.exe' (Windows) / 'agent' (Unix), NOT 'agent-code'
|
|
||||||
> ```
|
|
||||||
> This repo (`ultraworkers/claw-code`) is **build-from-source only** — follow the steps below.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Clone and build
|
cd rust
|
||||||
git clone https://github.com/ultraworkers/claw-code
|
|
||||||
cd claw-code/rust
|
|
||||||
cargo build --workspace
|
cargo build --workspace
|
||||||
|
./target/debug/claw --help
|
||||||
# 2. Set your API key (Anthropic API key — not a Claude subscription)
|
./target/debug/claw prompt "summarize this repository"
|
||||||
export ANTHROPIC_API_KEY="sk-ant-..."
|
|
||||||
|
|
||||||
# 3. Verify everything is wired correctly
|
|
||||||
./target/debug/claw doctor
|
|
||||||
|
|
||||||
# 4. Run a prompt
|
|
||||||
./target/debug/claw prompt "say hello"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
> [!NOTE]
|
Authenticate with either an API key or the built-in OAuth flow:
|
||||||
> **Windows (PowerShell):** the binary is `claw.exe`, not `claw`. Use `.\target\debug\claw.exe` or run `cargo run -- prompt "say hello"` to skip the path lookup.
|
|
||||||
|
|
||||||
### Windows setup
|
```bash
|
||||||
|
export ANTHROPIC_API_KEY="sk-ant-..."
|
||||||
**PowerShell is a supported Windows path.** Use whichever shell works for you. The common onboarding issues on Windows are:
|
# or
|
||||||
|
cd rust
|
||||||
1. **Install Rust first** — download from <https://rustup.rs/> and run the installer. Close and reopen your terminal when it finishes.
|
./target/debug/claw login
|
||||||
2. **Verify Rust is on PATH:**
|
```
|
||||||
```powershell
|
|
||||||
cargo --version
|
|
||||||
```
|
|
||||||
If this fails, reopen your terminal or run the PATH setup from the Rust installer output, then retry.
|
|
||||||
3. **Clone and build** (works in PowerShell, Git Bash, or WSL):
|
|
||||||
```powershell
|
|
||||||
git clone https://github.com/ultraworkers/claw-code
|
|
||||||
cd claw-code/rust
|
|
||||||
cargo build --workspace
|
|
||||||
```
|
|
||||||
4. **Run** (PowerShell — note `.exe` and backslash):
|
|
||||||
```powershell
|
|
||||||
$env:ANTHROPIC_API_KEY = "sk-ant-..."
|
|
||||||
.\target\debug\claw.exe prompt "say hello"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Git Bash / WSL** are optional alternatives, not requirements. If you prefer bash-style paths (`/c/Users/you/...` instead of `C:\Users\you\...`), Git Bash (ships with Git for Windows) works well. In Git Bash, the `MINGW64` prompt is expected and normal — not a broken install.
|
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> **Auth:** claw requires an **API key** (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, etc.) — Claude subscription login is not a supported auth path.
|
|
||||||
|
|
||||||
Run the workspace test suite:
|
Run the workspace test suite:
|
||||||
|
|
||||||
|
|||||||
816
ROADMAP.md
816
ROADMAP.md
File diff suppressed because one or more lines are too long
11
USAGE.md
11
USAGE.md
@@ -21,7 +21,7 @@ cargo build --workspace
|
|||||||
- Rust toolchain with `cargo`
|
- Rust toolchain with `cargo`
|
||||||
- One of:
|
- One of:
|
||||||
- `ANTHROPIC_API_KEY` for direct API access
|
- `ANTHROPIC_API_KEY` for direct API access
|
||||||
- `ANTHROPIC_AUTH_TOKEN` for bearer-token auth
|
- `claw login` for OAuth-based auth
|
||||||
- Optional: `ANTHROPIC_BASE_URL` when targeting a proxy or local service
|
- Optional: `ANTHROPIC_BASE_URL` when targeting a proxy or local service
|
||||||
|
|
||||||
## Install / build the workspace
|
## Install / build the workspace
|
||||||
@@ -105,7 +105,8 @@ export ANTHROPIC_API_KEY="sk-ant-..."
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd rust
|
cd rust
|
||||||
export ANTHROPIC_AUTH_TOKEN="anthropic-oauth-or-proxy-bearer-token"
|
./target/debug/claw login
|
||||||
|
./target/debug/claw logout
|
||||||
```
|
```
|
||||||
|
|
||||||
### Which env var goes where
|
### Which env var goes where
|
||||||
@@ -115,7 +116,7 @@ export ANTHROPIC_AUTH_TOKEN="anthropic-oauth-or-proxy-bearer-token"
|
|||||||
| Credential shape | Env var | HTTP header | Typical source |
|
| Credential shape | Env var | HTTP header | Typical source |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `sk-ant-*` API key | `ANTHROPIC_API_KEY` | `x-api-key: sk-ant-...` | [console.anthropic.com](https://console.anthropic.com) |
|
| `sk-ant-*` API key | `ANTHROPIC_API_KEY` | `x-api-key: sk-ant-...` | [console.anthropic.com](https://console.anthropic.com) |
|
||||||
| OAuth access token (opaque) | `ANTHROPIC_AUTH_TOKEN` | `Authorization: Bearer ...` | an Anthropic-compatible proxy or OAuth flow that mints bearer tokens |
|
| OAuth access token (opaque) | `ANTHROPIC_AUTH_TOKEN` | `Authorization: Bearer ...` | `claw login` or an Anthropic-compatible proxy that mints Bearer tokens |
|
||||||
| OpenRouter key (`sk-or-v1-*`) | `OPENAI_API_KEY` + `OPENAI_BASE_URL=https://openrouter.ai/api/v1` | `Authorization: Bearer ...` | [openrouter.ai/keys](https://openrouter.ai/keys) |
|
| OpenRouter key (`sk-or-v1-*`) | `OPENAI_API_KEY` + `OPENAI_BASE_URL=https://openrouter.ai/api/v1` | `Authorization: Bearer ...` | [openrouter.ai/keys](https://openrouter.ai/keys) |
|
||||||
|
|
||||||
**Why this matters:** if you paste an `sk-ant-*` key into `ANTHROPIC_AUTH_TOKEN`, Anthropic's API will return `401 Invalid bearer token` because `sk-ant-*` keys are rejected over the Bearer header. The fix is a one-line env var swap — move the key to `ANTHROPIC_API_KEY`. Recent `claw` builds detect this exact shape (401 + `sk-ant-*` in the Bearer slot) and append a hint to the error message pointing at the fix.
|
**Why this matters:** if you paste an `sk-ant-*` key into `ANTHROPIC_AUTH_TOKEN`, Anthropic's API will return `401 Invalid bearer token` because `sk-ant-*` keys are rejected over the Bearer header. The fix is a one-line env var swap — move the key to `ANTHROPIC_API_KEY`. Recent `claw` builds detect this exact shape (401 + `sk-ant-*` in the Bearer slot) and append a hint to the error message pointing at the fix.
|
||||||
@@ -124,7 +125,7 @@ export ANTHROPIC_AUTH_TOKEN="anthropic-oauth-or-proxy-bearer-token"
|
|||||||
|
|
||||||
## Local Models
|
## 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.
|
`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
|
### Anthropic-compatible endpoint
|
||||||
|
|
||||||
@@ -191,7 +192,7 @@ Reasoning variants (`qwen-qwq-*`, `qwq-*`, `*-thinking`) automatically strip `te
|
|||||||
|
|
||||||
| Provider | Protocol | Auth env var(s) | Base URL env var | Default base URL |
|
| Provider | Protocol | Auth env var(s) | Base URL env var | Default base URL |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| **Anthropic** (direct) | Anthropic Messages API | `ANTHROPIC_API_KEY` or `ANTHROPIC_AUTH_TOKEN` | `ANTHROPIC_BASE_URL` | `https://api.anthropic.com` |
|
| **Anthropic** (direct) | Anthropic Messages API | `ANTHROPIC_API_KEY` or `ANTHROPIC_AUTH_TOKEN` or OAuth (`claw login`) | `ANTHROPIC_BASE_URL` | `https://api.anthropic.com` |
|
||||||
| **xAI** | OpenAI-compatible | `XAI_API_KEY` | `XAI_BASE_URL` | `https://api.x.ai/v1` |
|
| **xAI** | OpenAI-compatible | `XAI_API_KEY` | `XAI_BASE_URL` | `https://api.x.ai/v1` |
|
||||||
| **OpenAI-compatible** | OpenAI Chat Completions | `OPENAI_API_KEY` | `OPENAI_BASE_URL` | `https://api.openai.com/v1` |
|
| **OpenAI-compatible** | OpenAI Chat Completions | `OPENAI_API_KEY` | `OPENAI_BASE_URL` | `https://api.openai.com/v1` |
|
||||||
| **DashScope** (Alibaba) | OpenAI-compatible | `DASHSCOPE_API_KEY` | `DASHSCOPE_BASE_URL` | `https://dashscope.aliyuncs.com/compatible-mode/v1` |
|
| **DashScope** (Alibaba) | OpenAI-compatible | `DASHSCOPE_API_KEY` | `DASHSCOPE_BASE_URL` | `https://dashscope.aliyuncs.com/compatible-mode/v1` |
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
{"created_at_ms":1775777421902,"session_id":"session-1775777421902-1","type":"session_meta","updated_at_ms":1775777421902,"version":1}
|
{"created_at_ms":1775386832313,"session_id":"session-1775386832313-0","type":"session_meta","updated_at_ms":1775386832313,"version":1}
|
||||||
|
{"message":{"blocks":[{"text":"status --help","type":"text"}],"role":"user"},"type":"message"}
|
||||||
|
|||||||
@@ -34,10 +34,10 @@ export ANTHROPIC_API_KEY="sk-ant-..."
|
|||||||
export ANTHROPIC_BASE_URL="https://your-proxy.com"
|
export ANTHROPIC_BASE_URL="https://your-proxy.com"
|
||||||
```
|
```
|
||||||
|
|
||||||
Or provide an OAuth bearer token directly:
|
Or authenticate via OAuth and let the CLI persist credentials locally:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export ANTHROPIC_AUTH_TOKEN="anthropic-oauth-or-proxy-bearer-token"
|
cargo run -p rusty-claude-cli -- login
|
||||||
```
|
```
|
||||||
|
|
||||||
## Mock parity harness
|
## Mock parity harness
|
||||||
@@ -80,7 +80,7 @@ Primary artifacts:
|
|||||||
| Feature | Status |
|
| Feature | Status |
|
||||||
|---------|--------|
|
|---------|--------|
|
||||||
| Anthropic / OpenAI-compatible provider flows + streaming | ✅ |
|
| Anthropic / OpenAI-compatible provider flows + streaming | ✅ |
|
||||||
| Direct bearer-token auth via `ANTHROPIC_AUTH_TOKEN` | ✅ |
|
| OAuth login/logout | ✅ |
|
||||||
| Interactive REPL (rustyline) | ✅ |
|
| Interactive REPL (rustyline) | ✅ |
|
||||||
| Tool system (bash, read, write, edit, grep, glob) | ✅ |
|
| Tool system (bash, read, write, edit, grep, glob) | ✅ |
|
||||||
| Web tools (search, fetch) | ✅ |
|
| Web tools (search, fetch) | ✅ |
|
||||||
@@ -135,18 +135,17 @@ Top-level commands:
|
|||||||
version
|
version
|
||||||
status
|
status
|
||||||
sandbox
|
sandbox
|
||||||
acp [serve]
|
|
||||||
dump-manifests
|
dump-manifests
|
||||||
bootstrap-plan
|
bootstrap-plan
|
||||||
agents
|
agents
|
||||||
mcp
|
mcp
|
||||||
skills
|
skills
|
||||||
system-prompt
|
system-prompt
|
||||||
|
login
|
||||||
|
logout
|
||||||
init
|
init
|
||||||
```
|
```
|
||||||
|
|
||||||
`claw acp` is a local discoverability surface for editor-first users: it reports the current ACP/Zed status without starting the runtime. As of April 16, 2026, claw-code does **not** ship an ACP/Zed daemon entrypoint yet, and `claw acp serve` is only a status alias until the real protocol surface lands.
|
|
||||||
|
|
||||||
The command surface is moving quickly. For the canonical live help text, run:
|
The command surface is moving quickly. For the canonical live help text, run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -160,8 +159,8 @@ Tab completion expands slash commands, model aliases, permission modes, and rece
|
|||||||
The REPL now exposes a much broader surface than the original minimal shell:
|
The REPL now exposes a much broader surface than the original minimal shell:
|
||||||
|
|
||||||
- session / visibility: `/help`, `/status`, `/sandbox`, `/cost`, `/resume`, `/session`, `/version`, `/usage`, `/stats`
|
- session / visibility: `/help`, `/status`, `/sandbox`, `/cost`, `/resume`, `/session`, `/version`, `/usage`, `/stats`
|
||||||
- workspace / git: `/compact`, `/clear`, `/config`, `/memory`, `/init`, `/diff`, `/commit`, `/pr`, `/issue`, `/export`, `/hooks`, `/files`, `/release-notes`
|
- workspace / git: `/compact`, `/clear`, `/config`, `/memory`, `/init`, `/diff`, `/commit`, `/pr`, `/issue`, `/export`, `/hooks`, `/files`, `/branch`, `/release-notes`, `/add-dir`
|
||||||
- discovery / debugging: `/mcp`, `/agents`, `/skills`, `/doctor`, `/tasks`, `/context`, `/desktop`
|
- discovery / debugging: `/mcp`, `/agents`, `/skills`, `/doctor`, `/tasks`, `/context`, `/desktop`, `/ide`
|
||||||
- automation / analysis: `/review`, `/advisor`, `/insights`, `/security-review`, `/subagent`, `/team`, `/telemetry`, `/providers`, `/cron`, and more
|
- automation / analysis: `/review`, `/advisor`, `/insights`, `/security-review`, `/subagent`, `/team`, `/telemetry`, `/providers`, `/cron`, and more
|
||||||
- plugin management: `/plugin` (with aliases `/plugins`, `/marketplace`)
|
- plugin management: `/plugin` (with aliases `/plugins`, `/marketplace`)
|
||||||
|
|
||||||
@@ -195,7 +194,7 @@ rust/
|
|||||||
|
|
||||||
### Crate Responsibilities
|
### Crate Responsibilities
|
||||||
|
|
||||||
- **api** — provider clients, SSE streaming, request/response types, auth (`ANTHROPIC_API_KEY` + bearer-token support), request-size/context-window preflight
|
- **api** — provider clients, SSE streaming, request/response types, auth (API key + OAuth bearer), request-size/context-window preflight
|
||||||
- **commands** — slash command definitions, parsing, help text generation, JSON/text command rendering
|
- **commands** — slash command definitions, parsing, help text generation, JSON/text command rendering
|
||||||
- **compat-harness** — extracts tool/prompt manifests from upstream TS source
|
- **compat-harness** — extracts tool/prompt manifests from upstream TS source
|
||||||
- **mock-anthropic-service** — deterministic `/v1/messages` mock for CLI parity tests and local harness runs
|
- **mock-anthropic-service** — deterministic `/v1/messages` mock for CLI parity tests and local harness runs
|
||||||
|
|||||||
@@ -232,7 +232,10 @@ mod tests {
|
|||||||
openai_client.base_url()
|
openai_client.base_url()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
other => panic!("Expected ProviderClient::OpenAi for qwen-plus, got: {other:?}"),
|
other => panic!(
|
||||||
|
"Expected ProviderClient::OpenAi for qwen-plus, got: {:?}",
|
||||||
|
other
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ pub enum ApiError {
|
|||||||
env_vars: &'static [&'static str],
|
env_vars: &'static [&'static str],
|
||||||
/// Optional, runtime-computed hint appended to the error Display
|
/// Optional, runtime-computed hint appended to the error Display
|
||||||
/// output. Populated when the provider resolver can infer what the
|
/// output. Populated when the provider resolver can infer what the
|
||||||
/// user probably intended (e.g. an `OpenAI` key is set but Anthropic
|
/// user probably intended (e.g. an OpenAI key is set but Anthropic
|
||||||
/// was selected because no Anthropic credentials exist).
|
/// was selected because no Anthropic credentials exist).
|
||||||
hint: Option<String>,
|
hint: Option<String>,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -88,12 +88,12 @@ pub fn build_http_client_with(config: &ProxyConfig) -> Result<reqwest::Client, A
|
|||||||
.as_deref()
|
.as_deref()
|
||||||
.and_then(reqwest::NoProxy::from_string);
|
.and_then(reqwest::NoProxy::from_string);
|
||||||
|
|
||||||
let (http_proxy_url, https_url) = match config.proxy_url.as_deref() {
|
let (http_proxy_url, https_proxy_url) = match config.proxy_url.as_deref() {
|
||||||
Some(unified) => (Some(unified), Some(unified)),
|
Some(unified) => (Some(unified), Some(unified)),
|
||||||
None => (config.http_proxy.as_deref(), config.https_proxy.as_deref()),
|
None => (config.http_proxy.as_deref(), config.https_proxy.as_deref()),
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(url) = https_url {
|
if let Some(url) = https_proxy_url {
|
||||||
let mut proxy = reqwest::Proxy::https(url)?;
|
let mut proxy = reqwest::Proxy::https(url)?;
|
||||||
if let Some(filter) = no_proxy.clone() {
|
if let Some(filter) = no_proxy.clone() {
|
||||||
proxy = proxy.no_proxy(Some(filter));
|
proxy = proxy.no_proxy(Some(filter));
|
||||||
|
|||||||
@@ -502,8 +502,9 @@ impl AnthropicClient {
|
|||||||
// Best-effort refinement using the Anthropic count_tokens endpoint.
|
// Best-effort refinement using the Anthropic count_tokens endpoint.
|
||||||
// On any failure (network, parse, auth), fall back to the local
|
// On any failure (network, parse, auth), fall back to the local
|
||||||
// byte-estimate result which already passed above.
|
// byte-estimate result which already passed above.
|
||||||
let Ok(counted_input_tokens) = self.count_tokens(request).await else {
|
let counted_input_tokens = match self.count_tokens(request).await {
|
||||||
return Ok(());
|
Ok(count) => count,
|
||||||
|
Err(_) => return Ok(()),
|
||||||
};
|
};
|
||||||
let estimated_total_tokens = counted_input_tokens.saturating_add(request.max_tokens);
|
let estimated_total_tokens = counted_input_tokens.saturating_add(request.max_tokens);
|
||||||
if estimated_total_tokens > limit.context_window_tokens {
|
if estimated_total_tokens > limit.context_window_tokens {
|
||||||
@@ -630,7 +631,21 @@ impl AuthSource {
|
|||||||
if let Some(bearer_token) = read_env_non_empty("ANTHROPIC_AUTH_TOKEN")? {
|
if let Some(bearer_token) = read_env_non_empty("ANTHROPIC_AUTH_TOKEN")? {
|
||||||
return Ok(Self::BearerToken(bearer_token));
|
return Ok(Self::BearerToken(bearer_token));
|
||||||
}
|
}
|
||||||
Err(anthropic_missing_credentials())
|
match load_saved_oauth_token() {
|
||||||
|
Ok(Some(token_set)) if oauth_token_is_expired(&token_set) => {
|
||||||
|
if token_set.refresh_token.is_some() {
|
||||||
|
Err(ApiError::Auth(
|
||||||
|
"saved OAuth token is expired; load runtime OAuth config to refresh it"
|
||||||
|
.to_string(),
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
Err(ApiError::ExpiredOAuthToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Some(token_set)) => Ok(Self::BearerToken(token_set.access_token)),
|
||||||
|
Ok(None) => Err(anthropic_missing_credentials()),
|
||||||
|
Err(error) => Err(error),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -650,14 +665,14 @@ pub fn resolve_saved_oauth_token(config: &OAuthConfig) -> Result<Option<OAuthTok
|
|||||||
|
|
||||||
pub fn has_auth_from_env_or_saved() -> Result<bool, ApiError> {
|
pub fn has_auth_from_env_or_saved() -> Result<bool, ApiError> {
|
||||||
Ok(read_env_non_empty("ANTHROPIC_API_KEY")?.is_some()
|
Ok(read_env_non_empty("ANTHROPIC_API_KEY")?.is_some()
|
||||||
|| read_env_non_empty("ANTHROPIC_AUTH_TOKEN")?.is_some())
|
|| read_env_non_empty("ANTHROPIC_AUTH_TOKEN")?.is_some()
|
||||||
|
|| load_saved_oauth_token()?.is_some())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn resolve_startup_auth_source<F>(load_oauth_config: F) -> Result<AuthSource, ApiError>
|
pub fn resolve_startup_auth_source<F>(load_oauth_config: F) -> Result<AuthSource, ApiError>
|
||||||
where
|
where
|
||||||
F: FnOnce() -> Result<Option<OAuthConfig>, ApiError>,
|
F: FnOnce() -> Result<Option<OAuthConfig>, ApiError>,
|
||||||
{
|
{
|
||||||
let _ = load_oauth_config;
|
|
||||||
if let Some(api_key) = read_env_non_empty("ANTHROPIC_API_KEY")? {
|
if let Some(api_key) = read_env_non_empty("ANTHROPIC_API_KEY")? {
|
||||||
return match read_env_non_empty("ANTHROPIC_AUTH_TOKEN")? {
|
return match read_env_non_empty("ANTHROPIC_AUTH_TOKEN")? {
|
||||||
Some(bearer_token) => Ok(AuthSource::ApiKeyAndBearer {
|
Some(bearer_token) => Ok(AuthSource::ApiKeyAndBearer {
|
||||||
@@ -670,7 +685,25 @@ where
|
|||||||
if let Some(bearer_token) = read_env_non_empty("ANTHROPIC_AUTH_TOKEN")? {
|
if let Some(bearer_token) = read_env_non_empty("ANTHROPIC_AUTH_TOKEN")? {
|
||||||
return Ok(AuthSource::BearerToken(bearer_token));
|
return Ok(AuthSource::BearerToken(bearer_token));
|
||||||
}
|
}
|
||||||
Err(anthropic_missing_credentials())
|
|
||||||
|
let Some(token_set) = load_saved_oauth_token()? else {
|
||||||
|
return Err(anthropic_missing_credentials());
|
||||||
|
};
|
||||||
|
if !oauth_token_is_expired(&token_set) {
|
||||||
|
return Ok(AuthSource::BearerToken(token_set.access_token));
|
||||||
|
}
|
||||||
|
if token_set.refresh_token.is_none() {
|
||||||
|
return Err(ApiError::ExpiredOAuthToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(config) = load_oauth_config()? else {
|
||||||
|
return Err(ApiError::Auth(
|
||||||
|
"saved OAuth token is expired; runtime OAuth config is missing".to_string(),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
Ok(AuthSource::from(resolve_saved_oauth_token_set(
|
||||||
|
&config, token_set,
|
||||||
|
)?))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_saved_oauth_token_set(
|
fn resolve_saved_oauth_token_set(
|
||||||
@@ -983,7 +1016,7 @@ fn strip_unsupported_beta_body_fields(body: &mut Value) {
|
|||||||
object.remove("presence_penalty");
|
object.remove("presence_penalty");
|
||||||
// Anthropic uses "stop_sequences" not "stop". Convert if present.
|
// Anthropic uses "stop_sequences" not "stop". Convert if present.
|
||||||
if let Some(stop_val) = object.remove("stop") {
|
if let Some(stop_val) = object.remove("stop") {
|
||||||
if stop_val.as_array().is_some_and(|a| !a.is_empty()) {
|
if stop_val.as_array().map_or(false, |a| !a.is_empty()) {
|
||||||
object.insert("stop_sequences".to_string(), stop_val);
|
object.insert("stop_sequences".to_string(), stop_val);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1147,7 +1180,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn auth_source_from_env_or_saved_ignores_saved_oauth_when_env_absent() {
|
fn auth_source_from_saved_oauth_when_env_absent() {
|
||||||
let _guard = env_lock();
|
let _guard = env_lock();
|
||||||
let config_home = temp_config_home();
|
let config_home = temp_config_home();
|
||||||
std::env::set_var("CLAW_CONFIG_HOME", &config_home);
|
std::env::set_var("CLAW_CONFIG_HOME", &config_home);
|
||||||
@@ -1161,8 +1194,8 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.expect("save oauth credentials");
|
.expect("save oauth credentials");
|
||||||
|
|
||||||
let error = AuthSource::from_env_or_saved().expect_err("saved oauth should be ignored");
|
let auth = AuthSource::from_env_or_saved().expect("saved auth");
|
||||||
assert!(error.to_string().contains("ANTHROPIC_API_KEY"));
|
assert_eq!(auth.bearer_token(), Some("saved-access-token"));
|
||||||
|
|
||||||
clear_oauth_credentials().expect("clear credentials");
|
clear_oauth_credentials().expect("clear credentials");
|
||||||
std::env::remove_var("CLAW_CONFIG_HOME");
|
std::env::remove_var("CLAW_CONFIG_HOME");
|
||||||
@@ -1218,7 +1251,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolve_startup_auth_source_ignores_saved_oauth_without_loading_config() {
|
fn resolve_startup_auth_source_uses_saved_oauth_without_loading_config() {
|
||||||
let _guard = env_lock();
|
let _guard = env_lock();
|
||||||
let config_home = temp_config_home();
|
let config_home = temp_config_home();
|
||||||
std::env::set_var("CLAW_CONFIG_HOME", &config_home);
|
std::env::set_var("CLAW_CONFIG_HOME", &config_home);
|
||||||
@@ -1232,9 +1265,41 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.expect("save oauth credentials");
|
.expect("save oauth credentials");
|
||||||
|
|
||||||
let error = resolve_startup_auth_source(|| panic!("config should not be loaded"))
|
let auth = resolve_startup_auth_source(|| panic!("config should not be loaded"))
|
||||||
.expect_err("saved oauth should be ignored");
|
.expect("startup auth");
|
||||||
assert!(error.to_string().contains("ANTHROPIC_API_KEY"));
|
assert_eq!(auth.bearer_token(), Some("saved-access-token"));
|
||||||
|
|
||||||
|
clear_oauth_credentials().expect("clear credentials");
|
||||||
|
std::env::remove_var("CLAW_CONFIG_HOME");
|
||||||
|
cleanup_temp_config_home(&config_home);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_startup_auth_source_errors_when_refreshable_token_lacks_config() {
|
||||||
|
let _guard = env_lock();
|
||||||
|
let config_home = temp_config_home();
|
||||||
|
std::env::set_var("CLAW_CONFIG_HOME", &config_home);
|
||||||
|
std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
|
||||||
|
std::env::remove_var("ANTHROPIC_API_KEY");
|
||||||
|
save_oauth_credentials(&runtime::OAuthTokenSet {
|
||||||
|
access_token: "expired-access-token".to_string(),
|
||||||
|
refresh_token: Some("refresh-token".to_string()),
|
||||||
|
expires_at: Some(1),
|
||||||
|
scopes: vec!["scope:a".to_string()],
|
||||||
|
})
|
||||||
|
.expect("save expired oauth credentials");
|
||||||
|
|
||||||
|
let error =
|
||||||
|
resolve_startup_auth_source(|| Ok(None)).expect_err("missing config should error");
|
||||||
|
assert!(
|
||||||
|
matches!(error, crate::error::ApiError::Auth(message) if message.contains("runtime OAuth config is missing"))
|
||||||
|
);
|
||||||
|
|
||||||
|
let stored = runtime::load_oauth_credentials()
|
||||||
|
.expect("load stored credentials")
|
||||||
|
.expect("stored token set");
|
||||||
|
assert_eq!(stored.access_token, "expired-access-token");
|
||||||
|
assert_eq!(stored.refresh_token.as_deref(), Some("refresh-token"));
|
||||||
|
|
||||||
clear_oauth_credentials().expect("clear credentials");
|
clear_oauth_credentials().expect("clear credentials");
|
||||||
std::env::remove_var("CLAW_CONFIG_HOME");
|
std::env::remove_var("CLAW_CONFIG_HOME");
|
||||||
|
|||||||
@@ -202,15 +202,6 @@ pub fn detect_provider_kind(model: &str) -> ProviderKind {
|
|||||||
if let Some(metadata) = metadata_for_model(model) {
|
if let Some(metadata) = metadata_for_model(model) {
|
||||||
return metadata.provider;
|
return metadata.provider;
|
||||||
}
|
}
|
||||||
// When OPENAI_BASE_URL is set, the user explicitly configured an
|
|
||||||
// OpenAI-compatible endpoint. Prefer it over the Anthropic fallback
|
|
||||||
// even when the model name has no recognized prefix — this is the
|
|
||||||
// common case for local providers (Ollama, LM Studio, vLLM, etc.)
|
|
||||||
// where model names like "qwen2.5-coder:7b" don't match any prefix.
|
|
||||||
if std::env::var_os("OPENAI_BASE_URL").is_some() && openai_compat::has_api_key("OPENAI_API_KEY")
|
|
||||||
{
|
|
||||||
return ProviderKind::OpenAi;
|
|
||||||
}
|
|
||||||
if anthropic::has_auth_from_env_or_saved().unwrap_or(false) {
|
if anthropic::has_auth_from_env_or_saved().unwrap_or(false) {
|
||||||
return ProviderKind::Anthropic;
|
return ProviderKind::Anthropic;
|
||||||
}
|
}
|
||||||
@@ -220,11 +211,6 @@ pub fn detect_provider_kind(model: &str) -> ProviderKind {
|
|||||||
if openai_compat::has_api_key("XAI_API_KEY") {
|
if openai_compat::has_api_key("XAI_API_KEY") {
|
||||||
return ProviderKind::Xai;
|
return ProviderKind::Xai;
|
||||||
}
|
}
|
||||||
// Last resort: if OPENAI_BASE_URL is set without OPENAI_API_KEY (some
|
|
||||||
// local providers like Ollama don't require auth), still route there.
|
|
||||||
if std::env::var_os("OPENAI_BASE_URL").is_some() {
|
|
||||||
return ProviderKind::OpenAi;
|
|
||||||
}
|
|
||||||
ProviderKind::Anthropic
|
ProviderKind::Anthropic
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -508,10 +494,9 @@ mod tests {
|
|||||||
// ANTHROPIC_API_KEY was set because metadata_for_model returned None
|
// ANTHROPIC_API_KEY was set because metadata_for_model returned None
|
||||||
// and detect_provider_kind fell through to auth-sniffer order.
|
// and detect_provider_kind fell through to auth-sniffer order.
|
||||||
// The model prefix must win over env-var presence.
|
// The model prefix must win over env-var presence.
|
||||||
let kind = super::metadata_for_model("openai/gpt-4.1-mini").map_or_else(
|
let kind = super::metadata_for_model("openai/gpt-4.1-mini")
|
||||||
|| detect_provider_kind("openai/gpt-4.1-mini"),
|
.map(|m| m.provider)
|
||||||
|m| m.provider,
|
.unwrap_or_else(|| detect_provider_kind("openai/gpt-4.1-mini"));
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
kind,
|
kind,
|
||||||
ProviderKind::OpenAi,
|
ProviderKind::OpenAi,
|
||||||
@@ -520,7 +505,8 @@ mod tests {
|
|||||||
|
|
||||||
// Also cover bare gpt- prefix
|
// Also cover bare gpt- prefix
|
||||||
let kind2 = super::metadata_for_model("gpt-4o")
|
let kind2 = super::metadata_for_model("gpt-4o")
|
||||||
.map_or_else(|| detect_provider_kind("gpt-4o"), |m| m.provider);
|
.map(|m| m.provider)
|
||||||
|
.unwrap_or_else(|| detect_provider_kind("gpt-4o"));
|
||||||
assert_eq!(kind2, ProviderKind::OpenAi);
|
assert_eq!(kind2, ProviderKind::OpenAi);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -995,31 +981,4 @@ NO_EQUALS_LINE
|
|||||||
"empty env var should not trigger the hint sniffer, got {hint:?}"
|
"empty env var should not trigger the hint sniffer, got {hint:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn openai_base_url_overrides_anthropic_fallback_for_unknown_model() {
|
|
||||||
// given — user has OPENAI_BASE_URL + OPENAI_API_KEY but no Anthropic
|
|
||||||
// creds, and a model name with no recognized prefix.
|
|
||||||
let _lock = env_lock();
|
|
||||||
let _base_url = EnvVarGuard::set("OPENAI_BASE_URL", Some("http://127.0.0.1:11434/v1"));
|
|
||||||
let _api_key = EnvVarGuard::set("OPENAI_API_KEY", Some("dummy"));
|
|
||||||
let _anthropic_key = EnvVarGuard::set("ANTHROPIC_API_KEY", None);
|
|
||||||
let _anthropic_token = EnvVarGuard::set("ANTHROPIC_AUTH_TOKEN", None);
|
|
||||||
|
|
||||||
// when
|
|
||||||
let provider = detect_provider_kind("qwen2.5-coder:7b");
|
|
||||||
|
|
||||||
// then — should route to OpenAI, not Anthropic
|
|
||||||
assert_eq!(
|
|
||||||
provider,
|
|
||||||
ProviderKind::OpenAi,
|
|
||||||
"OPENAI_BASE_URL should win over Anthropic fallback for unknown models"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// NOTE: a "OPENAI_BASE_URL without OPENAI_API_KEY" test is omitted
|
|
||||||
// because workspace-parallel test binaries can race on process env
|
|
||||||
// (env_lock only protects within a single binary). The detection logic
|
|
||||||
// is covered: OPENAI_BASE_URL alone routes to OpenAi as a last-resort
|
|
||||||
// fallback in detect_provider_kind().
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,10 +58,10 @@ impl OpenAiCompatConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Alibaba `DashScope` compatible-mode endpoint (Qwen family models).
|
/// Alibaba DashScope compatible-mode endpoint (Qwen family models).
|
||||||
/// Uses the OpenAI-compatible REST shape at /compatible-mode/v1.
|
/// Uses the OpenAI-compatible REST shape at /compatible-mode/v1.
|
||||||
/// Requested via Discord #clawcode-get-help: native Alibaba API for
|
/// Requested via Discord #clawcode-get-help: native Alibaba API for
|
||||||
/// higher rate limits than going through `OpenRouter`.
|
/// higher rate limits than going through OpenRouter.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub const fn dashscope() -> Self {
|
pub const fn dashscope() -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -157,35 +157,6 @@ impl OpenAiCompatClient {
|
|||||||
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 body = response.text().await.map_err(ApiError::from)?;
|
let body = response.text().await.map_err(ApiError::from)?;
|
||||||
// Some backends return {"error":{"message":"...","type":"...","code":...}}
|
|
||||||
// instead of a valid completion object. Check for this before attempting
|
|
||||||
// full deserialization so the user sees the actual error, not a cryptic
|
|
||||||
// "missing field 'id'" parse failure.
|
|
||||||
if let Ok(raw) = serde_json::from_str::<serde_json::Value>(&body) {
|
|
||||||
if let Some(err_obj) = raw.get("error") {
|
|
||||||
let msg = err_obj
|
|
||||||
.get("message")
|
|
||||||
.and_then(|m| m.as_str())
|
|
||||||
.unwrap_or("provider returned an error")
|
|
||||||
.to_string();
|
|
||||||
let code = err_obj
|
|
||||||
.get("code")
|
|
||||||
.and_then(serde_json::Value::as_u64)
|
|
||||||
.map(|c| c as u16);
|
|
||||||
return Err(ApiError::Api {
|
|
||||||
status: reqwest::StatusCode::from_u16(code.unwrap_or(400))
|
|
||||||
.unwrap_or(reqwest::StatusCode::BAD_REQUEST),
|
|
||||||
error_type: err_obj
|
|
||||||
.get("type")
|
|
||||||
.and_then(|t| t.as_str())
|
|
||||||
.map(str::to_owned),
|
|
||||||
message: Some(msg),
|
|
||||||
request_id,
|
|
||||||
body,
|
|
||||||
retryable: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let payload = serde_json::from_str::<ChatCompletionResponse>(&body).map_err(|error| {
|
let payload = serde_json::from_str::<ChatCompletionResponse>(&body).map_err(|error| {
|
||||||
ApiError::json_deserialize(self.config.provider_name, &request.model, &body, error)
|
ApiError::json_deserialize(self.config.provider_name, &request.model, &body, error)
|
||||||
})?;
|
})?;
|
||||||
@@ -284,19 +255,6 @@ impl OpenAiCompatClient {
|
|||||||
static JITTER_COUNTER: AtomicU64 = AtomicU64::new(0);
|
static JITTER_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
/// Returns a random additive jitter in `[0, base]` to decorrelate retries
|
/// Returns a random additive jitter in `[0, base]` to decorrelate retries
|
||||||
/// Deserialize a JSON field as a `Vec<T>`, treating an explicit `null` value
|
|
||||||
/// the same as a missing field (i.e. as an empty vector).
|
|
||||||
/// Some OpenAI-compatible providers emit `"tool_calls": null` instead of
|
|
||||||
/// omitting the field or using `[]`, which serde's `#[serde(default)]` alone
|
|
||||||
/// does not tolerate — `default` only handles absent keys, not null values.
|
|
||||||
fn deserialize_null_as_empty_vec<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
|
|
||||||
where
|
|
||||||
D: serde::Deserializer<'de>,
|
|
||||||
T: serde::Deserialize<'de>,
|
|
||||||
{
|
|
||||||
Ok(Option::<Vec<T>>::deserialize(deserializer)?.unwrap_or_default())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// from multiple concurrent clients. Entropy is drawn from the nanosecond
|
/// from multiple concurrent clients. Entropy is drawn from the nanosecond
|
||||||
/// wall clock mixed with a monotonic counter and run through a splitmix64
|
/// wall clock mixed with a monotonic counter and run through a splitmix64
|
||||||
/// finalizer; adequate for retry jitter (no cryptographic requirement).
|
/// finalizer; adequate for retry jitter (no cryptographic requirement).
|
||||||
@@ -715,7 +673,7 @@ struct ChunkChoice {
|
|||||||
struct ChunkDelta {
|
struct ChunkDelta {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
content: Option<String>,
|
content: Option<String>,
|
||||||
#[serde(default, deserialize_with = "deserialize_null_as_empty_vec")]
|
#[serde(default)]
|
||||||
tool_calls: Vec<DeltaToolCall>,
|
tool_calls: Vec<DeltaToolCall>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -750,7 +708,7 @@ struct ErrorBody {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Returns true for models known to reject tuning parameters like temperature,
|
/// Returns true for models known to reject tuning parameters like temperature,
|
||||||
/// `top_p`, `frequency_penalty`, and `presence_penalty`. These are typically
|
/// top_p, frequency_penalty, and presence_penalty. These are typically
|
||||||
/// reasoning/chain-of-thought models with fixed sampling.
|
/// reasoning/chain-of-thought models with fixed sampling.
|
||||||
fn is_reasoning_model(model: &str) -> bool {
|
fn is_reasoning_model(model: &str) -> bool {
|
||||||
let lowered = model.to_ascii_lowercase();
|
let lowered = model.to_ascii_lowercase();
|
||||||
@@ -768,24 +726,6 @@ fn is_reasoning_model(model: &str) -> bool {
|
|||||||
|| canonical.contains("thinking")
|
|| canonical.contains("thinking")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Strip routing prefix (e.g., "openai/gpt-4" → "gpt-4") for the wire.
|
|
||||||
/// The prefix is used only to select transport; the backend expects the
|
|
||||||
/// bare model id.
|
|
||||||
fn strip_routing_prefix(model: &str) -> &str {
|
|
||||||
if let Some(pos) = model.find('/') {
|
|
||||||
let prefix = &model[..pos];
|
|
||||||
// Only strip if the prefix before "/" is a known routing prefix,
|
|
||||||
// not if "/" appears in the middle of the model name for other reasons.
|
|
||||||
if matches!(prefix, "openai" | "xai" | "grok" | "qwen") {
|
|
||||||
&model[pos + 1..]
|
|
||||||
} else {
|
|
||||||
model
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
model
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_chat_completion_request(request: &MessageRequest, config: OpenAiCompatConfig) -> Value {
|
fn build_chat_completion_request(request: &MessageRequest, config: OpenAiCompatConfig) -> Value {
|
||||||
let mut messages = Vec::new();
|
let mut messages = Vec::new();
|
||||||
if let Some(system) = request.system.as_ref().filter(|value| !value.is_empty()) {
|
if let Some(system) = request.system.as_ref().filter(|value| !value.is_empty()) {
|
||||||
@@ -797,30 +737,10 @@ fn build_chat_completion_request(request: &MessageRequest, config: OpenAiCompatC
|
|||||||
for message in &request.messages {
|
for message in &request.messages {
|
||||||
messages.extend(translate_message(message));
|
messages.extend(translate_message(message));
|
||||||
}
|
}
|
||||||
// Sanitize: drop any `role:"tool"` message that does not have a valid
|
|
||||||
// paired `role:"assistant"` with a `tool_calls` entry carrying the same
|
|
||||||
// `id` immediately before it (directly or as part of a run of tool
|
|
||||||
// results). OpenAI-compatible backends return 400 for orphaned tool
|
|
||||||
// messages regardless of how they were produced (compaction, session
|
|
||||||
// editing, resume, etc.). We drop rather than error so the request can
|
|
||||||
// still proceed with the remaining history intact.
|
|
||||||
messages = sanitize_tool_message_pairing(messages);
|
|
||||||
|
|
||||||
// Strip routing prefix (e.g., "openai/gpt-4" → "gpt-4") for the wire.
|
|
||||||
let wire_model = strip_routing_prefix(&request.model);
|
|
||||||
|
|
||||||
// gpt-5* requires `max_completion_tokens`; older OpenAI models accept both.
|
|
||||||
// We send the correct field based on the wire model name so gpt-5.x requests
|
|
||||||
// don't fail with "unknown field max_tokens".
|
|
||||||
let max_tokens_key = if wire_model.starts_with("gpt-5") {
|
|
||||||
"max_completion_tokens"
|
|
||||||
} else {
|
|
||||||
"max_tokens"
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut payload = json!({
|
let mut payload = json!({
|
||||||
"model": wire_model,
|
"model": request.model,
|
||||||
max_tokens_key: request.max_tokens,
|
"max_tokens": request.max_tokens,
|
||||||
"messages": messages,
|
"messages": messages,
|
||||||
"stream": request.stream,
|
"stream": request.stream,
|
||||||
});
|
});
|
||||||
@@ -860,10 +780,6 @@ fn build_chat_completion_request(request: &MessageRequest, config: OpenAiCompatC
|
|||||||
payload["stop"] = json!(stop);
|
payload["stop"] = json!(stop);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// reasoning_effort for OpenAI-compatible reasoning models (o4-mini, o3, etc.)
|
|
||||||
if let Some(effort) = &request.reasoning_effort {
|
|
||||||
payload["reasoning_effort"] = json!(effort);
|
|
||||||
}
|
|
||||||
|
|
||||||
payload
|
payload
|
||||||
}
|
}
|
||||||
@@ -890,16 +806,11 @@ fn translate_message(message: &InputMessage) -> Vec<Value> {
|
|||||||
if text.is_empty() && tool_calls.is_empty() {
|
if text.is_empty() && tool_calls.is_empty() {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
} else {
|
} else {
|
||||||
let mut msg = serde_json::json!({
|
vec![json!({
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"content": (!text.is_empty()).then_some(text),
|
"content": (!text.is_empty()).then_some(text),
|
||||||
});
|
"tool_calls": tool_calls,
|
||||||
// Only include tool_calls when non-empty: some providers reject
|
})]
|
||||||
// assistant messages with an explicit empty tool_calls array.
|
|
||||||
if !tool_calls.is_empty() {
|
|
||||||
msg["tool_calls"] = json!(tool_calls);
|
|
||||||
}
|
|
||||||
vec![msg]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => message
|
_ => message
|
||||||
@@ -926,74 +837,6 @@ fn translate_message(message: &InputMessage) -> Vec<Value> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove `role:"tool"` messages from `messages` that have no valid paired
|
|
||||||
/// `role:"assistant"` message with a matching `tool_calls[].id` immediately
|
|
||||||
/// preceding them. This is a last-resort safety net at the request-building
|
|
||||||
/// layer — the compaction boundary fix (6e301c8) prevents the most common
|
|
||||||
/// producer path, but resume, session editing, or future compaction variants
|
|
||||||
/// could still create orphaned tool messages.
|
|
||||||
///
|
|
||||||
/// Algorithm: scan left-to-right. For each `role:"tool"` message, check the
|
|
||||||
/// immediately preceding non-tool message. If it's `role:"assistant"` with a
|
|
||||||
/// `tool_calls` array containing an entry whose `id` matches the tool
|
|
||||||
/// message's `tool_call_id`, the pair is valid and both are kept. Otherwise
|
|
||||||
/// the tool message is dropped.
|
|
||||||
fn sanitize_tool_message_pairing(messages: Vec<Value>) -> Vec<Value> {
|
|
||||||
// Collect indices of tool messages that are orphaned.
|
|
||||||
let mut drop_indices = std::collections::HashSet::new();
|
|
||||||
for (i, msg) in messages.iter().enumerate() {
|
|
||||||
if msg.get("role").and_then(|v| v.as_str()) != Some("tool") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let tool_call_id = msg
|
|
||||||
.get("tool_call_id")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("");
|
|
||||||
// Find the nearest preceding non-tool message.
|
|
||||||
let preceding = messages[..i]
|
|
||||||
.iter()
|
|
||||||
.rev()
|
|
||||||
.find(|m| m.get("role").and_then(|v| v.as_str()) != Some("tool"));
|
|
||||||
// A tool message is considered paired when:
|
|
||||||
// (a) the nearest preceding non-tool message is an assistant message
|
|
||||||
// whose `tool_calls` array contains an entry with the matching id, OR
|
|
||||||
// (b) there's no clear preceding context (e.g. the message comes right
|
|
||||||
// after a user turn — this can happen with translated mixed-content
|
|
||||||
// user messages). In case (b) we allow the message through rather
|
|
||||||
// than silently dropping potentially valid history.
|
|
||||||
let preceding_role = preceding
|
|
||||||
.and_then(|m| m.get("role"))
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("");
|
|
||||||
// Only apply sanitization when the preceding message is an assistant
|
|
||||||
// turn (the invariant is: assistant-with-tool_calls must precede tool).
|
|
||||||
// If the preceding is something else (user, system) don't drop — it
|
|
||||||
// may be a valid translation artifact or a path we don't understand.
|
|
||||||
if preceding_role != "assistant" {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let paired = preceding
|
|
||||||
.and_then(|m| m.get("tool_calls").and_then(|tc| tc.as_array()))
|
|
||||||
.is_some_and(|tool_calls| {
|
|
||||||
tool_calls
|
|
||||||
.iter()
|
|
||||||
.any(|tc| tc.get("id").and_then(|v| v.as_str()) == Some(tool_call_id))
|
|
||||||
});
|
|
||||||
if !paired {
|
|
||||||
drop_indices.insert(i);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if drop_indices.is_empty() {
|
|
||||||
return messages;
|
|
||||||
}
|
|
||||||
messages
|
|
||||||
.into_iter()
|
|
||||||
.enumerate()
|
|
||||||
.filter(|(i, _)| !drop_indices.contains(i))
|
|
||||||
.map(|(_, m)| m)
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn flatten_tool_result_content(content: &[ToolResultContentBlock]) -> String {
|
fn flatten_tool_result_content(content: &[ToolResultContentBlock]) -> String {
|
||||||
content
|
content
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1005,45 +848,13 @@ fn flatten_tool_result_content(content: &[ToolResultContentBlock]) -> String {
|
|||||||
.join("\n")
|
.join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Recursively ensure every object-type node in a JSON Schema has
|
|
||||||
/// `"properties"` (at least `{}`) and `"additionalProperties": false`.
|
|
||||||
/// The `OpenAI` `/responses` endpoint validates schemas strictly and rejects
|
|
||||||
/// objects that omit these fields; `/chat/completions` is lenient but also
|
|
||||||
/// accepts them, so we normalise unconditionally.
|
|
||||||
fn normalize_object_schema(schema: &mut Value) {
|
|
||||||
if let Some(obj) = schema.as_object_mut() {
|
|
||||||
if obj.get("type").and_then(Value::as_str) == Some("object") {
|
|
||||||
obj.entry("properties").or_insert_with(|| json!({}));
|
|
||||||
obj.entry("additionalProperties")
|
|
||||||
.or_insert(Value::Bool(false));
|
|
||||||
}
|
|
||||||
// Recurse into properties values
|
|
||||||
if let Some(props) = obj.get_mut("properties") {
|
|
||||||
if let Some(props_obj) = props.as_object_mut() {
|
|
||||||
let keys: Vec<String> = props_obj.keys().cloned().collect();
|
|
||||||
for k in keys {
|
|
||||||
if let Some(v) = props_obj.get_mut(&k) {
|
|
||||||
normalize_object_schema(v);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Recurse into items (arrays)
|
|
||||||
if let Some(items) = obj.get_mut("items") {
|
|
||||||
normalize_object_schema(items);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn openai_tool_definition(tool: &ToolDefinition) -> Value {
|
fn openai_tool_definition(tool: &ToolDefinition) -> Value {
|
||||||
let mut parameters = tool.input_schema.clone();
|
|
||||||
normalize_object_schema(&mut parameters);
|
|
||||||
json!({
|
json!({
|
||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
"name": tool.name,
|
"name": tool.name,
|
||||||
"description": tool.description,
|
"description": tool.description,
|
||||||
"parameters": parameters,
|
"parameters": tool.input_schema,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1160,35 +971,6 @@ fn parse_sse_frame(
|
|||||||
if payload == "[DONE]" {
|
if payload == "[DONE]" {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
// Some backends embed an error object in a data: frame instead of using an
|
|
||||||
// HTTP error status. Surface the error message directly rather than letting
|
|
||||||
// ChatCompletionChunk deserialization fail with a cryptic 'missing field' error.
|
|
||||||
if let Ok(raw) = serde_json::from_str::<serde_json::Value>(&payload) {
|
|
||||||
if let Some(err_obj) = raw.get("error") {
|
|
||||||
let msg = err_obj
|
|
||||||
.get("message")
|
|
||||||
.and_then(|m| m.as_str())
|
|
||||||
.unwrap_or("provider returned an error in stream")
|
|
||||||
.to_string();
|
|
||||||
let code = err_obj
|
|
||||||
.get("code")
|
|
||||||
.and_then(serde_json::Value::as_u64)
|
|
||||||
.map(|c| c as u16);
|
|
||||||
let status = reqwest::StatusCode::from_u16(code.unwrap_or(400))
|
|
||||||
.unwrap_or(reqwest::StatusCode::BAD_REQUEST);
|
|
||||||
return Err(ApiError::Api {
|
|
||||||
status,
|
|
||||||
error_type: err_obj
|
|
||||||
.get("type")
|
|
||||||
.and_then(|t| t.as_str())
|
|
||||||
.map(str::to_owned),
|
|
||||||
message: Some(msg),
|
|
||||||
request_id: None,
|
|
||||||
body: payload.clone(),
|
|
||||||
retryable: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
serde_json::from_str::<ChatCompletionChunk>(&payload)
|
serde_json::from_str::<ChatCompletionChunk>(&payload)
|
||||||
.map(Some)
|
.map(Some)
|
||||||
.map_err(|error| ApiError::json_deserialize(provider, model, &payload, error))
|
.map_err(|error| ApiError::json_deserialize(provider, model, &payload, error))
|
||||||
@@ -1340,76 +1122,6 @@ mod tests {
|
|||||||
assert_eq!(payload["tool_choice"], json!("auto"));
|
assert_eq!(payload["tool_choice"], json!("auto"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tool_schema_object_gets_strict_fields_for_responses_endpoint() {
|
|
||||||
// OpenAI /responses endpoint rejects object schemas missing
|
|
||||||
// "properties" and "additionalProperties". Verify normalize_object_schema
|
|
||||||
// fills them in so the request shape is strict-validator-safe.
|
|
||||||
use super::normalize_object_schema;
|
|
||||||
|
|
||||||
// Bare object — no properties at all
|
|
||||||
let mut schema = json!({"type": "object"});
|
|
||||||
normalize_object_schema(&mut schema);
|
|
||||||
assert_eq!(schema["properties"], json!({}));
|
|
||||||
assert_eq!(schema["additionalProperties"], json!(false));
|
|
||||||
|
|
||||||
// Nested object inside properties
|
|
||||||
let mut schema2 = json!({
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"location": {"type": "object", "properties": {"lat": {"type": "number"}}}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
normalize_object_schema(&mut schema2);
|
|
||||||
assert_eq!(schema2["additionalProperties"], json!(false));
|
|
||||||
assert_eq!(
|
|
||||||
schema2["properties"]["location"]["additionalProperties"],
|
|
||||||
json!(false)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Existing properties/additionalProperties should not be overwritten
|
|
||||||
let mut schema3 = json!({
|
|
||||||
"type": "object",
|
|
||||||
"properties": {"x": {"type": "string"}},
|
|
||||||
"additionalProperties": true
|
|
||||||
});
|
|
||||||
normalize_object_schema(&mut schema3);
|
|
||||||
assert_eq!(
|
|
||||||
schema3["additionalProperties"],
|
|
||||||
json!(true),
|
|
||||||
"must not overwrite existing"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn reasoning_effort_is_included_when_set() {
|
|
||||||
let payload = build_chat_completion_request(
|
|
||||||
&MessageRequest {
|
|
||||||
model: "o4-mini".to_string(),
|
|
||||||
max_tokens: 1024,
|
|
||||||
messages: vec![InputMessage::user_text("think hard")],
|
|
||||||
reasoning_effort: Some("high".to_string()),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
OpenAiCompatConfig::openai(),
|
|
||||||
);
|
|
||||||
assert_eq!(payload["reasoning_effort"], json!("high"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn reasoning_effort_omitted_when_not_set() {
|
|
||||||
let payload = build_chat_completion_request(
|
|
||||||
&MessageRequest {
|
|
||||||
model: "gpt-4o".to_string(),
|
|
||||||
max_tokens: 64,
|
|
||||||
messages: vec![InputMessage::user_text("hello")],
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
OpenAiCompatConfig::openai(),
|
|
||||||
);
|
|
||||||
assert!(payload.get("reasoning_effort").is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn openai_streaming_requests_include_usage_opt_in() {
|
fn openai_streaming_requests_include_usage_opt_in() {
|
||||||
let payload = build_chat_completion_request(
|
let payload = build_chat_completion_request(
|
||||||
@@ -1527,7 +1239,6 @@ mod tests {
|
|||||||
frequency_penalty: Some(0.5),
|
frequency_penalty: Some(0.5),
|
||||||
presence_penalty: Some(0.3),
|
presence_penalty: Some(0.3),
|
||||||
stop: Some(vec!["\n".to_string()]),
|
stop: Some(vec!["\n".to_string()]),
|
||||||
reasoning_effort: None,
|
|
||||||
};
|
};
|
||||||
let payload = build_chat_completion_request(&request, OpenAiCompatConfig::openai());
|
let payload = build_chat_completion_request(&request, OpenAiCompatConfig::openai());
|
||||||
assert_eq!(payload["temperature"], 0.7);
|
assert_eq!(payload["temperature"], 0.7);
|
||||||
@@ -1612,186 +1323,4 @@ mod tests {
|
|||||||
assert!(payload.get("presence_penalty").is_none());
|
assert!(payload.get("presence_penalty").is_none());
|
||||||
assert!(payload.get("stop").is_none());
|
assert!(payload.get("stop").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn gpt5_uses_max_completion_tokens_not_max_tokens() {
|
|
||||||
// gpt-5* models require `max_completion_tokens`; legacy `max_tokens` causes
|
|
||||||
// a request-validation failure. Verify the correct key is emitted.
|
|
||||||
let request = MessageRequest {
|
|
||||||
model: "gpt-5.2".to_string(),
|
|
||||||
max_tokens: 512,
|
|
||||||
messages: vec![],
|
|
||||||
stream: false,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let payload = build_chat_completion_request(&request, OpenAiCompatConfig::openai());
|
|
||||||
assert_eq!(
|
|
||||||
payload["max_completion_tokens"],
|
|
||||||
json!(512),
|
|
||||||
"gpt-5.2 should emit max_completion_tokens"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
payload.get("max_tokens").is_none(),
|
|
||||||
"gpt-5.2 must not emit max_tokens"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Regression test: some OpenAI-compatible providers emit `"tool_calls": null`
|
|
||||||
/// in stream delta chunks instead of omitting the field or using `[]`.
|
|
||||||
/// Before the fix this produced: `invalid type: null, expected a sequence`.
|
|
||||||
#[test]
|
|
||||||
fn delta_with_null_tool_calls_deserializes_as_empty_vec() {
|
|
||||||
use super::deserialize_null_as_empty_vec;
|
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
#[derive(serde::Deserialize, Debug)]
|
|
||||||
struct Delta {
|
|
||||||
content: Option<String>,
|
|
||||||
#[serde(default, deserialize_with = "deserialize_null_as_empty_vec")]
|
|
||||||
tool_calls: Vec<super::DeltaToolCall>,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simulate the exact shape observed in the wild (gaebal-gajae repro 2026-04-09)
|
|
||||||
let json = r#"{
|
|
||||||
"content": "",
|
|
||||||
"function_call": null,
|
|
||||||
"refusal": null,
|
|
||||||
"role": "assistant",
|
|
||||||
"tool_calls": null
|
|
||||||
}"#;
|
|
||||||
let delta: Delta = serde_json::from_str(json)
|
|
||||||
.expect("delta with tool_calls:null must deserialize without error");
|
|
||||||
assert!(
|
|
||||||
delta.tool_calls.is_empty(),
|
|
||||||
"tool_calls:null must produce an empty vec, not an error"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Regression: when building a multi-turn request where a prior assistant
|
|
||||||
/// turn has no tool calls, the serialized assistant message must NOT include
|
|
||||||
/// `tool_calls: []`. Some providers reject requests that carry an empty
|
|
||||||
/// `tool_calls` array on assistant turns (gaebal-gajae repro 2026-04-09).
|
|
||||||
#[test]
|
|
||||||
fn assistant_message_without_tool_calls_omits_tool_calls_field() {
|
|
||||||
use crate::types::{InputContentBlock, InputMessage};
|
|
||||||
|
|
||||||
let request = MessageRequest {
|
|
||||||
model: "gpt-4o".to_string(),
|
|
||||||
max_tokens: 100,
|
|
||||||
messages: vec![InputMessage {
|
|
||||||
role: "assistant".to_string(),
|
|
||||||
content: vec![InputContentBlock::Text {
|
|
||||||
text: "Hello".to_string(),
|
|
||||||
}],
|
|
||||||
}],
|
|
||||||
stream: false,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let payload = build_chat_completion_request(&request, OpenAiCompatConfig::openai());
|
|
||||||
let messages = payload["messages"].as_array().unwrap();
|
|
||||||
let assistant_msg = messages
|
|
||||||
.iter()
|
|
||||||
.find(|m| m["role"] == "assistant")
|
|
||||||
.expect("assistant message must be present");
|
|
||||||
assert!(
|
|
||||||
assistant_msg.get("tool_calls").is_none(),
|
|
||||||
"assistant message without tool calls must omit tool_calls field: {assistant_msg:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Regression: assistant messages WITH tool calls must still include
|
|
||||||
/// the `tool_calls` array (normal multi-turn tool-use flow).
|
|
||||||
#[test]
|
|
||||||
fn assistant_message_with_tool_calls_includes_tool_calls_field() {
|
|
||||||
use crate::types::{InputContentBlock, InputMessage};
|
|
||||||
|
|
||||||
let request = MessageRequest {
|
|
||||||
model: "gpt-4o".to_string(),
|
|
||||||
max_tokens: 100,
|
|
||||||
messages: vec![InputMessage {
|
|
||||||
role: "assistant".to_string(),
|
|
||||||
content: vec![InputContentBlock::ToolUse {
|
|
||||||
id: "call_1".to_string(),
|
|
||||||
name: "read_file".to_string(),
|
|
||||||
input: serde_json::json!({"path": "/tmp/test"}),
|
|
||||||
}],
|
|
||||||
}],
|
|
||||||
stream: false,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let payload = build_chat_completion_request(&request, OpenAiCompatConfig::openai());
|
|
||||||
let messages = payload["messages"].as_array().unwrap();
|
|
||||||
let assistant_msg = messages
|
|
||||||
.iter()
|
|
||||||
.find(|m| m["role"] == "assistant")
|
|
||||||
.expect("assistant message must be present");
|
|
||||||
let tool_calls = assistant_msg
|
|
||||||
.get("tool_calls")
|
|
||||||
.expect("assistant message with tool calls must include tool_calls field");
|
|
||||||
assert!(tool_calls.is_array());
|
|
||||||
assert_eq!(tool_calls.as_array().unwrap().len(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Orphaned tool messages (no preceding assistant `tool_calls`) must be
|
|
||||||
/// dropped by the request-builder sanitizer. Regression for the second
|
|
||||||
/// layer of the tool-pairing invariant fix (gaebal-gajae 2026-04-10).
|
|
||||||
#[test]
|
|
||||||
fn sanitize_drops_orphaned_tool_messages() {
|
|
||||||
use super::sanitize_tool_message_pairing;
|
|
||||||
|
|
||||||
// Valid pair: assistant with tool_calls → tool result
|
|
||||||
let valid = vec![
|
|
||||||
json!({"role": "assistant", "content": null, "tool_calls": [{"id": "call_1", "type": "function", "function": {"name": "search", "arguments": "{}"}}]}),
|
|
||||||
json!({"role": "tool", "tool_call_id": "call_1", "content": "result"}),
|
|
||||||
];
|
|
||||||
let out = sanitize_tool_message_pairing(valid);
|
|
||||||
assert_eq!(out.len(), 2, "valid pair must be preserved");
|
|
||||||
|
|
||||||
// Orphaned tool message: no preceding assistant tool_calls
|
|
||||||
let orphaned = vec![
|
|
||||||
json!({"role": "assistant", "content": "hi"}),
|
|
||||||
json!({"role": "tool", "tool_call_id": "call_2", "content": "orphaned"}),
|
|
||||||
];
|
|
||||||
let out = sanitize_tool_message_pairing(orphaned);
|
|
||||||
assert_eq!(out.len(), 1, "orphaned tool message must be dropped");
|
|
||||||
assert_eq!(out[0]["role"], json!("assistant"));
|
|
||||||
|
|
||||||
// Mismatched tool_call_id
|
|
||||||
let mismatched = vec![
|
|
||||||
json!({"role": "assistant", "content": null, "tool_calls": [{"id": "call_3", "type": "function", "function": {"name": "f", "arguments": "{}"}}]}),
|
|
||||||
json!({"role": "tool", "tool_call_id": "call_WRONG", "content": "bad"}),
|
|
||||||
];
|
|
||||||
let out = sanitize_tool_message_pairing(mismatched);
|
|
||||||
assert_eq!(out.len(), 1, "tool message with wrong id must be dropped");
|
|
||||||
|
|
||||||
// Two tool results both valid (same preceding assistant)
|
|
||||||
let two_results = vec![
|
|
||||||
json!({"role": "assistant", "content": null, "tool_calls": [
|
|
||||||
{"id": "call_a", "type": "function", "function": {"name": "fa", "arguments": "{}"}},
|
|
||||||
{"id": "call_b", "type": "function", "function": {"name": "fb", "arguments": "{}"}}
|
|
||||||
]}),
|
|
||||||
json!({"role": "tool", "tool_call_id": "call_a", "content": "ra"}),
|
|
||||||
json!({"role": "tool", "tool_call_id": "call_b", "content": "rb"}),
|
|
||||||
];
|
|
||||||
let out = sanitize_tool_message_pairing(two_results);
|
|
||||||
assert_eq!(out.len(), 3, "both valid tool results must be preserved");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn non_gpt5_uses_max_tokens() {
|
|
||||||
// Older OpenAI models expect `max_tokens`; verify gpt-4o is unaffected.
|
|
||||||
let request = MessageRequest {
|
|
||||||
model: "gpt-4o".to_string(),
|
|
||||||
max_tokens: 512,
|
|
||||||
messages: vec![],
|
|
||||||
stream: false,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let payload = build_chat_completion_request(&request, OpenAiCompatConfig::openai());
|
|
||||||
assert_eq!(payload["max_tokens"], json!(512));
|
|
||||||
assert!(
|
|
||||||
payload.get("max_completion_tokens").is_none(),
|
|
||||||
"gpt-4o must not emit max_completion_tokens"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,11 +26,6 @@ pub struct MessageRequest {
|
|||||||
pub presence_penalty: Option<f64>,
|
pub presence_penalty: Option<f64>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub stop: Option<Vec<String>>,
|
pub stop: Option<Vec<String>>,
|
||||||
/// Reasoning effort level for OpenAI-compatible reasoning models (e.g. `o4-mini`).
|
|
||||||
/// Accepted values: `"low"`, `"medium"`, `"high"`. Omitted when `None`.
|
|
||||||
/// Silently ignored by backends that do not support it.
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub reasoning_effort: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MessageRequest {
|
impl MessageRequest {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use std::fmt;
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use plugins::{PluginError, PluginLoadFailure, PluginManager, PluginSummary};
|
use plugins::{PluginError, PluginManager, PluginSummary};
|
||||||
use runtime::{
|
use runtime::{
|
||||||
compact_session, CompactionConfig, ConfigLoader, ConfigSource, McpOAuthConfig, McpServerConfig,
|
compact_session, CompactionConfig, ConfigLoader, ConfigSource, McpOAuthConfig, McpServerConfig,
|
||||||
ScopedMcpServerConfig, Session,
|
ScopedMcpServerConfig, Session,
|
||||||
@@ -257,6 +257,20 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
|
|||||||
argument_hint: None,
|
argument_hint: None,
|
||||||
resume_supported: true,
|
resume_supported: true,
|
||||||
},
|
},
|
||||||
|
SlashCommandSpec {
|
||||||
|
name: "login",
|
||||||
|
aliases: &[],
|
||||||
|
summary: "Log in to the service",
|
||||||
|
argument_hint: None,
|
||||||
|
resume_supported: false,
|
||||||
|
},
|
||||||
|
SlashCommandSpec {
|
||||||
|
name: "logout",
|
||||||
|
aliases: &[],
|
||||||
|
summary: "Log out of the current session",
|
||||||
|
argument_hint: None,
|
||||||
|
resume_supported: false,
|
||||||
|
},
|
||||||
SlashCommandSpec {
|
SlashCommandSpec {
|
||||||
name: "plan",
|
name: "plan",
|
||||||
aliases: &[],
|
aliases: &[],
|
||||||
@@ -1207,83 +1221,6 @@ impl SlashCommand {
|
|||||||
pub fn parse(input: &str) -> Result<Option<Self>, SlashCommandParseError> {
|
pub fn parse(input: &str) -> Result<Option<Self>, SlashCommandParseError> {
|
||||||
validate_slash_command_input(input)
|
validate_slash_command_input(input)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the canonical slash-command name (e.g. `"/branch"`) for use in
|
|
||||||
/// error messages and logging. Derived from the spec table so it always
|
|
||||||
/// matches what the user would have typed.
|
|
||||||
#[must_use]
|
|
||||||
pub fn slash_name(&self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::Help => "/help",
|
|
||||||
Self::Clear { .. } => "/clear",
|
|
||||||
Self::Compact { .. } => "/compact",
|
|
||||||
Self::Cost => "/cost",
|
|
||||||
Self::Doctor => "/doctor",
|
|
||||||
Self::Config { .. } => "/config",
|
|
||||||
Self::Memory { .. } => "/memory",
|
|
||||||
Self::History { .. } => "/history",
|
|
||||||
Self::Diff => "/diff",
|
|
||||||
Self::Status => "/status",
|
|
||||||
Self::Stats => "/stats",
|
|
||||||
Self::Version => "/version",
|
|
||||||
Self::Commit { .. } => "/commit",
|
|
||||||
Self::Pr { .. } => "/pr",
|
|
||||||
Self::Issue { .. } => "/issue",
|
|
||||||
Self::Init => "/init",
|
|
||||||
Self::Bughunter { .. } => "/bughunter",
|
|
||||||
Self::Ultraplan { .. } => "/ultraplan",
|
|
||||||
Self::Teleport { .. } => "/teleport",
|
|
||||||
Self::DebugToolCall { .. } => "/debug-tool-call",
|
|
||||||
Self::Resume { .. } => "/resume",
|
|
||||||
Self::Model { .. } => "/model",
|
|
||||||
Self::Permissions { .. } => "/permissions",
|
|
||||||
Self::Session { .. } => "/session",
|
|
||||||
Self::Plugins { .. } => "/plugins",
|
|
||||||
Self::Login => "/login",
|
|
||||||
Self::Logout => "/logout",
|
|
||||||
Self::Vim => "/vim",
|
|
||||||
Self::Upgrade => "/upgrade",
|
|
||||||
Self::Share => "/share",
|
|
||||||
Self::Feedback => "/feedback",
|
|
||||||
Self::Files => "/files",
|
|
||||||
Self::Fast => "/fast",
|
|
||||||
Self::Exit => "/exit",
|
|
||||||
Self::Summary => "/summary",
|
|
||||||
Self::Desktop => "/desktop",
|
|
||||||
Self::Brief => "/brief",
|
|
||||||
Self::Advisor => "/advisor",
|
|
||||||
Self::Stickers => "/stickers",
|
|
||||||
Self::Insights => "/insights",
|
|
||||||
Self::Thinkback => "/thinkback",
|
|
||||||
Self::ReleaseNotes => "/release-notes",
|
|
||||||
Self::SecurityReview => "/security-review",
|
|
||||||
Self::Keybindings => "/keybindings",
|
|
||||||
Self::PrivacySettings => "/privacy-settings",
|
|
||||||
Self::Plan { .. } => "/plan",
|
|
||||||
Self::Review { .. } => "/review",
|
|
||||||
Self::Tasks { .. } => "/tasks",
|
|
||||||
Self::Theme { .. } => "/theme",
|
|
||||||
Self::Voice { .. } => "/voice",
|
|
||||||
Self::Usage { .. } => "/usage",
|
|
||||||
Self::Rename { .. } => "/rename",
|
|
||||||
Self::Copy { .. } => "/copy",
|
|
||||||
Self::Hooks { .. } => "/hooks",
|
|
||||||
Self::Context { .. } => "/context",
|
|
||||||
Self::Color { .. } => "/color",
|
|
||||||
Self::Effort { .. } => "/effort",
|
|
||||||
Self::Branch { .. } => "/branch",
|
|
||||||
Self::Rewind { .. } => "/rewind",
|
|
||||||
Self::Ide { .. } => "/ide",
|
|
||||||
Self::Tag { .. } => "/tag",
|
|
||||||
Self::OutputStyle { .. } => "/output-style",
|
|
||||||
Self::AddDir { .. } => "/add-dir",
|
|
||||||
Self::Sandbox => "/sandbox",
|
|
||||||
Self::Mcp { .. } => "/mcp",
|
|
||||||
Self::Export { .. } => "/export",
|
|
||||||
#[allow(unreachable_patterns)]
|
|
||||||
_ => "/unknown",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_lines)]
|
#[allow(clippy::too_many_lines)]
|
||||||
@@ -1383,16 +1320,17 @@ pub fn validate_slash_command_input(
|
|||||||
"skills" | "skill" => SlashCommand::Skills {
|
"skills" | "skill" => SlashCommand::Skills {
|
||||||
args: parse_skills_args(remainder.as_deref())?,
|
args: parse_skills_args(remainder.as_deref())?,
|
||||||
},
|
},
|
||||||
"doctor" | "providers" => {
|
"doctor" => {
|
||||||
validate_no_args(command, &args)?;
|
validate_no_args(command, &args)?;
|
||||||
SlashCommand::Doctor
|
SlashCommand::Doctor
|
||||||
}
|
}
|
||||||
"login" | "logout" => {
|
"login" => {
|
||||||
return Err(command_error(
|
validate_no_args(command, &args)?;
|
||||||
"This auth flow was removed. Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN instead.",
|
SlashCommand::Login
|
||||||
command,
|
}
|
||||||
"",
|
"logout" => {
|
||||||
));
|
validate_no_args(command, &args)?;
|
||||||
|
SlashCommand::Logout
|
||||||
}
|
}
|
||||||
"vim" => {
|
"vim" => {
|
||||||
validate_no_args(command, &args)?;
|
validate_no_args(command, &args)?;
|
||||||
@@ -1402,7 +1340,7 @@ pub fn validate_slash_command_input(
|
|||||||
validate_no_args(command, &args)?;
|
validate_no_args(command, &args)?;
|
||||||
SlashCommand::Upgrade
|
SlashCommand::Upgrade
|
||||||
}
|
}
|
||||||
"stats" | "tokens" | "cache" => {
|
"stats" => {
|
||||||
validate_no_args(command, &args)?;
|
validate_no_args(command, &args)?;
|
||||||
SlashCommand::Stats
|
SlashCommand::Stats
|
||||||
}
|
}
|
||||||
@@ -1877,12 +1815,20 @@ pub fn resume_supported_slash_commands() -> Vec<&'static SlashCommandSpec> {
|
|||||||
|
|
||||||
fn slash_command_category(name: &str) -> &'static str {
|
fn slash_command_category(name: &str) -> &'static str {
|
||||||
match name {
|
match name {
|
||||||
"help" | "status" | "cost" | "resume" | "session" | "version" | "usage" | "stats"
|
"help" | "status" | "cost" | "resume" | "session" | "version" | "login" | "logout"
|
||||||
| "rename" | "clear" | "compact" | "history" | "tokens" | "cache" | "exit" | "summary"
|
| "usage" | "stats" | "rename" | "clear" | "compact" | "history" | "tokens" | "cache"
|
||||||
| "tag" | "thinkback" | "copy" | "share" | "feedback" | "rewind" | "pin" | "unpin"
|
| "exit" | "summary" | "tag" | "thinkback" | "copy" | "share" | "feedback" | "rewind"
|
||||||
| "bookmarks" | "context" | "files" | "focus" | "unfocus" | "retry" | "stop" | "undo" => {
|
| "pin" | "unpin" | "bookmarks" | "context" | "files" | "focus" | "unfocus" | "retry"
|
||||||
"Session"
|
| "stop" | "undo" => "Session",
|
||||||
}
|
"diff" | "commit" | "pr" | "issue" | "branch" | "blame" | "log" | "git" | "stash"
|
||||||
|
| "init" | "export" | "plan" | "review" | "security-review" | "bughunter" | "ultraplan"
|
||||||
|
| "teleport" | "refactor" | "fix" | "autofix" | "explain" | "docs" | "perf" | "search"
|
||||||
|
| "references" | "definition" | "hover" | "symbols" | "map" | "web" | "image"
|
||||||
|
| "screenshot" | "paste" | "listen" | "speak" | "test" | "lint" | "build" | "run"
|
||||||
|
| "format" | "parallel" | "multi" | "macro" | "alias" | "templates" | "migrate"
|
||||||
|
| "benchmark" | "cron" | "agent" | "subagent" | "agents" | "skills" | "team" | "plugin"
|
||||||
|
| "mcp" | "hooks" | "tasks" | "advisor" | "insights" | "release-notes" | "chat"
|
||||||
|
| "approve" | "deny" | "allowed-tools" | "add-dir" => "Tools",
|
||||||
"model" | "permissions" | "config" | "memory" | "theme" | "vim" | "voice" | "color"
|
"model" | "permissions" | "config" | "memory" | "theme" | "vim" | "voice" | "color"
|
||||||
| "effort" | "fast" | "brief" | "output-style" | "keybindings" | "privacy-settings"
|
| "effort" | "fast" | "brief" | "output-style" | "keybindings" | "privacy-settings"
|
||||||
| "stickers" | "language" | "profile" | "max-tokens" | "temperature" | "system-prompt"
|
| "stickers" | "language" | "profile" | "max-tokens" | "temperature" | "system-prompt"
|
||||||
@@ -1992,42 +1938,6 @@ pub fn suggest_slash_commands(input: &str, limit: usize) -> Vec<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
/// Render the slash-command help section, optionally excluding stub commands
|
|
||||||
/// (commands that are registered in the spec list but not yet implemented).
|
|
||||||
/// Pass an empty slice to include all commands.
|
|
||||||
pub fn render_slash_command_help_filtered(exclude: &[&str]) -> String {
|
|
||||||
let mut lines = vec![
|
|
||||||
"Slash commands".to_string(),
|
|
||||||
" Start here /status, /diff, /agents, /skills, /commit".to_string(),
|
|
||||||
" [resume] also works with --resume SESSION.jsonl".to_string(),
|
|
||||||
String::new(),
|
|
||||||
];
|
|
||||||
|
|
||||||
let categories = ["Session", "Tools", "Config", "Debug"];
|
|
||||||
|
|
||||||
for category in categories {
|
|
||||||
lines.push(category.to_string());
|
|
||||||
for spec in slash_command_specs()
|
|
||||||
.iter()
|
|
||||||
.filter(|spec| slash_command_category(spec.name) == category)
|
|
||||||
.filter(|spec| !exclude.contains(&spec.name))
|
|
||||||
{
|
|
||||||
lines.push(format_slash_command_help_line(spec));
|
|
||||||
}
|
|
||||||
lines.push(String::new());
|
|
||||||
}
|
|
||||||
|
|
||||||
lines
|
|
||||||
.into_iter()
|
|
||||||
.rev()
|
|
||||||
.skip_while(String::is_empty)
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.into_iter()
|
|
||||||
.rev()
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn render_slash_command_help() -> String {
|
pub fn render_slash_command_help() -> String {
|
||||||
let mut lines = vec![
|
let mut lines = vec![
|
||||||
"Slash commands".to_string(),
|
"Slash commands".to_string(),
|
||||||
@@ -2186,15 +2096,10 @@ pub fn handle_plugins_slash_command(
|
|||||||
manager: &mut PluginManager,
|
manager: &mut PluginManager,
|
||||||
) -> Result<PluginsCommandResult, PluginError> {
|
) -> Result<PluginsCommandResult, PluginError> {
|
||||||
match action {
|
match action {
|
||||||
None | Some("list") => {
|
None | Some("list") => Ok(PluginsCommandResult {
|
||||||
let report = manager.installed_plugin_registry_report()?;
|
message: render_plugins_report(&manager.list_installed_plugins()?),
|
||||||
let plugins = report.summaries();
|
reload_runtime: false,
|
||||||
let failures = report.failures();
|
}),
|
||||||
Ok(PluginsCommandResult {
|
|
||||||
message: render_plugins_report_with_failures(&plugins, failures),
|
|
||||||
reload_runtime: false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
Some("install") => {
|
Some("install") => {
|
||||||
let Some(target) = target else {
|
let Some(target) = target else {
|
||||||
return Ok(PluginsCommandResult {
|
return Ok(PluginsCommandResult {
|
||||||
@@ -2453,8 +2358,7 @@ pub fn resolve_skill_invocation(
|
|||||||
.map(|s| s.name.clone())
|
.map(|s| s.name.clone())
|
||||||
.collect();
|
.collect();
|
||||||
if !names.is_empty() {
|
if !names.is_empty() {
|
||||||
message.push_str("\n Available skills: ");
|
message.push_str(&format!("\n Available skills: {}", names.join(", ")));
|
||||||
message.push_str(&names.join(", "));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
message.push_str("\n Usage: /skills [list|install <path>|help|<skill> [args]]");
|
message.push_str("\n Usage: /skills [list|install <path>|help|<skill> [args]]");
|
||||||
@@ -2649,48 +2553,6 @@ pub fn render_plugins_report(plugins: &[PluginSummary]) -> String {
|
|||||||
lines.join("\n")
|
lines.join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn render_plugins_report_with_failures(
|
|
||||||
plugins: &[PluginSummary],
|
|
||||||
failures: &[PluginLoadFailure],
|
|
||||||
) -> String {
|
|
||||||
let mut lines = vec!["Plugins".to_string()];
|
|
||||||
|
|
||||||
// Show successfully loaded plugins
|
|
||||||
if plugins.is_empty() {
|
|
||||||
lines.push(" No plugins installed.".to_string());
|
|
||||||
} else {
|
|
||||||
for plugin in plugins {
|
|
||||||
let enabled = if plugin.enabled {
|
|
||||||
"enabled"
|
|
||||||
} else {
|
|
||||||
"disabled"
|
|
||||||
};
|
|
||||||
lines.push(format!(
|
|
||||||
" {name:<20} v{version:<10} {enabled}",
|
|
||||||
name = plugin.metadata.name,
|
|
||||||
version = plugin.metadata.version,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show warnings for broken plugins
|
|
||||||
if !failures.is_empty() {
|
|
||||||
lines.push(String::new());
|
|
||||||
lines.push("Warnings:".to_string());
|
|
||||||
for failure in failures {
|
|
||||||
lines.push(format!(
|
|
||||||
" ⚠️ Failed to load {} plugin from `{}`",
|
|
||||||
failure.kind,
|
|
||||||
failure.plugin_root.display()
|
|
||||||
));
|
|
||||||
lines.push(format!(" Error: {}", failure.error()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.join("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_plugin_install_report(plugin_id: &str, plugin: Option<&PluginSummary>) -> String {
|
fn render_plugin_install_report(plugin_id: &str, plugin: Option<&PluginSummary>) -> String {
|
||||||
let name = plugin.map_or(plugin_id, |plugin| plugin.metadata.name.as_str());
|
let name = plugin.map_or(plugin_id, |plugin| plugin.metadata.name.as_str());
|
||||||
let version = plugin.map_or("unknown", |plugin| plugin.metadata.version.as_str());
|
let version = plugin.map_or("unknown", |plugin| plugin.metadata.version.as_str());
|
||||||
@@ -4121,15 +3983,12 @@ mod tests {
|
|||||||
handle_plugins_slash_command, handle_skills_slash_command_json, handle_slash_command,
|
handle_plugins_slash_command, handle_skills_slash_command_json, handle_slash_command,
|
||||||
load_agents_from_roots, load_skills_from_roots, render_agents_report,
|
load_agents_from_roots, load_skills_from_roots, render_agents_report,
|
||||||
render_agents_report_json, render_mcp_report_json_for, render_plugins_report,
|
render_agents_report_json, render_mcp_report_json_for, render_plugins_report,
|
||||||
render_plugins_report_with_failures, render_skills_report, render_slash_command_help,
|
render_skills_report, render_slash_command_help, render_slash_command_help_detail,
|
||||||
render_slash_command_help_detail, resolve_skill_path, resume_supported_slash_commands,
|
resolve_skill_path, resume_supported_slash_commands, slash_command_specs,
|
||||||
slash_command_specs, suggest_slash_commands, validate_slash_command_input,
|
suggest_slash_commands, validate_slash_command_input, DefinitionSource, SkillOrigin,
|
||||||
DefinitionSource, SkillOrigin, SkillRoot, SkillSlashDispatch, SlashCommand,
|
SkillRoot, SkillSlashDispatch, SlashCommand,
|
||||||
};
|
|
||||||
use plugins::{
|
|
||||||
PluginError, PluginKind, PluginLoadFailure, PluginManager, PluginManagerConfig,
|
|
||||||
PluginMetadata, PluginSummary,
|
|
||||||
};
|
};
|
||||||
|
use plugins::{PluginKind, PluginManager, PluginManagerConfig, PluginMetadata, PluginSummary};
|
||||||
use runtime::{
|
use runtime::{
|
||||||
CompactionConfig, ConfigLoader, ContentBlock, ConversationMessage, MessageRole, Session,
|
CompactionConfig, ConfigLoader, ContentBlock, ConversationMessage, MessageRole, Session,
|
||||||
};
|
};
|
||||||
@@ -4152,24 +4011,6 @@ mod tests {
|
|||||||
LOCK.get_or_init(|| Mutex::new(()))
|
LOCK.get_or_init(|| Mutex::new(()))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn env_guard() -> std::sync::MutexGuard<'static, ()> {
|
|
||||||
env_lock()
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn env_guard_recovers_after_poisoning() {
|
|
||||||
let poisoned = std::thread::spawn(|| {
|
|
||||||
let _guard = env_guard();
|
|
||||||
panic!("poison env lock");
|
|
||||||
})
|
|
||||||
.join();
|
|
||||||
assert!(poisoned.is_err(), "poisoning thread should panic");
|
|
||||||
|
|
||||||
let _guard = env_guard();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn restore_env_var(key: &str, original: Option<OsString>) {
|
fn restore_env_var(key: &str, original: Option<OsString>) {
|
||||||
match original {
|
match original {
|
||||||
Some(value) => std::env::set_var(key, value),
|
Some(value) => std::env::set_var(key, value),
|
||||||
@@ -4596,14 +4437,6 @@ mod tests {
|
|||||||
assert!(action_error.contains(" Usage /mcp [list|show <server>|help]"));
|
assert!(action_error.contains(" Usage /mcp [list|show <server>|help]"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn removed_login_and_logout_commands_report_env_auth_guidance() {
|
|
||||||
let login_error = parse_error_message("/login");
|
|
||||||
assert!(login_error.contains("ANTHROPIC_API_KEY"));
|
|
||||||
let logout_error = parse_error_message("/logout");
|
|
||||||
assert!(logout_error.contains("ANTHROPIC_AUTH_TOKEN"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn renders_help_from_shared_specs() {
|
fn renders_help_from_shared_specs() {
|
||||||
let help = render_slash_command_help();
|
let help = render_slash_command_help();
|
||||||
@@ -4645,9 +4478,7 @@ mod tests {
|
|||||||
assert!(help.contains("/agents [list|help]"));
|
assert!(help.contains("/agents [list|help]"));
|
||||||
assert!(help.contains("/skills [list|install <path>|help|<skill> [args]]"));
|
assert!(help.contains("/skills [list|install <path>|help|<skill> [args]]"));
|
||||||
assert!(help.contains("aliases: /skill"));
|
assert!(help.contains("aliases: /skill"));
|
||||||
assert!(!help.contains("/login"));
|
assert_eq!(slash_command_specs().len(), 141);
|
||||||
assert!(!help.contains("/logout"));
|
|
||||||
assert_eq!(slash_command_specs().len(), 139);
|
|
||||||
assert!(resume_supported_slash_commands().len() >= 39);
|
assert!(resume_supported_slash_commands().len() >= 39);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4778,14 +4609,7 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.expect("slash command should be handled");
|
.expect("slash command should be handled");
|
||||||
|
|
||||||
// With the tool-use/tool-result boundary guard the compaction may
|
assert!(result.message.contains("Compacted 2 messages"));
|
||||||
// preserve one extra message, so 1 or 2 messages may be removed.
|
|
||||||
assert!(
|
|
||||||
result.message.contains("Compacted 1 messages")
|
|
||||||
|| result.message.contains("Compacted 2 messages"),
|
|
||||||
"unexpected compaction message: {}",
|
|
||||||
result.message
|
|
||||||
);
|
|
||||||
assert_eq!(result.session.messages[0].role, MessageRole::System);
|
assert_eq!(result.session.messages[0].role, MessageRole::System);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4905,36 +4729,6 @@ mod tests {
|
|||||||
assert!(rendered.contains("disabled"));
|
assert!(rendered.contains("disabled"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn renders_plugins_report_with_broken_plugin_warnings() {
|
|
||||||
let rendered = render_plugins_report_with_failures(
|
|
||||||
&[PluginSummary {
|
|
||||||
metadata: PluginMetadata {
|
|
||||||
id: "demo@external".to_string(),
|
|
||||||
name: "demo".to_string(),
|
|
||||||
version: "1.2.3".to_string(),
|
|
||||||
description: "demo plugin".to_string(),
|
|
||||||
kind: PluginKind::External,
|
|
||||||
source: "demo".to_string(),
|
|
||||||
default_enabled: false,
|
|
||||||
root: None,
|
|
||||||
},
|
|
||||||
enabled: true,
|
|
||||||
}],
|
|
||||||
&[PluginLoadFailure::new(
|
|
||||||
PathBuf::from("/tmp/broken-plugin"),
|
|
||||||
PluginKind::External,
|
|
||||||
"broken".to_string(),
|
|
||||||
PluginError::InvalidManifest("hook path `hooks/pre.sh` does not exist".to_string()),
|
|
||||||
)],
|
|
||||||
);
|
|
||||||
|
|
||||||
assert!(rendered.contains("Warnings:"));
|
|
||||||
assert!(rendered.contains("Failed to load external plugin"));
|
|
||||||
assert!(rendered.contains("/tmp/broken-plugin"));
|
|
||||||
assert!(rendered.contains("does not exist"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn lists_agents_from_project_and_user_roots() {
|
fn lists_agents_from_project_and_user_roots() {
|
||||||
let workspace = temp_dir("agents-workspace");
|
let workspace = temp_dir("agents-workspace");
|
||||||
@@ -5232,7 +5026,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn discovers_omc_skills_from_project_and_user_compatibility_roots() {
|
fn discovers_omc_skills_from_project_and_user_compatibility_roots() {
|
||||||
let _guard = env_guard();
|
let _guard = env_lock().lock().expect("env lock");
|
||||||
let workspace = temp_dir("skills-omc-workspace");
|
let workspace = temp_dir("skills-omc-workspace");
|
||||||
let user_home = temp_dir("skills-omc-home");
|
let user_home = temp_dir("skills-omc-home");
|
||||||
let claude_config_dir = temp_dir("skills-omc-claude-config");
|
let claude_config_dir = temp_dir("skills-omc-claude-config");
|
||||||
|
|||||||
@@ -18,12 +18,6 @@ impl UpstreamPaths {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the repository root path.
|
|
||||||
#[must_use]
|
|
||||||
pub fn repo_root(&self) -> &Path {
|
|
||||||
&self.repo_root
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn from_workspace_dir(workspace_dir: impl AsRef<Path>) -> Self {
|
pub fn from_workspace_dir(workspace_dir: impl AsRef<Path>) -> Self {
|
||||||
let workspace_dir = workspace_dir
|
let workspace_dir = workspace_dir
|
||||||
|
|||||||
@@ -561,4 +561,43 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn output_with_stdin_tolerates_broken_pipe_when_child_closes_stdin_early() {
|
||||||
|
// given: a hook that immediately closes stdin without consuming the
|
||||||
|
// JSON payload. Use an oversized payload so the parent keeps writing
|
||||||
|
// long enough for Linux to surface EPIPE on the old implementation.
|
||||||
|
let root = temp_dir("stdin-close");
|
||||||
|
let script = root.join("close-stdin.sh");
|
||||||
|
fs::create_dir_all(&root).expect("temp hook dir");
|
||||||
|
fs::write(
|
||||||
|
&script,
|
||||||
|
"#!/bin/sh\nexec 0<&-\nprintf 'stdin closed early\\n'\nsleep 0.05\n",
|
||||||
|
)
|
||||||
|
.expect("write stdin-closing hook");
|
||||||
|
make_executable(&script);
|
||||||
|
|
||||||
|
let mut child = super::shell_command(script.to_str().expect("utf8 path"));
|
||||||
|
child.stdin(std::process::Stdio::piped());
|
||||||
|
child.stdout(std::process::Stdio::piped());
|
||||||
|
child.stderr(std::process::Stdio::piped());
|
||||||
|
let large_input = vec![b'x'; 2 * 1024 * 1024];
|
||||||
|
|
||||||
|
// when
|
||||||
|
let output = child
|
||||||
|
.output_with_stdin(&large_input)
|
||||||
|
.expect("broken pipe should be tolerated");
|
||||||
|
|
||||||
|
// then
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"child should still exit cleanly: {output:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
String::from_utf8_lossy(&output.stdout).trim(),
|
||||||
|
"stdin closed early"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(root);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
mod hooks;
|
mod hooks;
|
||||||
#[cfg(test)]
|
|
||||||
pub mod test_isolation;
|
|
||||||
|
|
||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
use std::fmt::{Display, Formatter};
|
use std::fmt::{Display, Formatter};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::{Command, Stdio};
|
use std::process::{Command, Stdio};
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -2163,13 +2160,7 @@ fn materialize_source(
|
|||||||
match source {
|
match source {
|
||||||
PluginInstallSource::LocalPath { path } => Ok(path.clone()),
|
PluginInstallSource::LocalPath { path } => Ok(path.clone()),
|
||||||
PluginInstallSource::GitUrl { url } => {
|
PluginInstallSource::GitUrl { url } => {
|
||||||
static MATERIALIZE_COUNTER: AtomicU64 = AtomicU64::new(0);
|
let destination = temp_root.join(format!("plugin-{}", unix_time_ms()));
|
||||||
let unique = MATERIALIZE_COUNTER.fetch_add(1, Ordering::Relaxed);
|
|
||||||
let nanos = SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.unwrap()
|
|
||||||
.as_nanos();
|
|
||||||
let destination = temp_root.join(format!("plugin-{nanos}-{unique}"));
|
|
||||||
let output = Command::new("git")
|
let output = Command::new("git")
|
||||||
.arg("clone")
|
.arg("clone")
|
||||||
.arg("--depth")
|
.arg("--depth")
|
||||||
@@ -2282,24 +2273,10 @@ fn ensure_object<'a>(root: &'a mut Map<String, Value>, key: &str) -> &'a mut Map
|
|||||||
.expect("object should exist")
|
.expect("object should exist")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Environment variable lock for test isolation.
|
|
||||||
/// Guards against concurrent modification of `CLAW_CONFIG_HOME`.
|
|
||||||
#[cfg(test)]
|
|
||||||
fn env_lock() -> &'static std::sync::Mutex<()> {
|
|
||||||
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
|
||||||
&ENV_LOCK
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn env_guard() -> std::sync::MutexGuard<'static, ()> {
|
|
||||||
env_lock()
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn temp_dir(label: &str) -> PathBuf {
|
fn temp_dir(label: &str) -> PathBuf {
|
||||||
let nanos = std::time::SystemTime::now()
|
let nanos = std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
@@ -2308,18 +2285,6 @@ mod tests {
|
|||||||
std::env::temp_dir().join(format!("plugins-{label}-{nanos}"))
|
std::env::temp_dir().join(format!("plugins-{label}-{nanos}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn env_guard_recovers_after_poisoning() {
|
|
||||||
let poisoned = std::thread::spawn(|| {
|
|
||||||
let _guard = env_guard();
|
|
||||||
panic!("poison env lock");
|
|
||||||
})
|
|
||||||
.join();
|
|
||||||
assert!(poisoned.is_err(), "poisoning thread should panic");
|
|
||||||
|
|
||||||
let _guard = env_guard();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_file(path: &Path, contents: &str) {
|
fn write_file(path: &Path, contents: &str) {
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
fs::create_dir_all(parent).expect("parent dir");
|
fs::create_dir_all(parent).expect("parent dir");
|
||||||
@@ -2503,7 +2468,6 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn load_plugin_from_directory_validates_required_fields() {
|
fn load_plugin_from_directory_validates_required_fields() {
|
||||||
let _guard = env_guard();
|
|
||||||
let root = temp_dir("manifest-required");
|
let root = temp_dir("manifest-required");
|
||||||
write_file(
|
write_file(
|
||||||
root.join(MANIFEST_FILE_NAME).as_path(),
|
root.join(MANIFEST_FILE_NAME).as_path(),
|
||||||
@@ -2518,7 +2482,6 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn load_plugin_from_directory_reads_root_manifest_and_validates_entries() {
|
fn load_plugin_from_directory_reads_root_manifest_and_validates_entries() {
|
||||||
let _guard = env_guard();
|
|
||||||
let root = temp_dir("manifest-root");
|
let root = temp_dir("manifest-root");
|
||||||
write_loader_plugin(&root);
|
write_loader_plugin(&root);
|
||||||
|
|
||||||
@@ -2548,7 +2511,6 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn load_plugin_from_directory_supports_packaged_manifest_path() {
|
fn load_plugin_from_directory_supports_packaged_manifest_path() {
|
||||||
let _guard = env_guard();
|
|
||||||
let root = temp_dir("manifest-packaged");
|
let root = temp_dir("manifest-packaged");
|
||||||
write_external_plugin(&root, "packaged-demo", "1.0.0");
|
write_external_plugin(&root, "packaged-demo", "1.0.0");
|
||||||
|
|
||||||
@@ -2562,7 +2524,6 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn load_plugin_from_directory_defaults_optional_fields() {
|
fn load_plugin_from_directory_defaults_optional_fields() {
|
||||||
let _guard = env_guard();
|
|
||||||
let root = temp_dir("manifest-defaults");
|
let root = temp_dir("manifest-defaults");
|
||||||
write_file(
|
write_file(
|
||||||
root.join(MANIFEST_FILE_NAME).as_path(),
|
root.join(MANIFEST_FILE_NAME).as_path(),
|
||||||
@@ -2584,7 +2545,6 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn load_plugin_from_directory_rejects_duplicate_permissions_and_commands() {
|
fn load_plugin_from_directory_rejects_duplicate_permissions_and_commands() {
|
||||||
let _guard = env_guard();
|
|
||||||
let root = temp_dir("manifest-duplicates");
|
let root = temp_dir("manifest-duplicates");
|
||||||
write_file(
|
write_file(
|
||||||
root.join("commands").join("sync.sh").as_path(),
|
root.join("commands").join("sync.sh").as_path(),
|
||||||
@@ -2880,7 +2840,6 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn discovers_builtin_and_bundled_plugins() {
|
fn discovers_builtin_and_bundled_plugins() {
|
||||||
let _guard = env_guard();
|
|
||||||
let manager = PluginManager::new(PluginManagerConfig::new(temp_dir("discover")));
|
let manager = PluginManager::new(PluginManagerConfig::new(temp_dir("discover")));
|
||||||
let plugins = manager.list_plugins().expect("plugins should list");
|
let plugins = manager.list_plugins().expect("plugins should list");
|
||||||
assert!(plugins
|
assert!(plugins
|
||||||
@@ -2893,7 +2852,6 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn installs_enables_updates_and_uninstalls_external_plugins() {
|
fn installs_enables_updates_and_uninstalls_external_plugins() {
|
||||||
let _guard = env_guard();
|
|
||||||
let config_home = temp_dir("home");
|
let config_home = temp_dir("home");
|
||||||
let source_root = temp_dir("source");
|
let source_root = temp_dir("source");
|
||||||
write_external_plugin(&source_root, "demo", "1.0.0");
|
write_external_plugin(&source_root, "demo", "1.0.0");
|
||||||
@@ -2942,7 +2900,6 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn auto_installs_bundled_plugins_into_the_registry() {
|
fn auto_installs_bundled_plugins_into_the_registry() {
|
||||||
let _guard = env_guard();
|
|
||||||
let config_home = temp_dir("bundled-home");
|
let config_home = temp_dir("bundled-home");
|
||||||
let bundled_root = temp_dir("bundled-root");
|
let bundled_root = temp_dir("bundled-root");
|
||||||
write_bundled_plugin(&bundled_root.join("starter"), "starter", "0.1.0", false);
|
write_bundled_plugin(&bundled_root.join("starter"), "starter", "0.1.0", false);
|
||||||
@@ -2974,7 +2931,6 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn default_bundled_root_loads_repo_bundles_as_installed_plugins() {
|
fn default_bundled_root_loads_repo_bundles_as_installed_plugins() {
|
||||||
let _guard = env_guard();
|
|
||||||
let config_home = temp_dir("default-bundled-home");
|
let config_home = temp_dir("default-bundled-home");
|
||||||
let manager = PluginManager::new(PluginManagerConfig::new(&config_home));
|
let manager = PluginManager::new(PluginManagerConfig::new(&config_home));
|
||||||
|
|
||||||
@@ -2993,7 +2949,6 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn bundled_sync_prunes_removed_bundled_registry_entries() {
|
fn bundled_sync_prunes_removed_bundled_registry_entries() {
|
||||||
let _guard = env_guard();
|
|
||||||
let config_home = temp_dir("bundled-prune-home");
|
let config_home = temp_dir("bundled-prune-home");
|
||||||
let bundled_root = temp_dir("bundled-prune-root");
|
let bundled_root = temp_dir("bundled-prune-root");
|
||||||
let stale_install_path = config_home
|
let stale_install_path = config_home
|
||||||
@@ -3057,7 +3012,6 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn installed_plugin_discovery_keeps_registry_entries_outside_install_root() {
|
fn installed_plugin_discovery_keeps_registry_entries_outside_install_root() {
|
||||||
let _guard = env_guard();
|
|
||||||
let config_home = temp_dir("registry-fallback-home");
|
let config_home = temp_dir("registry-fallback-home");
|
||||||
let bundled_root = temp_dir("registry-fallback-bundled");
|
let bundled_root = temp_dir("registry-fallback-bundled");
|
||||||
let install_root = config_home.join("plugins").join("installed");
|
let install_root = config_home.join("plugins").join("installed");
|
||||||
@@ -3112,7 +3066,6 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn installed_plugin_discovery_prunes_stale_registry_entries() {
|
fn installed_plugin_discovery_prunes_stale_registry_entries() {
|
||||||
let _guard = env_guard();
|
|
||||||
let config_home = temp_dir("registry-prune-home");
|
let config_home = temp_dir("registry-prune-home");
|
||||||
let bundled_root = temp_dir("registry-prune-bundled");
|
let bundled_root = temp_dir("registry-prune-bundled");
|
||||||
let install_root = config_home.join("plugins").join("installed");
|
let install_root = config_home.join("plugins").join("installed");
|
||||||
@@ -3158,7 +3111,6 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn persists_bundled_plugin_enable_state_across_reloads() {
|
fn persists_bundled_plugin_enable_state_across_reloads() {
|
||||||
let _guard = env_guard();
|
|
||||||
let config_home = temp_dir("bundled-state-home");
|
let config_home = temp_dir("bundled-state-home");
|
||||||
let bundled_root = temp_dir("bundled-state-root");
|
let bundled_root = temp_dir("bundled-state-root");
|
||||||
write_bundled_plugin(&bundled_root.join("starter"), "starter", "0.1.0", false);
|
write_bundled_plugin(&bundled_root.join("starter"), "starter", "0.1.0", false);
|
||||||
@@ -3192,7 +3144,6 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn persists_bundled_plugin_disable_state_across_reloads() {
|
fn persists_bundled_plugin_disable_state_across_reloads() {
|
||||||
let _guard = env_guard();
|
|
||||||
let config_home = temp_dir("bundled-disabled-home");
|
let config_home = temp_dir("bundled-disabled-home");
|
||||||
let bundled_root = temp_dir("bundled-disabled-root");
|
let bundled_root = temp_dir("bundled-disabled-root");
|
||||||
write_bundled_plugin(&bundled_root.join("starter"), "starter", "0.1.0", true);
|
write_bundled_plugin(&bundled_root.join("starter"), "starter", "0.1.0", true);
|
||||||
@@ -3226,7 +3177,6 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn validates_plugin_source_before_install() {
|
fn validates_plugin_source_before_install() {
|
||||||
let _guard = env_guard();
|
|
||||||
let config_home = temp_dir("validate-home");
|
let config_home = temp_dir("validate-home");
|
||||||
let source_root = temp_dir("validate-source");
|
let source_root = temp_dir("validate-source");
|
||||||
write_external_plugin(&source_root, "validator", "1.0.0");
|
write_external_plugin(&source_root, "validator", "1.0.0");
|
||||||
@@ -3241,7 +3191,6 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn plugin_registry_tracks_enabled_state_and_lookup() {
|
fn plugin_registry_tracks_enabled_state_and_lookup() {
|
||||||
let _guard = env_guard();
|
|
||||||
let config_home = temp_dir("registry-home");
|
let config_home = temp_dir("registry-home");
|
||||||
let source_root = temp_dir("registry-source");
|
let source_root = temp_dir("registry-source");
|
||||||
write_external_plugin(&source_root, "registry-demo", "1.0.0");
|
write_external_plugin(&source_root, "registry-demo", "1.0.0");
|
||||||
@@ -3269,7 +3218,6 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn plugin_registry_report_collects_load_failures_without_dropping_valid_plugins() {
|
fn plugin_registry_report_collects_load_failures_without_dropping_valid_plugins() {
|
||||||
let _guard = env_guard();
|
|
||||||
// given
|
// given
|
||||||
let config_home = temp_dir("report-home");
|
let config_home = temp_dir("report-home");
|
||||||
let external_root = temp_dir("report-external");
|
let external_root = temp_dir("report-external");
|
||||||
@@ -3314,7 +3262,6 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn installed_plugin_registry_report_collects_load_failures_from_install_root() {
|
fn installed_plugin_registry_report_collects_load_failures_from_install_root() {
|
||||||
let _guard = env_guard();
|
|
||||||
// given
|
// given
|
||||||
let config_home = temp_dir("installed-report-home");
|
let config_home = temp_dir("installed-report-home");
|
||||||
let bundled_root = temp_dir("installed-report-bundled");
|
let bundled_root = temp_dir("installed-report-bundled");
|
||||||
@@ -3345,7 +3292,6 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_plugin_sources_with_missing_hook_paths() {
|
fn rejects_plugin_sources_with_missing_hook_paths() {
|
||||||
let _guard = env_guard();
|
|
||||||
// given
|
// given
|
||||||
let config_home = temp_dir("broken-home");
|
let config_home = temp_dir("broken-home");
|
||||||
let source_root = temp_dir("broken-source");
|
let source_root = temp_dir("broken-source");
|
||||||
@@ -3373,7 +3319,6 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_plugin_sources_with_missing_failure_hook_paths() {
|
fn rejects_plugin_sources_with_missing_failure_hook_paths() {
|
||||||
let _guard = env_guard();
|
|
||||||
// given
|
// given
|
||||||
let config_home = temp_dir("broken-failure-home");
|
let config_home = temp_dir("broken-failure-home");
|
||||||
let source_root = temp_dir("broken-failure-source");
|
let source_root = temp_dir("broken-failure-source");
|
||||||
@@ -3401,7 +3346,6 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn plugin_registry_runs_initialize_and_shutdown_for_enabled_plugins() {
|
fn plugin_registry_runs_initialize_and_shutdown_for_enabled_plugins() {
|
||||||
let _guard = env_guard();
|
|
||||||
let config_home = temp_dir("lifecycle-home");
|
let config_home = temp_dir("lifecycle-home");
|
||||||
let source_root = temp_dir("lifecycle-source");
|
let source_root = temp_dir("lifecycle-source");
|
||||||
let _ = write_lifecycle_plugin(&source_root, "lifecycle-demo", "1.0.0");
|
let _ = write_lifecycle_plugin(&source_root, "lifecycle-demo", "1.0.0");
|
||||||
@@ -3425,7 +3369,6 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn aggregates_and_executes_plugin_tools() {
|
fn aggregates_and_executes_plugin_tools() {
|
||||||
let _guard = env_guard();
|
|
||||||
let config_home = temp_dir("tool-home");
|
let config_home = temp_dir("tool-home");
|
||||||
let source_root = temp_dir("tool-source");
|
let source_root = temp_dir("tool-source");
|
||||||
write_tool_plugin(&source_root, "tool-demo", "1.0.0");
|
write_tool_plugin(&source_root, "tool-demo", "1.0.0");
|
||||||
@@ -3454,7 +3397,6 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn list_installed_plugins_scans_install_root_without_registry_entries() {
|
fn list_installed_plugins_scans_install_root_without_registry_entries() {
|
||||||
let _guard = env_guard();
|
|
||||||
let config_home = temp_dir("installed-scan-home");
|
let config_home = temp_dir("installed-scan-home");
|
||||||
let bundled_root = temp_dir("installed-scan-bundled");
|
let bundled_root = temp_dir("installed-scan-bundled");
|
||||||
let install_root = config_home.join("plugins").join("installed");
|
let install_root = config_home.join("plugins").join("installed");
|
||||||
@@ -3486,7 +3428,6 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn list_installed_plugins_scans_packaged_manifests_in_install_root() {
|
fn list_installed_plugins_scans_packaged_manifests_in_install_root() {
|
||||||
let _guard = env_guard();
|
|
||||||
let config_home = temp_dir("installed-packaged-scan-home");
|
let config_home = temp_dir("installed-packaged-scan-home");
|
||||||
let bundled_root = temp_dir("installed-packaged-scan-bundled");
|
let bundled_root = temp_dir("installed-packaged-scan-bundled");
|
||||||
let install_root = config_home.join("plugins").join("installed");
|
let install_root = config_home.join("plugins").join("installed");
|
||||||
@@ -3515,143 +3456,4 @@ mod tests {
|
|||||||
let _ = fs::remove_dir_all(config_home);
|
let _ = fs::remove_dir_all(config_home);
|
||||||
let _ = fs::remove_dir_all(bundled_root);
|
let _ = fs::remove_dir_all(bundled_root);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Regression test for ROADMAP #41: verify that `CLAW_CONFIG_HOME` isolation prevents
|
|
||||||
/// host `~/.claw/plugins/` from bleeding into test runs.
|
|
||||||
#[test]
|
|
||||||
fn claw_config_home_isolation_prevents_host_plugin_leakage() {
|
|
||||||
let _guard = env_guard();
|
|
||||||
|
|
||||||
// Create a temp directory to act as our isolated CLAW_CONFIG_HOME
|
|
||||||
let config_home = temp_dir("isolated-home");
|
|
||||||
let bundled_root = temp_dir("isolated-bundled");
|
|
||||||
|
|
||||||
// Set CLAW_CONFIG_HOME to our temp directory
|
|
||||||
std::env::set_var("CLAW_CONFIG_HOME", &config_home);
|
|
||||||
|
|
||||||
// Create a test fixture plugin in the isolated config home
|
|
||||||
let install_root = config_home.join("plugins").join("installed");
|
|
||||||
let fixture_plugin_root = install_root.join("isolated-test-plugin");
|
|
||||||
write_file(
|
|
||||||
fixture_plugin_root.join(MANIFEST_RELATIVE_PATH).as_path(),
|
|
||||||
r#"{
|
|
||||||
"name": "isolated-test-plugin",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "Test fixture plugin in isolated config home"
|
|
||||||
}"#,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Create PluginManager with isolated bundled_root - it should use the temp config_home, not host ~/.claw/
|
|
||||||
let mut config = PluginManagerConfig::new(&config_home);
|
|
||||||
config.bundled_root = Some(bundled_root.clone());
|
|
||||||
let manager = PluginManager::new(config);
|
|
||||||
|
|
||||||
// List installed plugins - should only see the test fixture, not host plugins
|
|
||||||
let installed = manager
|
|
||||||
.list_installed_plugins()
|
|
||||||
.expect("installed plugins should list");
|
|
||||||
|
|
||||||
// Verify we only see the test fixture plugin
|
|
||||||
assert_eq!(
|
|
||||||
installed.len(),
|
|
||||||
1,
|
|
||||||
"should only see the test fixture plugin, not host ~/.claw/plugins/"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
installed[0].metadata.id, "isolated-test-plugin@external",
|
|
||||||
"should see the test fixture plugin"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
std::env::remove_var("CLAW_CONFIG_HOME");
|
|
||||||
let _ = fs::remove_dir_all(config_home);
|
|
||||||
let _ = fs::remove_dir_all(bundled_root);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn plugin_lifecycle_handles_parallel_execution() {
|
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering};
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::thread;
|
|
||||||
|
|
||||||
let _guard = env_guard();
|
|
||||||
|
|
||||||
// Shared base directory for all threads
|
|
||||||
let base_dir = temp_dir("parallel-base");
|
|
||||||
|
|
||||||
// Track successful installations and any errors
|
|
||||||
let success_count = Arc::new(AtomicUsize::new(0));
|
|
||||||
let error_count = Arc::new(AtomicUsize::new(0));
|
|
||||||
|
|
||||||
// Spawn multiple threads to install plugins simultaneously
|
|
||||||
let mut handles = Vec::new();
|
|
||||||
for thread_id in 0..5 {
|
|
||||||
let base_dir = base_dir.clone();
|
|
||||||
let success_count = Arc::clone(&success_count);
|
|
||||||
let error_count = Arc::clone(&error_count);
|
|
||||||
|
|
||||||
let handle = thread::spawn(move || {
|
|
||||||
// Create unique directories for this thread
|
|
||||||
let config_home = base_dir.join(format!("config-{thread_id}"));
|
|
||||||
let source_root = base_dir.join(format!("source-{thread_id}"));
|
|
||||||
|
|
||||||
// Write lifecycle plugin for this thread
|
|
||||||
let _log_path =
|
|
||||||
write_lifecycle_plugin(&source_root, &format!("parallel-{thread_id}"), "1.0.0");
|
|
||||||
|
|
||||||
// Create PluginManager and install
|
|
||||||
let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
|
|
||||||
let install_result = manager.install(source_root.to_str().expect("utf8 path"));
|
|
||||||
|
|
||||||
match install_result {
|
|
||||||
Ok(install) => {
|
|
||||||
let log_path = install.install_path.join("lifecycle.log");
|
|
||||||
|
|
||||||
// Initialize and shutdown the registry to trigger lifecycle hooks
|
|
||||||
let registry = manager.plugin_registry();
|
|
||||||
match registry {
|
|
||||||
Ok(registry) => {
|
|
||||||
if registry.initialize().is_ok() && registry.shutdown().is_ok() {
|
|
||||||
// Verify lifecycle.log exists and has expected content
|
|
||||||
if let Ok(log) = fs::read_to_string(&log_path) {
|
|
||||||
if log == "init\nshutdown\n" {
|
|
||||||
success_count.fetch_add(1, AtomicOrdering::Relaxed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
error_count.fetch_add(1, AtomicOrdering::Relaxed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
error_count.fetch_add(1, AtomicOrdering::Relaxed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
handles.push(handle);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for all threads to complete
|
|
||||||
for handle in handles {
|
|
||||||
handle.join().expect("thread should complete");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify all threads succeeded without collisions
|
|
||||||
let successes = success_count.load(AtomicOrdering::Relaxed);
|
|
||||||
let errors = error_count.load(AtomicOrdering::Relaxed);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
successes, 5,
|
|
||||||
"all 5 parallel plugin installations should succeed"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
errors, 0,
|
|
||||||
"no errors should occur during parallel execution"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
let _ = fs::remove_dir_all(base_dir);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
// Test isolation utilities for plugin tests
|
|
||||||
// ROADMAP #41: Stop ambient plugin state from skewing CLI regression checks
|
|
||||||
|
|
||||||
use std::env;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
|
||||||
use std::sync::Mutex;
|
|
||||||
|
|
||||||
static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
|
|
||||||
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
|
||||||
|
|
||||||
/// Lock for test environment isolation
|
|
||||||
pub struct EnvLock {
|
|
||||||
_guard: std::sync::MutexGuard<'static, ()>,
|
|
||||||
temp_home: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EnvLock {
|
|
||||||
/// Acquire environment lock for test isolation
|
|
||||||
pub fn lock() -> Self {
|
|
||||||
let guard = ENV_LOCK.lock().unwrap();
|
|
||||||
let count = TEST_COUNTER.fetch_add(1, Ordering::SeqCst);
|
|
||||||
let temp_home = std::env::temp_dir().join(format!("plugin-test-{count}"));
|
|
||||||
|
|
||||||
// Set up isolated environment
|
|
||||||
std::fs::create_dir_all(&temp_home).ok();
|
|
||||||
std::fs::create_dir_all(temp_home.join(".claude/plugins/installed")).ok();
|
|
||||||
std::fs::create_dir_all(temp_home.join(".config")).ok();
|
|
||||||
|
|
||||||
// Redirect HOME and XDG_CONFIG_HOME to temp directory
|
|
||||||
env::set_var("HOME", &temp_home);
|
|
||||||
env::set_var("XDG_CONFIG_HOME", temp_home.join(".config"));
|
|
||||||
env::set_var("XDG_DATA_HOME", temp_home.join(".local/share"));
|
|
||||||
|
|
||||||
EnvLock {
|
|
||||||
_guard: guard,
|
|
||||||
temp_home,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the temporary home directory for this test
|
|
||||||
#[must_use]
|
|
||||||
pub fn temp_home(&self) -> &PathBuf {
|
|
||||||
&self.temp_home
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for EnvLock {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
// Cleanup temp directory
|
|
||||||
std::fs::remove_dir_all(&self.temp_home).ok();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_env_lock_creates_isolated_home() {
|
|
||||||
let lock = EnvLock::lock();
|
|
||||||
let home = env::var("HOME").unwrap();
|
|
||||||
assert!(home.contains("plugin-test-"));
|
|
||||||
assert_eq!(home, lock.temp_home().to_str().unwrap());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_env_lock_creates_plugin_directories() {
|
|
||||||
let lock = EnvLock::lock();
|
|
||||||
let plugins_dir = lock.temp_home().join(".claude/plugins/installed");
|
|
||||||
assert!(plugins_dir.exists());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -108,54 +108,10 @@ pub fn compact_session(session: &Session, config: CompactionConfig) -> Compactio
|
|||||||
.first()
|
.first()
|
||||||
.and_then(extract_existing_compacted_summary);
|
.and_then(extract_existing_compacted_summary);
|
||||||
let compacted_prefix_len = usize::from(existing_summary.is_some());
|
let compacted_prefix_len = usize::from(existing_summary.is_some());
|
||||||
let raw_keep_from = session
|
let keep_from = session
|
||||||
.messages
|
.messages
|
||||||
.len()
|
.len()
|
||||||
.saturating_sub(config.preserve_recent_messages);
|
.saturating_sub(config.preserve_recent_messages);
|
||||||
// Ensure we do not split a tool-use / tool-result pair at the compaction
|
|
||||||
// boundary. If the first preserved message is a user message whose first
|
|
||||||
// block is a ToolResult, the assistant message with the matching ToolUse
|
|
||||||
// was slated for removal — that produces an orphaned tool role message on
|
|
||||||
// the OpenAI-compat path (400: tool message must follow assistant with
|
|
||||||
// tool_calls). Walk the boundary back until we start at a safe point.
|
|
||||||
let keep_from = {
|
|
||||||
let mut k = raw_keep_from;
|
|
||||||
// If the first preserved message is a tool-result turn, ensure its
|
|
||||||
// paired assistant tool-use turn is preserved too. Without this fix,
|
|
||||||
// the OpenAI-compat adapter sends an orphaned 'tool' role message
|
|
||||||
// with no preceding assistant 'tool_calls', which providers reject
|
|
||||||
// with a 400. We walk back only if the immediately preceding message
|
|
||||||
// is NOT an assistant message that contains a ToolUse block (i.e. the
|
|
||||||
// pair is actually broken at the boundary).
|
|
||||||
loop {
|
|
||||||
if k == 0 || k <= compacted_prefix_len {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
let first_preserved = &session.messages[k];
|
|
||||||
let starts_with_tool_result = first_preserved
|
|
||||||
.blocks
|
|
||||||
.first()
|
|
||||||
.is_some_and(|b| matches!(b, ContentBlock::ToolResult { .. }));
|
|
||||||
if !starts_with_tool_result {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
// Check the message just before the current boundary.
|
|
||||||
let preceding = &session.messages[k - 1];
|
|
||||||
let preceding_has_tool_use = preceding
|
|
||||||
.blocks
|
|
||||||
.iter()
|
|
||||||
.any(|b| matches!(b, ContentBlock::ToolUse { .. }));
|
|
||||||
if preceding_has_tool_use {
|
|
||||||
// Pair is intact — walk back one more to include the assistant turn.
|
|
||||||
k = k.saturating_sub(1);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
// Preceding message has no ToolUse but we have a ToolResult —
|
|
||||||
// this is already an orphaned pair; walk back to try to fix it.
|
|
||||||
k = k.saturating_sub(1);
|
|
||||||
}
|
|
||||||
k
|
|
||||||
};
|
|
||||||
let removed = &session.messages[compacted_prefix_len..keep_from];
|
let removed = &session.messages[compacted_prefix_len..keep_from];
|
||||||
let preserved = session.messages[keep_from..].to_vec();
|
let preserved = session.messages[keep_from..].to_vec();
|
||||||
let summary =
|
let summary =
|
||||||
@@ -554,7 +510,7 @@ fn extract_summary_timeline(summary: &str) -> Vec<String> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
collect_key_files, compact_session, format_compact_summary,
|
collect_key_files, compact_session, estimate_session_tokens, format_compact_summary,
|
||||||
get_compact_continuation_message, infer_pending_work, should_compact, CompactionConfig,
|
get_compact_continuation_message, infer_pending_work, should_compact, CompactionConfig,
|
||||||
};
|
};
|
||||||
use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session};
|
use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session};
|
||||||
@@ -603,14 +559,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// With the tool-use/tool-result boundary fix, the compaction preserves
|
assert_eq!(result.removed_message_count, 2);
|
||||||
// one extra message to avoid an orphaned tool result at the boundary.
|
|
||||||
// messages[1] (assistant) must be kept along with messages[2] (tool result).
|
|
||||||
assert!(
|
|
||||||
result.removed_message_count <= 2,
|
|
||||||
"expected at most 2 removed, got {}",
|
|
||||||
result.removed_message_count
|
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
result.compacted_session.messages[0].role,
|
result.compacted_session.messages[0].role,
|
||||||
MessageRole::System
|
MessageRole::System
|
||||||
@@ -628,13 +577,8 @@ mod tests {
|
|||||||
max_estimated_tokens: 1,
|
max_estimated_tokens: 1,
|
||||||
}
|
}
|
||||||
));
|
));
|
||||||
// Note: with the tool-use/tool-result boundary guard the compacted session
|
|
||||||
// may preserve one extra message at the boundary, so token reduction is
|
|
||||||
// not guaranteed for small sessions. The invariant that matters is that
|
|
||||||
// the removed_message_count is non-zero (something was compacted).
|
|
||||||
assert!(
|
assert!(
|
||||||
result.removed_message_count > 0,
|
estimate_session_tokens(&result.compacted_session) < estimate_session_tokens(&session)
|
||||||
"compaction must remove at least one message"
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -738,79 +682,6 @@ mod tests {
|
|||||||
assert!(files.contains(&"rust/crates/rusty-claude-cli/src/main.rs".to_string()));
|
assert!(files.contains(&"rust/crates/rusty-claude-cli/src/main.rs".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Regression: compaction must not split an assistant(ToolUse) /
|
|
||||||
/// user(ToolResult) pair at the boundary. An orphaned tool-result message
|
|
||||||
/// without the preceding assistant `tool_calls` causes a 400 on the
|
|
||||||
/// OpenAI-compat path (gaebal-gajae repro 2026-04-09).
|
|
||||||
#[test]
|
|
||||||
fn compaction_does_not_split_tool_use_tool_result_pair() {
|
|
||||||
use crate::session::{ContentBlock, Session};
|
|
||||||
|
|
||||||
let tool_id = "call_abc";
|
|
||||||
let mut session = Session::default();
|
|
||||||
// Turn 1: user prompt
|
|
||||||
session
|
|
||||||
.push_message(ConversationMessage::user_text("Search for files"))
|
|
||||||
.unwrap();
|
|
||||||
// Turn 2: assistant calls a tool
|
|
||||||
session
|
|
||||||
.push_message(ConversationMessage::assistant(vec![
|
|
||||||
ContentBlock::ToolUse {
|
|
||||||
id: tool_id.to_string(),
|
|
||||||
name: "search".to_string(),
|
|
||||||
input: "{\"q\":\"*.rs\"}".to_string(),
|
|
||||||
},
|
|
||||||
]))
|
|
||||||
.unwrap();
|
|
||||||
// Turn 3: tool result
|
|
||||||
session
|
|
||||||
.push_message(ConversationMessage::tool_result(
|
|
||||||
tool_id,
|
|
||||||
"search",
|
|
||||||
"found 5 files",
|
|
||||||
false,
|
|
||||||
))
|
|
||||||
.unwrap();
|
|
||||||
// Turn 4: assistant final response
|
|
||||||
session
|
|
||||||
.push_message(ConversationMessage::assistant(vec![ContentBlock::Text {
|
|
||||||
text: "Done.".to_string(),
|
|
||||||
}]))
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// Compact preserving only 1 recent message — without the fix this
|
|
||||||
// would cut the boundary so that the tool result (turn 3) is first,
|
|
||||||
// without its preceding assistant tool_calls (turn 2).
|
|
||||||
let config = CompactionConfig {
|
|
||||||
preserve_recent_messages: 1,
|
|
||||||
..CompactionConfig::default()
|
|
||||||
};
|
|
||||||
let result = compact_session(&session, config);
|
|
||||||
// After compaction, no two consecutive messages should have the pattern
|
|
||||||
// tool_result immediately following a non-assistant message (i.e. an
|
|
||||||
// orphaned tool result without a preceding assistant ToolUse).
|
|
||||||
let messages = &result.compacted_session.messages;
|
|
||||||
for i in 1..messages.len() {
|
|
||||||
let curr_is_tool_result = messages[i]
|
|
||||||
.blocks
|
|
||||||
.first()
|
|
||||||
.is_some_and(|b| matches!(b, ContentBlock::ToolResult { .. }));
|
|
||||||
if curr_is_tool_result {
|
|
||||||
let prev_has_tool_use = messages[i - 1]
|
|
||||||
.blocks
|
|
||||||
.iter()
|
|
||||||
.any(|b| matches!(b, ContentBlock::ToolUse { .. }));
|
|
||||||
assert!(
|
|
||||||
prev_has_tool_use,
|
|
||||||
"message[{}] is a ToolResult but message[{}] has no ToolUse: {:?}",
|
|
||||||
i,
|
|
||||||
i - 1,
|
|
||||||
&messages[i - 1].blocks
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn infers_pending_work_from_recent_messages() {
|
fn infers_pending_work_from_recent_messages() {
|
||||||
let pending = infer_pending_work(&[
|
let pending = infer_pending_work(&[
|
||||||
|
|||||||
@@ -292,24 +292,6 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run a session health probe to verify the runtime is functional after compaction.
|
|
||||||
/// Returns Ok(()) if healthy, Err if the session appears broken.
|
|
||||||
fn run_session_health_probe(&mut self) -> Result<(), String> {
|
|
||||||
// Check if we have basic session integrity
|
|
||||||
if self.session.messages.is_empty() && self.session.compaction.is_some() {
|
|
||||||
// Freshly compacted with no messages - this is normal
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify tool executor is responsive with a non-destructive probe
|
|
||||||
// Using glob_search with a pattern that won't match anything
|
|
||||||
let probe_input = r#"{"pattern": "*.health-check-probe-"}"#;
|
|
||||||
match self.tool_executor.execute("glob_search", probe_input) {
|
|
||||||
Ok(_) => Ok(()),
|
|
||||||
Err(e) => Err(format!("Tool executor probe failed: {e}")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(clippy::too_many_lines)]
|
#[allow(clippy::too_many_lines)]
|
||||||
pub fn run_turn(
|
pub fn run_turn(
|
||||||
&mut self,
|
&mut self,
|
||||||
@@ -317,18 +299,6 @@ where
|
|||||||
mut prompter: Option<&mut dyn PermissionPrompter>,
|
mut prompter: Option<&mut dyn PermissionPrompter>,
|
||||||
) -> Result<TurnSummary, RuntimeError> {
|
) -> Result<TurnSummary, RuntimeError> {
|
||||||
let user_input = user_input.into();
|
let user_input = user_input.into();
|
||||||
|
|
||||||
// ROADMAP #38: Session-health canary - probe if context was compacted
|
|
||||||
if self.session.compaction.is_some() {
|
|
||||||
if let Err(error) = self.run_session_health_probe() {
|
|
||||||
return Err(RuntimeError::new(format!(
|
|
||||||
"Session health probe failed after compaction: {error}. \
|
|
||||||
The session may be in an inconsistent state. \
|
|
||||||
Consider starting a fresh session with /session new."
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.record_turn_started(&user_input);
|
self.record_turn_started(&user_input);
|
||||||
self.session
|
self.session
|
||||||
.push_user_text(user_input)
|
.push_user_text(user_input)
|
||||||
@@ -534,10 +504,6 @@ where
|
|||||||
&self.session
|
&self.session
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn api_client_mut(&mut self) -> &mut C {
|
|
||||||
&mut self.api_client
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn session_mut(&mut self) -> &mut Session {
|
pub fn session_mut(&mut self) -> &mut Session {
|
||||||
&mut self.session
|
&mut self.session
|
||||||
}
|
}
|
||||||
@@ -1611,88 +1577,6 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn compaction_health_probe_blocks_turn_when_tool_executor_is_broken() {
|
|
||||||
struct SimpleApi;
|
|
||||||
impl ApiClient for SimpleApi {
|
|
||||||
fn stream(
|
|
||||||
&mut self,
|
|
||||||
_request: ApiRequest,
|
|
||||||
) -> Result<Vec<AssistantEvent>, RuntimeError> {
|
|
||||||
panic!("API should not run when health probe fails");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut session = Session::new();
|
|
||||||
session.record_compaction("summarized earlier work", 4);
|
|
||||||
session
|
|
||||||
.push_user_text("previous message")
|
|
||||||
.expect("message should append");
|
|
||||||
|
|
||||||
let tool_executor = StaticToolExecutor::new().register("glob_search", |_input| {
|
|
||||||
Err(ToolError::new("transport unavailable"))
|
|
||||||
});
|
|
||||||
let mut runtime = ConversationRuntime::new(
|
|
||||||
session,
|
|
||||||
SimpleApi,
|
|
||||||
tool_executor,
|
|
||||||
PermissionPolicy::new(PermissionMode::DangerFullAccess),
|
|
||||||
vec!["system".to_string()],
|
|
||||||
);
|
|
||||||
|
|
||||||
let error = runtime
|
|
||||||
.run_turn("trigger", None)
|
|
||||||
.expect_err("health probe failure should abort the turn");
|
|
||||||
assert!(
|
|
||||||
error
|
|
||||||
.to_string()
|
|
||||||
.contains("Session health probe failed after compaction"),
|
|
||||||
"unexpected error: {error}"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
error.to_string().contains("transport unavailable"),
|
|
||||||
"expected underlying probe error: {error}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn compaction_health_probe_skips_empty_compacted_session() {
|
|
||||||
struct SimpleApi;
|
|
||||||
impl ApiClient for SimpleApi {
|
|
||||||
fn stream(
|
|
||||||
&mut self,
|
|
||||||
_request: ApiRequest,
|
|
||||||
) -> Result<Vec<AssistantEvent>, RuntimeError> {
|
|
||||||
Ok(vec![
|
|
||||||
AssistantEvent::TextDelta("done".to_string()),
|
|
||||||
AssistantEvent::MessageStop,
|
|
||||||
])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut session = Session::new();
|
|
||||||
session.record_compaction("fresh summary", 2);
|
|
||||||
|
|
||||||
let tool_executor = StaticToolExecutor::new().register("glob_search", |_input| {
|
|
||||||
Err(ToolError::new(
|
|
||||||
"glob_search should not run for an empty compacted session",
|
|
||||||
))
|
|
||||||
});
|
|
||||||
let mut runtime = ConversationRuntime::new(
|
|
||||||
session,
|
|
||||||
SimpleApi,
|
|
||||||
tool_executor,
|
|
||||||
PermissionPolicy::new(PermissionMode::DangerFullAccess),
|
|
||||||
vec!["system".to_string()],
|
|
||||||
);
|
|
||||||
|
|
||||||
let summary = runtime
|
|
||||||
.run_turn("trigger", None)
|
|
||||||
.expect("empty compacted session should not fail health probe");
|
|
||||||
assert_eq!(summary.auto_compaction, None);
|
|
||||||
assert_eq!(runtime.session().messages.len(), 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn build_assistant_message_requires_message_stop_event() {
|
fn build_assistant_message_requires_message_stop_event() {
|
||||||
// given
|
// given
|
||||||
|
|||||||
@@ -308,20 +308,12 @@ pub fn glob_search(pattern: &str, path: Option<&str>) -> io::Result<GlobSearchOu
|
|||||||
base_dir.join(pattern).to_string_lossy().into_owned()
|
base_dir.join(pattern).to_string_lossy().into_owned()
|
||||||
};
|
};
|
||||||
|
|
||||||
// The `glob` crate does not support brace expansion ({a,b,c}).
|
|
||||||
// Expand braces into multiple patterns so patterns like
|
|
||||||
// `Assets/**/*.{cs,uxml,uss}` work correctly.
|
|
||||||
let expanded = expand_braces(&search_pattern);
|
|
||||||
|
|
||||||
let mut seen = std::collections::HashSet::new();
|
|
||||||
let mut matches = Vec::new();
|
let mut matches = Vec::new();
|
||||||
for pat in &expanded {
|
let entries = glob::glob(&search_pattern)
|
||||||
let entries = glob::glob(pat)
|
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error.to_string()))?;
|
||||||
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error.to_string()))?;
|
for entry in entries.flatten() {
|
||||||
for entry in entries.flatten() {
|
if entry.is_file() {
|
||||||
if entry.is_file() && seen.insert(entry.clone()) {
|
matches.push(entry);
|
||||||
matches.push(entry);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -627,35 +619,13 @@ pub fn is_symlink_escape(path: &Path, workspace_root: &Path) -> io::Result<bool>
|
|||||||
Ok(!resolved.starts_with(&canonical_root))
|
Ok(!resolved.starts_with(&canonical_root))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Expand shell-style brace groups in a glob pattern.
|
|
||||||
///
|
|
||||||
/// Handles one level of braces: `foo.{a,b,c}` → `["foo.a", "foo.b", "foo.c"]`.
|
|
||||||
/// Nested braces are not expanded (uncommon in practice).
|
|
||||||
/// Patterns without braces pass through unchanged.
|
|
||||||
fn expand_braces(pattern: &str) -> Vec<String> {
|
|
||||||
let Some(open) = pattern.find('{') else {
|
|
||||||
return vec![pattern.to_owned()];
|
|
||||||
};
|
|
||||||
let Some(close) = pattern[open..].find('}').map(|i| open + i) else {
|
|
||||||
// Unmatched brace — treat as literal.
|
|
||||||
return vec![pattern.to_owned()];
|
|
||||||
};
|
|
||||||
let prefix = &pattern[..open];
|
|
||||||
let suffix = &pattern[close + 1..];
|
|
||||||
let alternatives = &pattern[open + 1..close];
|
|
||||||
alternatives
|
|
||||||
.split(',')
|
|
||||||
.flat_map(|alt| expand_braces(&format!("{prefix}{alt}{suffix}")))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
edit_file, expand_braces, glob_search, grep_search, is_symlink_escape, read_file,
|
edit_file, glob_search, grep_search, is_symlink_escape, read_file, read_file_in_workspace,
|
||||||
read_file_in_workspace, write_file, GrepSearchInput, MAX_WRITE_SIZE,
|
write_file, GrepSearchInput, MAX_WRITE_SIZE,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn temp_path(name: &str) -> std::path::PathBuf {
|
fn temp_path(name: &str) -> std::path::PathBuf {
|
||||||
@@ -789,51 +759,4 @@ mod tests {
|
|||||||
.expect("grep should succeed");
|
.expect("grep should succeed");
|
||||||
assert!(grep_output.content.unwrap_or_default().contains("hello"));
|
assert!(grep_output.content.unwrap_or_default().contains("hello"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn expand_braces_no_braces() {
|
|
||||||
assert_eq!(expand_braces("*.rs"), vec!["*.rs"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn expand_braces_single_group() {
|
|
||||||
let mut result = expand_braces("Assets/**/*.{cs,uxml,uss}");
|
|
||||||
result.sort();
|
|
||||||
assert_eq!(
|
|
||||||
result,
|
|
||||||
vec!["Assets/**/*.cs", "Assets/**/*.uss", "Assets/**/*.uxml",]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn expand_braces_nested() {
|
|
||||||
let mut result = expand_braces("src/{a,b}.{rs,toml}");
|
|
||||||
result.sort();
|
|
||||||
assert_eq!(
|
|
||||||
result,
|
|
||||||
vec!["src/a.rs", "src/a.toml", "src/b.rs", "src/b.toml"]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn expand_braces_unmatched() {
|
|
||||||
assert_eq!(expand_braces("foo.{bar"), vec!["foo.{bar"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn glob_search_with_braces_finds_files() {
|
|
||||||
let dir = temp_path("glob-braces");
|
|
||||||
std::fs::create_dir_all(&dir).unwrap();
|
|
||||||
std::fs::write(dir.join("a.rs"), "fn main() {}").unwrap();
|
|
||||||
std::fs::write(dir.join("b.toml"), "[package]").unwrap();
|
|
||||||
std::fs::write(dir.join("c.txt"), "hello").unwrap();
|
|
||||||
|
|
||||||
let result =
|
|
||||||
glob_search("*.{rs,toml}", Some(dir.to_str().unwrap())).expect("glob should succeed");
|
|
||||||
assert_eq!(
|
|
||||||
result.num_files, 2,
|
|
||||||
"should match .rs and .toml but not .txt"
|
|
||||||
);
|
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
use std::ffi::OsStr;
|
use std::ffi::OsStr;
|
||||||
use std::fmt::Write as FmtWrite;
|
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::process::{Command, Stdio};
|
use std::process::{Command, Stdio};
|
||||||
use std::sync::{
|
use std::sync::{
|
||||||
@@ -14,8 +13,6 @@ use serde_json::{json, Value};
|
|||||||
use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig};
|
use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig};
|
||||||
use crate::permissions::PermissionOverride;
|
use crate::permissions::PermissionOverride;
|
||||||
|
|
||||||
const HOOK_PREVIEW_CHAR_LIMIT: usize = 160;
|
|
||||||
|
|
||||||
pub type HookPermissionDecision = PermissionOverride;
|
pub type HookPermissionDecision = PermissionOverride;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -440,7 +437,7 @@ impl HookRunner {
|
|||||||
Ok(CommandExecution::Finished(output)) => {
|
Ok(CommandExecution::Finished(output)) => {
|
||||||
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||||
let parsed = parse_hook_output(event, tool_name, command, &stdout, &stderr);
|
let parsed = parse_hook_output(&stdout);
|
||||||
let primary_message = parsed.primary_message().map(ToOwned::to_owned);
|
let primary_message = parsed.primary_message().map(ToOwned::to_owned);
|
||||||
match output.status.code() {
|
match output.status.code() {
|
||||||
Some(0) => {
|
Some(0) => {
|
||||||
@@ -535,54 +532,16 @@ fn merge_parsed_hook_output(target: &mut HookRunResult, parsed: ParsedHookOutput
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_hook_output(
|
fn parse_hook_output(stdout: &str) -> ParsedHookOutput {
|
||||||
event: HookEvent,
|
|
||||||
tool_name: &str,
|
|
||||||
command: &str,
|
|
||||||
stdout: &str,
|
|
||||||
stderr: &str,
|
|
||||||
) -> ParsedHookOutput {
|
|
||||||
if stdout.is_empty() {
|
if stdout.is_empty() {
|
||||||
return ParsedHookOutput::default();
|
return ParsedHookOutput::default();
|
||||||
}
|
}
|
||||||
|
|
||||||
let root = match serde_json::from_str::<Value>(stdout) {
|
let Ok(Value::Object(root)) = serde_json::from_str::<Value>(stdout) else {
|
||||||
Ok(Value::Object(root)) => root,
|
return ParsedHookOutput {
|
||||||
Ok(value) => {
|
messages: vec![stdout.to_string()],
|
||||||
return ParsedHookOutput {
|
..ParsedHookOutput::default()
|
||||||
messages: vec![format_invalid_hook_output(
|
};
|
||||||
event,
|
|
||||||
tool_name,
|
|
||||||
command,
|
|
||||||
&format!(
|
|
||||||
"expected top-level JSON object, got {}",
|
|
||||||
json_type_name(&value)
|
|
||||||
),
|
|
||||||
stdout,
|
|
||||||
stderr,
|
|
||||||
)],
|
|
||||||
..ParsedHookOutput::default()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
Err(error) if looks_like_json_attempt(stdout) => {
|
|
||||||
return ParsedHookOutput {
|
|
||||||
messages: vec![format_invalid_hook_output(
|
|
||||||
event,
|
|
||||||
tool_name,
|
|
||||||
command,
|
|
||||||
&error.to_string(),
|
|
||||||
stdout,
|
|
||||||
stderr,
|
|
||||||
)],
|
|
||||||
..ParsedHookOutput::default()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
return ParsedHookOutput {
|
|
||||||
messages: vec![stdout.to_string()],
|
|
||||||
..ParsedHookOutput::default()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut parsed = ParsedHookOutput::default();
|
let mut parsed = ParsedHookOutput::default();
|
||||||
@@ -660,69 +619,6 @@ fn parse_tool_input(tool_input: &str) -> Value {
|
|||||||
serde_json::from_str(tool_input).unwrap_or_else(|_| json!({ "raw": tool_input }))
|
serde_json::from_str(tool_input).unwrap_or_else(|_| json!({ "raw": tool_input }))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn format_invalid_hook_output(
|
|
||||||
event: HookEvent,
|
|
||||||
tool_name: &str,
|
|
||||||
command: &str,
|
|
||||||
detail: &str,
|
|
||||||
stdout: &str,
|
|
||||||
stderr: &str,
|
|
||||||
) -> String {
|
|
||||||
let stdout_preview = bounded_hook_preview(stdout).unwrap_or_else(|| "<empty>".to_string());
|
|
||||||
let stderr_preview = bounded_hook_preview(stderr).unwrap_or_else(|| "<empty>".to_string());
|
|
||||||
let command_preview = bounded_hook_preview(command).unwrap_or_else(|| "<empty>".to_string());
|
|
||||||
|
|
||||||
format!(
|
|
||||||
"hook_invalid_json: phase={} tool={} command={} detail={} stdout_preview={} stderr_preview={}",
|
|
||||||
event.as_str(),
|
|
||||||
tool_name,
|
|
||||||
command_preview,
|
|
||||||
detail,
|
|
||||||
stdout_preview,
|
|
||||||
stderr_preview
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn bounded_hook_preview(value: &str) -> Option<String> {
|
|
||||||
let trimmed = value.trim();
|
|
||||||
if trimmed.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut preview = String::new();
|
|
||||||
for (count, ch) in trimmed.chars().enumerate() {
|
|
||||||
if count == HOOK_PREVIEW_CHAR_LIMIT {
|
|
||||||
preview.push('…');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
match ch {
|
|
||||||
'\n' => preview.push_str("\\n"),
|
|
||||||
'\r' => preview.push_str("\\r"),
|
|
||||||
'\t' => preview.push_str("\\t"),
|
|
||||||
control if control.is_control() => {
|
|
||||||
let _ = write!(&mut preview, "\\u{{{:x}}}", control as u32);
|
|
||||||
}
|
|
||||||
_ => preview.push(ch),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(preview)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn json_type_name(value: &Value) -> &'static str {
|
|
||||||
match value {
|
|
||||||
Value::Null => "null",
|
|
||||||
Value::Bool(_) => "boolean",
|
|
||||||
Value::Number(_) => "number",
|
|
||||||
Value::String(_) => "string",
|
|
||||||
Value::Array(_) => "array",
|
|
||||||
Value::Object(_) => "object",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn looks_like_json_attempt(value: &str) -> bool {
|
|
||||||
matches!(value.trim_start().chars().next(), Some('{' | '['))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_hook_failure(command: &str, code: i32, stdout: Option<&str>, stderr: &str) -> String {
|
fn format_hook_failure(command: &str, code: i32, stdout: Option<&str>, stderr: &str) -> String {
|
||||||
let mut message = format!("Hook `{command}` exited with status {code}");
|
let mut message = format!("Hook `{command}` exited with status {code}");
|
||||||
if let Some(stdout) = stdout.filter(|stdout| !stdout.is_empty()) {
|
if let Some(stdout) = stdout.filter(|stdout| !stdout.is_empty()) {
|
||||||
@@ -1039,31 +935,6 @@ mod tests {
|
|||||||
assert!(!result.messages().iter().any(|message| message == "later"));
|
assert!(!result.messages().iter().any(|message| message == "later"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn malformed_nonempty_hook_output_reports_explicit_diagnostic_with_previews() {
|
|
||||||
let runner = HookRunner::new(RuntimeHookConfig::new(
|
|
||||||
vec![shell_snippet(
|
|
||||||
"printf '{not-json\nsecond line'; printf 'stderr warning' >&2; exit 1",
|
|
||||||
)],
|
|
||||||
Vec::new(),
|
|
||||||
Vec::new(),
|
|
||||||
));
|
|
||||||
|
|
||||||
let result = runner.run_pre_tool_use("Edit", r#"{"file":"src/lib.rs"}"#);
|
|
||||||
|
|
||||||
assert!(result.is_failed());
|
|
||||||
let rendered = result.messages().join("\n");
|
|
||||||
assert!(rendered.contains("hook_invalid_json:"));
|
|
||||||
assert!(rendered.contains("phase=PreToolUse"));
|
|
||||||
assert!(rendered.contains("tool=Edit"));
|
|
||||||
assert!(rendered.contains("command=printf '{not-json"));
|
|
||||||
assert!(rendered.contains("printf 'stderr warning' >&2; exit 1"));
|
|
||||||
assert!(rendered.contains("detail=key must be a string"));
|
|
||||||
assert!(rendered.contains("stdout_preview={not-json"));
|
|
||||||
assert!(rendered.contains("second line stderr_preview=stderr warning"));
|
|
||||||
assert!(rendered.contains("stderr_preview=stderr warning"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn abort_signal_cancels_long_running_hook_and_reports_progress() {
|
fn abort_signal_cancels_long_running_hook_and_reports_progress() {
|
||||||
let runner = HookRunner::new(RuntimeHookConfig::new(
|
let runner = HookRunner::new(RuntimeHookConfig::new(
|
||||||
|
|||||||
@@ -36,8 +36,6 @@ pub enum LaneEventName {
|
|||||||
Closed,
|
Closed,
|
||||||
#[serde(rename = "branch.stale_against_main")]
|
#[serde(rename = "branch.stale_against_main")]
|
||||||
BranchStaleAgainstMain,
|
BranchStaleAgainstMain,
|
||||||
#[serde(rename = "branch.workspace_mismatch")]
|
|
||||||
BranchWorkspaceMismatch,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
@@ -69,7 +67,6 @@ pub enum LaneFailureClass {
|
|||||||
McpHandshake,
|
McpHandshake,
|
||||||
GatewayRouting,
|
GatewayRouting,
|
||||||
ToolRuntime,
|
ToolRuntime,
|
||||||
WorkspaceMismatch,
|
|
||||||
Infra,
|
Infra,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,10 +277,6 @@ mod tests {
|
|||||||
LaneEventName::BranchStaleAgainstMain,
|
LaneEventName::BranchStaleAgainstMain,
|
||||||
"branch.stale_against_main",
|
"branch.stale_against_main",
|
||||||
),
|
),
|
||||||
(
|
|
||||||
LaneEventName::BranchWorkspaceMismatch,
|
|
||||||
"branch.workspace_mismatch",
|
|
||||||
),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
for (event, expected) in cases {
|
for (event, expected) in cases {
|
||||||
@@ -307,7 +300,6 @@ mod tests {
|
|||||||
(LaneFailureClass::McpHandshake, "mcp_handshake"),
|
(LaneFailureClass::McpHandshake, "mcp_handshake"),
|
||||||
(LaneFailureClass::GatewayRouting, "gateway_routing"),
|
(LaneFailureClass::GatewayRouting, "gateway_routing"),
|
||||||
(LaneFailureClass::ToolRuntime, "tool_runtime"),
|
(LaneFailureClass::ToolRuntime, "tool_runtime"),
|
||||||
(LaneFailureClass::WorkspaceMismatch, "workspace_mismatch"),
|
|
||||||
(LaneFailureClass::Infra, "infra"),
|
(LaneFailureClass::Infra, "infra"),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -337,38 +329,6 @@ mod tests {
|
|||||||
assert_eq!(failed.detail.as_deref(), Some("broken server"));
|
assert_eq!(failed.detail.as_deref(), Some("broken server"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn workspace_mismatch_failure_class_round_trips_in_branch_event_payloads() {
|
|
||||||
let mismatch = LaneEvent::new(
|
|
||||||
LaneEventName::BranchWorkspaceMismatch,
|
|
||||||
LaneEventStatus::Blocked,
|
|
||||||
"2026-04-04T00:00:02Z",
|
|
||||||
)
|
|
||||||
.with_failure_class(LaneFailureClass::WorkspaceMismatch)
|
|
||||||
.with_detail("session belongs to /tmp/repo-a but current workspace is /tmp/repo-b")
|
|
||||||
.with_data(json!({
|
|
||||||
"expectedWorkspaceRoot": "/tmp/repo-a",
|
|
||||||
"actualWorkspaceRoot": "/tmp/repo-b",
|
|
||||||
"sessionId": "sess-123",
|
|
||||||
}));
|
|
||||||
|
|
||||||
let mismatch_json = serde_json::to_value(&mismatch).expect("lane event should serialize");
|
|
||||||
assert_eq!(mismatch_json["event"], "branch.workspace_mismatch");
|
|
||||||
assert_eq!(mismatch_json["failureClass"], "workspace_mismatch");
|
|
||||||
assert_eq!(
|
|
||||||
mismatch_json["data"]["expectedWorkspaceRoot"],
|
|
||||||
"/tmp/repo-a"
|
|
||||||
);
|
|
||||||
|
|
||||||
let round_trip: LaneEvent =
|
|
||||||
serde_json::from_value(mismatch_json).expect("lane event should deserialize");
|
|
||||||
assert_eq!(round_trip.event, LaneEventName::BranchWorkspaceMismatch);
|
|
||||||
assert_eq!(
|
|
||||||
round_trip.failure_class,
|
|
||||||
Some(LaneFailureClass::WorkspaceMismatch)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn commit_events_can_carry_worktree_and_supersession_metadata() {
|
fn commit_events_can_carry_worktree_and_supersession_metadata() {
|
||||||
let event = LaneEvent::commit_created(
|
let event = LaneEvent::commit_created(
|
||||||
|
|||||||
@@ -335,14 +335,7 @@ fn credentials_home_dir() -> io::Result<PathBuf> {
|
|||||||
return Ok(PathBuf::from(path));
|
return Ok(PathBuf::from(path));
|
||||||
}
|
}
|
||||||
let home = std::env::var_os("HOME")
|
let home = std::env::var_os("HOME")
|
||||||
.or_else(|| std::env::var_os("USERPROFILE"))
|
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "HOME is not set"))?;
|
||||||
.ok_or_else(|| {
|
|
||||||
io::Error::new(
|
|
||||||
io::ErrorKind::NotFound,
|
|
||||||
"HOME is not set (on Windows, set USERPROFILE or HOME, \
|
|
||||||
or use CLAW_CONFIG_HOME to point directly at the config directory)",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
Ok(PathBuf::from(home).join(".claw"))
|
Ok(PathBuf::from(home).join(".claw"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,40 +65,6 @@ impl PermissionEnforcer {
|
|||||||
matches!(self.check(tool_name, input), EnforcementResult::Allowed)
|
matches!(self.check(tool_name, input), EnforcementResult::Allowed)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check permission with an explicitly provided required mode.
|
|
||||||
/// Used when the required mode is determined dynamically (e.g., bash command classification).
|
|
||||||
pub fn check_with_required_mode(
|
|
||||||
&self,
|
|
||||||
tool_name: &str,
|
|
||||||
input: &str,
|
|
||||||
required_mode: PermissionMode,
|
|
||||||
) -> EnforcementResult {
|
|
||||||
// When the active mode is Prompt, defer to the caller's interactive
|
|
||||||
// prompt flow rather than hard-denying.
|
|
||||||
if self.policy.active_mode() == PermissionMode::Prompt {
|
|
||||||
return EnforcementResult::Allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
let active_mode = self.policy.active_mode();
|
|
||||||
|
|
||||||
// Check if active mode meets the dynamically determined required mode
|
|
||||||
if active_mode >= required_mode {
|
|
||||||
return EnforcementResult::Allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Permission denied - active mode is insufficient
|
|
||||||
EnforcementResult::Denied {
|
|
||||||
tool: tool_name.to_owned(),
|
|
||||||
active_mode: active_mode.as_str().to_owned(),
|
|
||||||
required_mode: required_mode.as_str().to_owned(),
|
|
||||||
reason: format!(
|
|
||||||
"'{tool_name}' with input '{input}' requires '{}' permission, but current mode is '{}'",
|
|
||||||
required_mode.as_str(),
|
|
||||||
active_mode.as_str()
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn active_mode(&self) -> PermissionMode {
|
pub fn active_mode(&self) -> PermissionMode {
|
||||||
self.policy.active_mode()
|
self.policy.active_mode()
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ const SESSION_VERSION: u32 = 1;
|
|||||||
const ROTATE_AFTER_BYTES: u64 = 256 * 1024;
|
const ROTATE_AFTER_BYTES: u64 = 256 * 1024;
|
||||||
const MAX_ROTATED_FILES: usize = 3;
|
const MAX_ROTATED_FILES: usize = 3;
|
||||||
static SESSION_ID_COUNTER: AtomicU64 = AtomicU64::new(0);
|
static SESSION_ID_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||||
static LAST_TIMESTAMP_MS: AtomicU64 = AtomicU64::new(0);
|
|
||||||
|
|
||||||
/// Speaker role associated with a persisted conversation message.
|
/// Speaker role associated with a persisted conversation message.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -97,11 +96,6 @@ pub struct Session {
|
|||||||
pub fork: Option<SessionFork>,
|
pub fork: Option<SessionFork>,
|
||||||
pub workspace_root: Option<PathBuf>,
|
pub workspace_root: Option<PathBuf>,
|
||||||
pub prompt_history: Vec<SessionPromptEntry>,
|
pub prompt_history: Vec<SessionPromptEntry>,
|
||||||
/// The model used in this session, persisted so resumed sessions can
|
|
||||||
/// report which model was originally used.
|
|
||||||
/// Timestamp of last successful health check (ROADMAP #38)
|
|
||||||
pub last_health_check_ms: Option<u64>,
|
|
||||||
pub model: Option<String>,
|
|
||||||
persistence: Option<SessionPersistence>,
|
persistence: Option<SessionPersistence>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,7 +110,6 @@ impl PartialEq for Session {
|
|||||||
&& self.fork == other.fork
|
&& self.fork == other.fork
|
||||||
&& self.workspace_root == other.workspace_root
|
&& self.workspace_root == other.workspace_root
|
||||||
&& self.prompt_history == other.prompt_history
|
&& self.prompt_history == other.prompt_history
|
||||||
&& self.last_health_check_ms == other.last_health_check_ms
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,8 +161,6 @@ impl Session {
|
|||||||
fork: None,
|
fork: None,
|
||||||
workspace_root: None,
|
workspace_root: None,
|
||||||
prompt_history: Vec::new(),
|
prompt_history: Vec::new(),
|
||||||
last_health_check_ms: None,
|
|
||||||
model: None,
|
|
||||||
persistence: None,
|
persistence: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -272,8 +263,6 @@ impl Session {
|
|||||||
}),
|
}),
|
||||||
workspace_root: self.workspace_root.clone(),
|
workspace_root: self.workspace_root.clone(),
|
||||||
prompt_history: self.prompt_history.clone(),
|
prompt_history: self.prompt_history.clone(),
|
||||||
last_health_check_ms: self.last_health_check_ms,
|
|
||||||
model: self.model.clone(),
|
|
||||||
persistence: None,
|
persistence: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -382,10 +371,6 @@ impl Session {
|
|||||||
.collect()
|
.collect()
|
||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let model = object
|
|
||||||
.get("model")
|
|
||||||
.and_then(JsonValue::as_str)
|
|
||||||
.map(String::from);
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
version,
|
version,
|
||||||
session_id,
|
session_id,
|
||||||
@@ -396,8 +381,6 @@ impl Session {
|
|||||||
fork,
|
fork,
|
||||||
workspace_root,
|
workspace_root,
|
||||||
prompt_history,
|
prompt_history,
|
||||||
last_health_check_ms: None,
|
|
||||||
model,
|
|
||||||
persistence: None,
|
persistence: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -411,7 +394,6 @@ impl Session {
|
|||||||
let mut compaction = None;
|
let mut compaction = None;
|
||||||
let mut fork = None;
|
let mut fork = None;
|
||||||
let mut workspace_root = None;
|
let mut workspace_root = None;
|
||||||
let mut model = None;
|
|
||||||
let mut prompt_history = Vec::new();
|
let mut prompt_history = Vec::new();
|
||||||
|
|
||||||
for (line_number, raw_line) in contents.lines().enumerate() {
|
for (line_number, raw_line) in contents.lines().enumerate() {
|
||||||
@@ -451,10 +433,6 @@ impl Session {
|
|||||||
.get("workspace_root")
|
.get("workspace_root")
|
||||||
.and_then(JsonValue::as_str)
|
.and_then(JsonValue::as_str)
|
||||||
.map(PathBuf::from);
|
.map(PathBuf::from);
|
||||||
model = object
|
|
||||||
.get("model")
|
|
||||||
.and_then(JsonValue::as_str)
|
|
||||||
.map(String::from);
|
|
||||||
}
|
}
|
||||||
"message" => {
|
"message" => {
|
||||||
let message_value = object.get("message").ok_or_else(|| {
|
let message_value = object.get("message").ok_or_else(|| {
|
||||||
@@ -497,8 +475,6 @@ impl Session {
|
|||||||
fork,
|
fork,
|
||||||
workspace_root,
|
workspace_root,
|
||||||
prompt_history,
|
prompt_history,
|
||||||
last_health_check_ms: None,
|
|
||||||
model,
|
|
||||||
persistence: None,
|
persistence: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -604,9 +580,6 @@ impl Session {
|
|||||||
JsonValue::String(workspace_root_to_string(workspace_root)?),
|
JsonValue::String(workspace_root_to_string(workspace_root)?),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if let Some(model) = &self.model {
|
|
||||||
object.insert("model".to_string(), JsonValue::String(model.clone()));
|
|
||||||
}
|
|
||||||
Ok(JsonValue::Object(object))
|
Ok(JsonValue::Object(object))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1031,27 +1004,10 @@ fn normalize_optional_string(value: Option<String>) -> Option<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn current_time_millis() -> u64 {
|
fn current_time_millis() -> u64 {
|
||||||
let wall_clock = SystemTime::now()
|
SystemTime::now()
|
||||||
.duration_since(UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
.map(|duration| u64::try_from(duration.as_millis()).unwrap_or(u64::MAX))
|
.map(|duration| u64::try_from(duration.as_millis()).unwrap_or(u64::MAX))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default()
|
||||||
|
|
||||||
let mut candidate = wall_clock;
|
|
||||||
loop {
|
|
||||||
let previous = LAST_TIMESTAMP_MS.load(Ordering::Relaxed);
|
|
||||||
if candidate <= previous {
|
|
||||||
candidate = previous.saturating_add(1);
|
|
||||||
}
|
|
||||||
match LAST_TIMESTAMP_MS.compare_exchange(
|
|
||||||
previous,
|
|
||||||
candidate,
|
|
||||||
Ordering::SeqCst,
|
|
||||||
Ordering::SeqCst,
|
|
||||||
) {
|
|
||||||
Ok(_) => return candidate,
|
|
||||||
Err(actual) => candidate = actual.saturating_add(1),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn generate_session_id() -> String {
|
fn generate_session_id() -> String {
|
||||||
@@ -1143,8 +1099,8 @@ fn cleanup_rotated_logs(path: &Path) -> Result<(), SessionError> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
cleanup_rotated_logs, current_time_millis, rotate_session_file_if_needed, ContentBlock,
|
cleanup_rotated_logs, rotate_session_file_if_needed, ContentBlock, ConversationMessage,
|
||||||
ConversationMessage, MessageRole, Session, SessionFork,
|
MessageRole, Session, SessionFork,
|
||||||
};
|
};
|
||||||
use crate::json::JsonValue;
|
use crate::json::JsonValue;
|
||||||
use crate::usage::TokenUsage;
|
use crate::usage::TokenUsage;
|
||||||
@@ -1152,16 +1108,6 @@ mod tests {
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn session_timestamps_are_monotonic_under_tight_loops() {
|
|
||||||
let first = current_time_millis();
|
|
||||||
let second = current_time_millis();
|
|
||||||
let third = current_time_millis();
|
|
||||||
|
|
||||||
assert!(first < second);
|
|
||||||
assert!(second < third);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn persists_and_restores_session_jsonl() {
|
fn persists_and_restores_session_jsonl() {
|
||||||
let mut session = Session::new();
|
let mut session = Session::new();
|
||||||
@@ -1495,8 +1441,12 @@ mod tests {
|
|||||||
/// Called by external consumers (e.g. clawhip) to enumerate sessions for a CWD.
|
/// Called by external consumers (e.g. clawhip) to enumerate sessions for a CWD.
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub fn workspace_sessions_dir(cwd: &std::path::Path) -> Result<std::path::PathBuf, SessionError> {
|
pub fn workspace_sessions_dir(cwd: &std::path::Path) -> Result<std::path::PathBuf, SessionError> {
|
||||||
let store = crate::session_control::SessionStore::from_cwd(cwd)
|
let store = crate::session_control::SessionStore::from_cwd(cwd).map_err(|e| {
|
||||||
.map_err(|e| SessionError::Io(std::io::Error::other(e.to_string())))?;
|
SessionError::Io(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::Other,
|
||||||
|
e.to_string(),
|
||||||
|
))
|
||||||
|
})?;
|
||||||
Ok(store.sessions_dir().to_path_buf())
|
Ok(store.sessions_dir().to_path_buf())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1513,7 +1463,8 @@ mod workspace_sessions_dir_tests {
|
|||||||
let result = workspace_sessions_dir(&tmp);
|
let result = workspace_sessions_dir(&tmp);
|
||||||
assert!(
|
assert!(
|
||||||
result.is_ok(),
|
result.is_ok(),
|
||||||
"workspace_sessions_dir should succeed for a valid CWD, got: {result:?}"
|
"workspace_sessions_dir should succeed for a valid CWD, got: {:?}",
|
||||||
|
result
|
||||||
);
|
);
|
||||||
let dir = result.unwrap();
|
let dir = result.unwrap();
|
||||||
// The returned path should be non-empty and end with a hash component
|
// The returned path should be non-empty and end with a hash component
|
||||||
|
|||||||
@@ -74,7 +74,6 @@ impl SessionStore {
|
|||||||
&self.workspace_root
|
&self.workspace_root
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn create_handle(&self, session_id: &str) -> SessionHandle {
|
pub fn create_handle(&self, session_id: &str) -> SessionHandle {
|
||||||
let id = session_id.to_string();
|
let id = session_id.to_string();
|
||||||
let path = self
|
let path = self
|
||||||
@@ -122,17 +121,6 @@ impl SessionStore {
|
|||||||
return Ok(path);
|
return Ok(path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(legacy_root) = self.legacy_sessions_root() {
|
|
||||||
for extension in [PRIMARY_SESSION_EXTENSION, LEGACY_SESSION_EXTENSION] {
|
|
||||||
let path = legacy_root.join(format!("{session_id}.{extension}"));
|
|
||||||
if !path.exists() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let session = Session::load_from_path(&path)?;
|
|
||||||
self.validate_loaded_session(&path, &session)?;
|
|
||||||
return Ok(path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(SessionControlError::Format(
|
Err(SessionControlError::Format(
|
||||||
format_missing_session_reference(session_id),
|
format_missing_session_reference(session_id),
|
||||||
))
|
))
|
||||||
@@ -140,11 +128,68 @@ impl SessionStore {
|
|||||||
|
|
||||||
pub fn list_sessions(&self) -> Result<Vec<ManagedSessionSummary>, SessionControlError> {
|
pub fn list_sessions(&self) -> Result<Vec<ManagedSessionSummary>, SessionControlError> {
|
||||||
let mut sessions = Vec::new();
|
let mut sessions = Vec::new();
|
||||||
self.collect_sessions_from_dir(&self.sessions_root, &mut sessions)?;
|
let read_result = fs::read_dir(&self.sessions_root);
|
||||||
if let Some(legacy_root) = self.legacy_sessions_root() {
|
let entries = match read_result {
|
||||||
self.collect_sessions_from_dir(&legacy_root, &mut sessions)?;
|
Ok(entries) => entries,
|
||||||
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(sessions),
|
||||||
|
Err(err) => return Err(err.into()),
|
||||||
|
};
|
||||||
|
for entry in entries {
|
||||||
|
let entry = entry?;
|
||||||
|
let path = entry.path();
|
||||||
|
if !is_managed_session_file(&path) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let metadata = entry.metadata()?;
|
||||||
|
let modified_epoch_millis = metadata
|
||||||
|
.modified()
|
||||||
|
.ok()
|
||||||
|
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
|
||||||
|
.map(|duration| duration.as_millis())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let (id, message_count, parent_session_id, branch_name) =
|
||||||
|
match Session::load_from_path(&path) {
|
||||||
|
Ok(session) => {
|
||||||
|
let parent_session_id = session
|
||||||
|
.fork
|
||||||
|
.as_ref()
|
||||||
|
.map(|fork| fork.parent_session_id.clone());
|
||||||
|
let branch_name = session
|
||||||
|
.fork
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|fork| fork.branch_name.clone());
|
||||||
|
(
|
||||||
|
session.session_id,
|
||||||
|
session.messages.len(),
|
||||||
|
parent_session_id,
|
||||||
|
branch_name,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Err(_) => (
|
||||||
|
path.file_stem()
|
||||||
|
.and_then(|value| value.to_str())
|
||||||
|
.unwrap_or("unknown")
|
||||||
|
.to_string(),
|
||||||
|
0,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
sessions.push(ManagedSessionSummary {
|
||||||
|
id,
|
||||||
|
path,
|
||||||
|
modified_epoch_millis,
|
||||||
|
message_count,
|
||||||
|
parent_session_id,
|
||||||
|
branch_name,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
sort_managed_sessions(&mut sessions);
|
sessions.sort_by(|left, right| {
|
||||||
|
right
|
||||||
|
.modified_epoch_millis
|
||||||
|
.cmp(&left.modified_epoch_millis)
|
||||||
|
.then_with(|| right.id.cmp(&left.id))
|
||||||
|
});
|
||||||
Ok(sessions)
|
Ok(sessions)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,7 +206,6 @@ impl SessionStore {
|
|||||||
) -> Result<LoadedManagedSession, SessionControlError> {
|
) -> Result<LoadedManagedSession, SessionControlError> {
|
||||||
let handle = self.resolve_reference(reference)?;
|
let handle = self.resolve_reference(reference)?;
|
||||||
let session = Session::load_from_path(&handle.path)?;
|
let session = Session::load_from_path(&handle.path)?;
|
||||||
self.validate_loaded_session(&handle.path, &session)?;
|
|
||||||
Ok(LoadedManagedSession {
|
Ok(LoadedManagedSession {
|
||||||
handle: SessionHandle {
|
handle: SessionHandle {
|
||||||
id: session.session_id.clone(),
|
id: session.session_id.clone(),
|
||||||
@@ -177,9 +221,7 @@ impl SessionStore {
|
|||||||
branch_name: Option<String>,
|
branch_name: Option<String>,
|
||||||
) -> Result<ForkedManagedSession, SessionControlError> {
|
) -> Result<ForkedManagedSession, SessionControlError> {
|
||||||
let parent_session_id = session.session_id.clone();
|
let parent_session_id = session.session_id.clone();
|
||||||
let forked = session
|
let forked = session.fork(branch_name);
|
||||||
.fork(branch_name)
|
|
||||||
.with_workspace_root(self.workspace_root.clone());
|
|
||||||
let handle = self.create_handle(&forked.session_id);
|
let handle = self.create_handle(&forked.session_id);
|
||||||
let branch_name = forked
|
let branch_name = forked
|
||||||
.fork
|
.fork
|
||||||
@@ -194,98 +236,6 @@ impl SessionStore {
|
|||||||
branch_name,
|
branch_name,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn legacy_sessions_root(&self) -> Option<PathBuf> {
|
|
||||||
self.sessions_root
|
|
||||||
.parent()
|
|
||||||
.filter(|parent| parent.file_name().is_some_and(|name| name == "sessions"))
|
|
||||||
.map(Path::to_path_buf)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_loaded_session(
|
|
||||||
&self,
|
|
||||||
session_path: &Path,
|
|
||||||
session: &Session,
|
|
||||||
) -> Result<(), SessionControlError> {
|
|
||||||
let Some(actual) = session.workspace_root() else {
|
|
||||||
if path_is_within_workspace(session_path, &self.workspace_root) {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
return Err(SessionControlError::Format(
|
|
||||||
format_legacy_session_missing_workspace_root(session_path, &self.workspace_root),
|
|
||||||
));
|
|
||||||
};
|
|
||||||
if workspace_roots_match(actual, &self.workspace_root) {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
Err(SessionControlError::WorkspaceMismatch {
|
|
||||||
expected: self.workspace_root.clone(),
|
|
||||||
actual: actual.to_path_buf(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn collect_sessions_from_dir(
|
|
||||||
&self,
|
|
||||||
directory: &Path,
|
|
||||||
sessions: &mut Vec<ManagedSessionSummary>,
|
|
||||||
) -> Result<(), SessionControlError> {
|
|
||||||
let entries = match fs::read_dir(directory) {
|
|
||||||
Ok(entries) => entries,
|
|
||||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
|
||||||
Err(err) => return Err(err.into()),
|
|
||||||
};
|
|
||||||
for entry in entries {
|
|
||||||
let entry = entry?;
|
|
||||||
let path = entry.path();
|
|
||||||
if !is_managed_session_file(&path) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let metadata = entry.metadata()?;
|
|
||||||
let modified_epoch_millis = metadata
|
|
||||||
.modified()
|
|
||||||
.ok()
|
|
||||||
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
|
|
||||||
.map(|duration| duration.as_millis())
|
|
||||||
.unwrap_or_default();
|
|
||||||
let summary = match Session::load_from_path(&path) {
|
|
||||||
Ok(session) => {
|
|
||||||
if self.validate_loaded_session(&path, &session).is_err() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
ManagedSessionSummary {
|
|
||||||
id: session.session_id,
|
|
||||||
path,
|
|
||||||
updated_at_ms: session.updated_at_ms,
|
|
||||||
modified_epoch_millis,
|
|
||||||
message_count: session.messages.len(),
|
|
||||||
parent_session_id: session
|
|
||||||
.fork
|
|
||||||
.as_ref()
|
|
||||||
.map(|fork| fork.parent_session_id.clone()),
|
|
||||||
branch_name: session
|
|
||||||
.fork
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|fork| fork.branch_name.clone()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(_) => ManagedSessionSummary {
|
|
||||||
id: path
|
|
||||||
.file_stem()
|
|
||||||
.and_then(|value| value.to_str())
|
|
||||||
.unwrap_or("unknown")
|
|
||||||
.to_string(),
|
|
||||||
path,
|
|
||||||
updated_at_ms: 0,
|
|
||||||
modified_epoch_millis,
|
|
||||||
message_count: 0,
|
|
||||||
parent_session_id: None,
|
|
||||||
branch_name: None,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
sessions.push(summary);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stable hex fingerprint of a workspace path.
|
/// Stable hex fingerprint of a workspace path.
|
||||||
@@ -319,23 +269,12 @@ pub struct SessionHandle {
|
|||||||
pub struct ManagedSessionSummary {
|
pub struct ManagedSessionSummary {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub path: PathBuf,
|
pub path: PathBuf,
|
||||||
pub updated_at_ms: u64,
|
|
||||||
pub modified_epoch_millis: u128,
|
pub modified_epoch_millis: u128,
|
||||||
pub message_count: usize,
|
pub message_count: usize,
|
||||||
pub parent_session_id: Option<String>,
|
pub parent_session_id: Option<String>,
|
||||||
pub branch_name: Option<String>,
|
pub branch_name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sort_managed_sessions(sessions: &mut [ManagedSessionSummary]) {
|
|
||||||
sessions.sort_by(|left, right| {
|
|
||||||
right
|
|
||||||
.updated_at_ms
|
|
||||||
.cmp(&left.updated_at_ms)
|
|
||||||
.then_with(|| right.modified_epoch_millis.cmp(&left.modified_epoch_millis))
|
|
||||||
.then_with(|| right.id.cmp(&left.id))
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct LoadedManagedSession {
|
pub struct LoadedManagedSession {
|
||||||
pub handle: SessionHandle,
|
pub handle: SessionHandle,
|
||||||
@@ -355,7 +294,6 @@ pub enum SessionControlError {
|
|||||||
Io(std::io::Error),
|
Io(std::io::Error),
|
||||||
Session(SessionError),
|
Session(SessionError),
|
||||||
Format(String),
|
Format(String),
|
||||||
WorkspaceMismatch { expected: PathBuf, actual: PathBuf },
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Display for SessionControlError {
|
impl Display for SessionControlError {
|
||||||
@@ -364,12 +302,6 @@ impl Display for SessionControlError {
|
|||||||
Self::Io(error) => write!(f, "{error}"),
|
Self::Io(error) => write!(f, "{error}"),
|
||||||
Self::Session(error) => write!(f, "{error}"),
|
Self::Session(error) => write!(f, "{error}"),
|
||||||
Self::Format(error) => write!(f, "{error}"),
|
Self::Format(error) => write!(f, "{error}"),
|
||||||
Self::WorkspaceMismatch { expected, actual } => write!(
|
|
||||||
f,
|
|
||||||
"session workspace mismatch: expected {}, found {}",
|
|
||||||
expected.display(),
|
|
||||||
actual.display()
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -395,8 +327,9 @@ pub fn sessions_dir() -> Result<PathBuf, SessionControlError> {
|
|||||||
pub fn managed_sessions_dir_for(
|
pub fn managed_sessions_dir_for(
|
||||||
base_dir: impl AsRef<Path>,
|
base_dir: impl AsRef<Path>,
|
||||||
) -> Result<PathBuf, SessionControlError> {
|
) -> Result<PathBuf, SessionControlError> {
|
||||||
let store = SessionStore::from_cwd(base_dir)?;
|
let path = base_dir.as_ref().join(".claw").join("sessions");
|
||||||
Ok(store.sessions_dir().to_path_buf())
|
fs::create_dir_all(&path)?;
|
||||||
|
Ok(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn create_managed_session_handle(
|
pub fn create_managed_session_handle(
|
||||||
@@ -409,8 +342,10 @@ pub fn create_managed_session_handle_for(
|
|||||||
base_dir: impl AsRef<Path>,
|
base_dir: impl AsRef<Path>,
|
||||||
session_id: &str,
|
session_id: &str,
|
||||||
) -> Result<SessionHandle, SessionControlError> {
|
) -> Result<SessionHandle, SessionControlError> {
|
||||||
let store = SessionStore::from_cwd(base_dir)?;
|
let id = session_id.to_string();
|
||||||
Ok(store.create_handle(session_id))
|
let path =
|
||||||
|
managed_sessions_dir_for(base_dir)?.join(format!("{id}.{PRIMARY_SESSION_EXTENSION}"));
|
||||||
|
Ok(SessionHandle { id, path })
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn resolve_session_reference(reference: &str) -> Result<SessionHandle, SessionControlError> {
|
pub fn resolve_session_reference(reference: &str) -> Result<SessionHandle, SessionControlError> {
|
||||||
@@ -421,8 +356,36 @@ pub fn resolve_session_reference_for(
|
|||||||
base_dir: impl AsRef<Path>,
|
base_dir: impl AsRef<Path>,
|
||||||
reference: &str,
|
reference: &str,
|
||||||
) -> Result<SessionHandle, SessionControlError> {
|
) -> Result<SessionHandle, SessionControlError> {
|
||||||
let store = SessionStore::from_cwd(base_dir)?;
|
let base_dir = base_dir.as_ref();
|
||||||
store.resolve_reference(reference)
|
if is_session_reference_alias(reference) {
|
||||||
|
let latest = latest_managed_session_for(base_dir)?;
|
||||||
|
return Ok(SessionHandle {
|
||||||
|
id: latest.id,
|
||||||
|
path: latest.path,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let direct = PathBuf::from(reference);
|
||||||
|
let candidate = if direct.is_absolute() {
|
||||||
|
direct.clone()
|
||||||
|
} else {
|
||||||
|
base_dir.join(&direct)
|
||||||
|
};
|
||||||
|
let looks_like_path = direct.extension().is_some() || direct.components().count() > 1;
|
||||||
|
let path = if candidate.exists() {
|
||||||
|
candidate
|
||||||
|
} else if looks_like_path {
|
||||||
|
return Err(SessionControlError::Format(
|
||||||
|
format_missing_session_reference(reference),
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
resolve_managed_session_path_for(base_dir, reference)?
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(SessionHandle {
|
||||||
|
id: session_id_from_path(&path).unwrap_or_else(|| reference.to_string()),
|
||||||
|
path,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn resolve_managed_session_path(session_id: &str) -> Result<PathBuf, SessionControlError> {
|
pub fn resolve_managed_session_path(session_id: &str) -> Result<PathBuf, SessionControlError> {
|
||||||
@@ -433,8 +396,16 @@ pub fn resolve_managed_session_path_for(
|
|||||||
base_dir: impl AsRef<Path>,
|
base_dir: impl AsRef<Path>,
|
||||||
session_id: &str,
|
session_id: &str,
|
||||||
) -> Result<PathBuf, SessionControlError> {
|
) -> Result<PathBuf, SessionControlError> {
|
||||||
let store = SessionStore::from_cwd(base_dir)?;
|
let directory = managed_sessions_dir_for(base_dir)?;
|
||||||
store.resolve_managed_path(session_id)
|
for extension in [PRIMARY_SESSION_EXTENSION, LEGACY_SESSION_EXTENSION] {
|
||||||
|
let path = directory.join(format!("{session_id}.{extension}"));
|
||||||
|
if path.exists() {
|
||||||
|
return Ok(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(SessionControlError::Format(
|
||||||
|
format_missing_session_reference(session_id),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
@@ -453,8 +424,64 @@ pub fn list_managed_sessions() -> Result<Vec<ManagedSessionSummary>, SessionCont
|
|||||||
pub fn list_managed_sessions_for(
|
pub fn list_managed_sessions_for(
|
||||||
base_dir: impl AsRef<Path>,
|
base_dir: impl AsRef<Path>,
|
||||||
) -> Result<Vec<ManagedSessionSummary>, SessionControlError> {
|
) -> Result<Vec<ManagedSessionSummary>, SessionControlError> {
|
||||||
let store = SessionStore::from_cwd(base_dir)?;
|
let mut sessions = Vec::new();
|
||||||
store.list_sessions()
|
for entry in fs::read_dir(managed_sessions_dir_for(base_dir)?)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let path = entry.path();
|
||||||
|
if !is_managed_session_file(&path) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let metadata = entry.metadata()?;
|
||||||
|
let modified_epoch_millis = metadata
|
||||||
|
.modified()
|
||||||
|
.ok()
|
||||||
|
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
|
||||||
|
.map(|duration| duration.as_millis())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let (id, message_count, parent_session_id, branch_name) =
|
||||||
|
match Session::load_from_path(&path) {
|
||||||
|
Ok(session) => {
|
||||||
|
let parent_session_id = session
|
||||||
|
.fork
|
||||||
|
.as_ref()
|
||||||
|
.map(|fork| fork.parent_session_id.clone());
|
||||||
|
let branch_name = session
|
||||||
|
.fork
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|fork| fork.branch_name.clone());
|
||||||
|
(
|
||||||
|
session.session_id,
|
||||||
|
session.messages.len(),
|
||||||
|
parent_session_id,
|
||||||
|
branch_name,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Err(_) => (
|
||||||
|
path.file_stem()
|
||||||
|
.and_then(|value| value.to_str())
|
||||||
|
.unwrap_or("unknown")
|
||||||
|
.to_string(),
|
||||||
|
0,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
sessions.push(ManagedSessionSummary {
|
||||||
|
id,
|
||||||
|
path,
|
||||||
|
modified_epoch_millis,
|
||||||
|
message_count,
|
||||||
|
parent_session_id,
|
||||||
|
branch_name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
sessions.sort_by(|left, right| {
|
||||||
|
right
|
||||||
|
.modified_epoch_millis
|
||||||
|
.cmp(&left.modified_epoch_millis)
|
||||||
|
.then_with(|| right.id.cmp(&left.id))
|
||||||
|
});
|
||||||
|
Ok(sessions)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn latest_managed_session() -> Result<ManagedSessionSummary, SessionControlError> {
|
pub fn latest_managed_session() -> Result<ManagedSessionSummary, SessionControlError> {
|
||||||
@@ -464,8 +491,10 @@ pub fn latest_managed_session() -> Result<ManagedSessionSummary, SessionControlE
|
|||||||
pub fn latest_managed_session_for(
|
pub fn latest_managed_session_for(
|
||||||
base_dir: impl AsRef<Path>,
|
base_dir: impl AsRef<Path>,
|
||||||
) -> Result<ManagedSessionSummary, SessionControlError> {
|
) -> Result<ManagedSessionSummary, SessionControlError> {
|
||||||
let store = SessionStore::from_cwd(base_dir)?;
|
list_managed_sessions_for(base_dir)?
|
||||||
store.latest_session()
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| SessionControlError::Format(format_no_managed_sessions()))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn load_managed_session(reference: &str) -> Result<LoadedManagedSession, SessionControlError> {
|
pub fn load_managed_session(reference: &str) -> Result<LoadedManagedSession, SessionControlError> {
|
||||||
@@ -476,8 +505,15 @@ pub fn load_managed_session_for(
|
|||||||
base_dir: impl AsRef<Path>,
|
base_dir: impl AsRef<Path>,
|
||||||
reference: &str,
|
reference: &str,
|
||||||
) -> Result<LoadedManagedSession, SessionControlError> {
|
) -> Result<LoadedManagedSession, SessionControlError> {
|
||||||
let store = SessionStore::from_cwd(base_dir)?;
|
let handle = resolve_session_reference_for(base_dir, reference)?;
|
||||||
store.load_session(reference)
|
let session = Session::load_from_path(&handle.path)?;
|
||||||
|
Ok(LoadedManagedSession {
|
||||||
|
handle: SessionHandle {
|
||||||
|
id: session.session_id.clone(),
|
||||||
|
path: handle.path,
|
||||||
|
},
|
||||||
|
session,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn fork_managed_session(
|
pub fn fork_managed_session(
|
||||||
@@ -492,8 +528,21 @@ pub fn fork_managed_session_for(
|
|||||||
session: &Session,
|
session: &Session,
|
||||||
branch_name: Option<String>,
|
branch_name: Option<String>,
|
||||||
) -> Result<ForkedManagedSession, SessionControlError> {
|
) -> Result<ForkedManagedSession, SessionControlError> {
|
||||||
let store = SessionStore::from_cwd(base_dir)?;
|
let parent_session_id = session.session_id.clone();
|
||||||
store.fork_session(session, branch_name)
|
let forked = session.fork(branch_name);
|
||||||
|
let handle = create_managed_session_handle_for(base_dir, &forked.session_id)?;
|
||||||
|
let branch_name = forked
|
||||||
|
.fork
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|fork| fork.branch_name.clone());
|
||||||
|
let forked = forked.with_persistence_path(handle.path.clone());
|
||||||
|
forked.save_to_path(&handle.path)?;
|
||||||
|
Ok(ForkedManagedSession {
|
||||||
|
parent_session_id,
|
||||||
|
handle,
|
||||||
|
session: forked,
|
||||||
|
branch_name,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
@@ -525,36 +574,12 @@ fn format_no_managed_sessions() -> String {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn format_legacy_session_missing_workspace_root(
|
|
||||||
session_path: &Path,
|
|
||||||
workspace_root: &Path,
|
|
||||||
) -> String {
|
|
||||||
format!(
|
|
||||||
"legacy session is missing workspace binding: {}\nOpen it from its original workspace or re-save it from {}.",
|
|
||||||
session_path.display(),
|
|
||||||
workspace_root.display()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn workspace_roots_match(left: &Path, right: &Path) -> bool {
|
|
||||||
canonicalize_for_compare(left) == canonicalize_for_compare(right)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn canonicalize_for_compare(path: &Path) -> PathBuf {
|
|
||||||
fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn path_is_within_workspace(path: &Path, workspace_root: &Path) -> bool {
|
|
||||||
canonicalize_for_compare(path).starts_with(canonicalize_for_compare(workspace_root))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
create_managed_session_handle_for, fork_managed_session_for, is_session_reference_alias,
|
create_managed_session_handle_for, fork_managed_session_for, is_session_reference_alias,
|
||||||
list_managed_sessions_for, load_managed_session_for, resolve_session_reference_for,
|
list_managed_sessions_for, load_managed_session_for, resolve_session_reference_for,
|
||||||
workspace_fingerprint, ManagedSessionSummary, SessionControlError, SessionStore,
|
workspace_fingerprint, ManagedSessionSummary, SessionStore, LATEST_SESSION_REFERENCE,
|
||||||
LATEST_SESSION_REFERENCE,
|
|
||||||
};
|
};
|
||||||
use crate::session::Session;
|
use crate::session::Session;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
@@ -570,7 +595,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn persist_session(root: &Path, text: &str) -> Session {
|
fn persist_session(root: &Path, text: &str) -> Session {
|
||||||
let mut session = Session::new().with_workspace_root(root.to_path_buf());
|
let mut session = Session::new();
|
||||||
session
|
session
|
||||||
.push_user_text(text)
|
.push_user_text(text)
|
||||||
.expect("session message should save");
|
.expect("session message should save");
|
||||||
@@ -606,35 +631,6 @@ mod tests {
|
|||||||
.expect("session summary should exist")
|
.expect("session summary should exist")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn latest_session_prefers_semantic_updated_at_over_file_mtime() {
|
|
||||||
let mut sessions = vec![
|
|
||||||
ManagedSessionSummary {
|
|
||||||
id: "older-file-newer-session".to_string(),
|
|
||||||
path: PathBuf::from("/tmp/older"),
|
|
||||||
updated_at_ms: 200,
|
|
||||||
modified_epoch_millis: 100,
|
|
||||||
message_count: 2,
|
|
||||||
parent_session_id: None,
|
|
||||||
branch_name: None,
|
|
||||||
},
|
|
||||||
ManagedSessionSummary {
|
|
||||||
id: "newer-file-older-session".to_string(),
|
|
||||||
path: PathBuf::from("/tmp/newer"),
|
|
||||||
updated_at_ms: 100,
|
|
||||||
modified_epoch_millis: 200,
|
|
||||||
message_count: 1,
|
|
||||||
parent_session_id: None,
|
|
||||||
branch_name: None,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
crate::session_control::sort_managed_sessions(&mut sessions);
|
|
||||||
|
|
||||||
assert_eq!(sessions[0].id, "older-file-newer-session");
|
|
||||||
assert_eq!(sessions[1].id, "newer-file-older-session");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn creates_and_lists_managed_sessions() {
|
fn creates_and_lists_managed_sessions() {
|
||||||
// given
|
// given
|
||||||
@@ -712,7 +708,7 @@ mod tests {
|
|||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
fn persist_session_via_store(store: &SessionStore, text: &str) -> Session {
|
fn persist_session_via_store(store: &SessionStore, text: &str) -> Session {
|
||||||
let mut session = Session::new().with_workspace_root(store.workspace_root().to_path_buf());
|
let mut session = Session::new();
|
||||||
session
|
session
|
||||||
.push_user_text(text)
|
.push_user_text(text)
|
||||||
.expect("session message should save");
|
.expect("session message should save");
|
||||||
@@ -824,95 +820,6 @@ mod tests {
|
|||||||
fs::remove_dir_all(base).expect("temp dir should clean up");
|
fs::remove_dir_all(base).expect("temp dir should clean up");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn session_store_rejects_legacy_session_from_other_workspace() {
|
|
||||||
// given
|
|
||||||
let base = temp_dir();
|
|
||||||
let workspace_a = base.join("repo-alpha");
|
|
||||||
let workspace_b = base.join("repo-beta");
|
|
||||||
fs::create_dir_all(&workspace_a).expect("workspace a should exist");
|
|
||||||
fs::create_dir_all(&workspace_b).expect("workspace b should exist");
|
|
||||||
|
|
||||||
let store_b = SessionStore::from_cwd(&workspace_b).expect("store b should build");
|
|
||||||
let legacy_root = workspace_b.join(".claw").join("sessions");
|
|
||||||
fs::create_dir_all(&legacy_root).expect("legacy root should exist");
|
|
||||||
let legacy_path = legacy_root.join("legacy-cross.jsonl");
|
|
||||||
let session = Session::new()
|
|
||||||
.with_workspace_root(workspace_a.clone())
|
|
||||||
.with_persistence_path(legacy_path.clone());
|
|
||||||
session
|
|
||||||
.save_to_path(&legacy_path)
|
|
||||||
.expect("legacy session should persist");
|
|
||||||
|
|
||||||
// when
|
|
||||||
let err = store_b
|
|
||||||
.load_session("legacy-cross")
|
|
||||||
.expect_err("workspace mismatch should be rejected");
|
|
||||||
|
|
||||||
// then
|
|
||||||
match err {
|
|
||||||
SessionControlError::WorkspaceMismatch { expected, actual } => {
|
|
||||||
assert_eq!(expected, workspace_b);
|
|
||||||
assert_eq!(actual, workspace_a);
|
|
||||||
}
|
|
||||||
other => panic!("expected workspace mismatch, got {other:?}"),
|
|
||||||
}
|
|
||||||
fs::remove_dir_all(base).expect("temp dir should clean up");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn session_store_loads_safe_legacy_session_from_same_workspace() {
|
|
||||||
// given
|
|
||||||
let base = temp_dir();
|
|
||||||
fs::create_dir_all(&base).expect("base dir should exist");
|
|
||||||
let store = SessionStore::from_cwd(&base).expect("store should build");
|
|
||||||
let legacy_root = base.join(".claw").join("sessions");
|
|
||||||
let legacy_path = legacy_root.join("legacy-safe.jsonl");
|
|
||||||
fs::create_dir_all(&legacy_root).expect("legacy root should exist");
|
|
||||||
let session = Session::new()
|
|
||||||
.with_workspace_root(base.clone())
|
|
||||||
.with_persistence_path(legacy_path.clone());
|
|
||||||
session
|
|
||||||
.save_to_path(&legacy_path)
|
|
||||||
.expect("legacy session should persist");
|
|
||||||
|
|
||||||
// when
|
|
||||||
let loaded = store
|
|
||||||
.load_session("legacy-safe")
|
|
||||||
.expect("same-workspace legacy session should load");
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(loaded.handle.id, session.session_id);
|
|
||||||
assert_eq!(loaded.handle.path, legacy_path);
|
|
||||||
assert_eq!(loaded.session.workspace_root(), Some(base.as_path()));
|
|
||||||
fs::remove_dir_all(base).expect("temp dir should clean up");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn session_store_loads_unbound_legacy_session_from_same_workspace() {
|
|
||||||
// given
|
|
||||||
let base = temp_dir();
|
|
||||||
fs::create_dir_all(&base).expect("base dir should exist");
|
|
||||||
let store = SessionStore::from_cwd(&base).expect("store should build");
|
|
||||||
let legacy_root = base.join(".claw").join("sessions");
|
|
||||||
let legacy_path = legacy_root.join("legacy-unbound.json");
|
|
||||||
fs::create_dir_all(&legacy_root).expect("legacy root should exist");
|
|
||||||
let session = Session::new().with_persistence_path(legacy_path.clone());
|
|
||||||
session
|
|
||||||
.save_to_path(&legacy_path)
|
|
||||||
.expect("legacy session should persist");
|
|
||||||
|
|
||||||
// when
|
|
||||||
let loaded = store
|
|
||||||
.load_session("legacy-unbound")
|
|
||||||
.expect("same-workspace legacy session without workspace binding should load");
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(loaded.handle.path, legacy_path);
|
|
||||||
assert_eq!(loaded.session.workspace_root(), None);
|
|
||||||
fs::remove_dir_all(base).expect("temp dir should clean up");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn session_store_latest_and_resolve_reference() {
|
fn session_store_latest_and_resolve_reference() {
|
||||||
// given
|
// given
|
||||||
|
|||||||
@@ -92,7 +92,6 @@ pub enum WorkerTrustResolution {
|
|||||||
pub enum WorkerPromptTarget {
|
pub enum WorkerPromptTarget {
|
||||||
Shell,
|
Shell,
|
||||||
WrongTarget,
|
WrongTarget,
|
||||||
WrongTask,
|
|
||||||
Unknown,
|
Unknown,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,24 +108,10 @@ pub enum WorkerEventPayload {
|
|||||||
observed_target: WorkerPromptTarget,
|
observed_target: WorkerPromptTarget,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
observed_cwd: Option<String>,
|
observed_cwd: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
observed_prompt_preview: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
task_receipt: Option<WorkerTaskReceipt>,
|
|
||||||
recovery_armed: bool,
|
recovery_armed: bool,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
pub struct WorkerTaskReceipt {
|
|
||||||
pub repo: String,
|
|
||||||
pub task_kind: String,
|
|
||||||
pub source_surface: String,
|
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
||||||
pub expected_artifacts: Vec<String>,
|
|
||||||
pub objective_preview: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct WorkerEvent {
|
pub struct WorkerEvent {
|
||||||
pub seq: u64,
|
pub seq: u64,
|
||||||
@@ -149,7 +134,6 @@ pub struct Worker {
|
|||||||
pub prompt_delivery_attempts: u32,
|
pub prompt_delivery_attempts: u32,
|
||||||
pub prompt_in_flight: bool,
|
pub prompt_in_flight: bool,
|
||||||
pub last_prompt: Option<String>,
|
pub last_prompt: Option<String>,
|
||||||
pub expected_receipt: Option<WorkerTaskReceipt>,
|
|
||||||
pub replay_prompt: Option<String>,
|
pub replay_prompt: Option<String>,
|
||||||
pub last_error: Option<WorkerFailure>,
|
pub last_error: Option<WorkerFailure>,
|
||||||
pub created_at: u64,
|
pub created_at: u64,
|
||||||
@@ -198,7 +182,6 @@ impl WorkerRegistry {
|
|||||||
prompt_delivery_attempts: 0,
|
prompt_delivery_attempts: 0,
|
||||||
prompt_in_flight: false,
|
prompt_in_flight: false,
|
||||||
last_prompt: None,
|
last_prompt: None,
|
||||||
expected_receipt: None,
|
|
||||||
replay_prompt: None,
|
replay_prompt: None,
|
||||||
last_error: None,
|
last_error: None,
|
||||||
created_at: ts,
|
created_at: ts,
|
||||||
@@ -274,7 +257,6 @@ impl WorkerRegistry {
|
|||||||
&lowered,
|
&lowered,
|
||||||
worker.last_prompt.as_deref(),
|
worker.last_prompt.as_deref(),
|
||||||
&worker.cwd,
|
&worker.cwd,
|
||||||
worker.expected_receipt.as_ref(),
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.flatten()
|
.flatten()
|
||||||
@@ -290,10 +272,6 @@ impl WorkerRegistry {
|
|||||||
"worker prompt landed in the wrong target instead of {}: {}",
|
"worker prompt landed in the wrong target instead of {}: {}",
|
||||||
worker.cwd, prompt_preview
|
worker.cwd, prompt_preview
|
||||||
),
|
),
|
||||||
WorkerPromptTarget::WrongTask => format!(
|
|
||||||
"worker prompt receipt mismatched the expected task context for {}: {}",
|
|
||||||
worker.cwd, prompt_preview
|
|
||||||
),
|
|
||||||
WorkerPromptTarget::Unknown => format!(
|
WorkerPromptTarget::Unknown => format!(
|
||||||
"worker prompt delivery failed before reaching coding agent: {prompt_preview}"
|
"worker prompt delivery failed before reaching coding agent: {prompt_preview}"
|
||||||
),
|
),
|
||||||
@@ -313,8 +291,6 @@ impl WorkerRegistry {
|
|||||||
prompt_preview: prompt_preview.clone(),
|
prompt_preview: prompt_preview.clone(),
|
||||||
observed_target: observation.target,
|
observed_target: observation.target,
|
||||||
observed_cwd: observation.observed_cwd.clone(),
|
observed_cwd: observation.observed_cwd.clone(),
|
||||||
observed_prompt_preview: observation.observed_prompt_preview.clone(),
|
|
||||||
task_receipt: worker.expected_receipt.clone(),
|
|
||||||
recovery_armed: false,
|
recovery_armed: false,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -330,8 +306,6 @@ impl WorkerRegistry {
|
|||||||
prompt_preview,
|
prompt_preview,
|
||||||
observed_target: observation.target,
|
observed_target: observation.target,
|
||||||
observed_cwd: observation.observed_cwd,
|
observed_cwd: observation.observed_cwd,
|
||||||
observed_prompt_preview: observation.observed_prompt_preview,
|
|
||||||
task_receipt: worker.expected_receipt.clone(),
|
|
||||||
recovery_armed: true,
|
recovery_armed: true,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -400,12 +374,7 @@ impl WorkerRegistry {
|
|||||||
Ok(worker.clone())
|
Ok(worker.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn send_prompt(
|
pub fn send_prompt(&self, worker_id: &str, prompt: Option<&str>) -> Result<Worker, String> {
|
||||||
&self,
|
|
||||||
worker_id: &str,
|
|
||||||
prompt: Option<&str>,
|
|
||||||
task_receipt: Option<WorkerTaskReceipt>,
|
|
||||||
) -> Result<Worker, String> {
|
|
||||||
let mut inner = self.inner.lock().expect("worker registry lock poisoned");
|
let mut inner = self.inner.lock().expect("worker registry lock poisoned");
|
||||||
let worker = inner
|
let worker = inner
|
||||||
.workers
|
.workers
|
||||||
@@ -429,7 +398,6 @@ impl WorkerRegistry {
|
|||||||
worker.prompt_delivery_attempts += 1;
|
worker.prompt_delivery_attempts += 1;
|
||||||
worker.prompt_in_flight = true;
|
worker.prompt_in_flight = true;
|
||||||
worker.last_prompt = Some(next_prompt.clone());
|
worker.last_prompt = Some(next_prompt.clone());
|
||||||
worker.expected_receipt = task_receipt;
|
|
||||||
worker.replay_prompt = None;
|
worker.replay_prompt = None;
|
||||||
worker.last_error = None;
|
worker.last_error = None;
|
||||||
worker.status = WorkerStatus::Running;
|
worker.status = WorkerStatus::Running;
|
||||||
@@ -580,7 +548,6 @@ fn prompt_misdelivery_is_relevant(worker: &Worker) -> bool {
|
|||||||
struct PromptDeliveryObservation {
|
struct PromptDeliveryObservation {
|
||||||
target: WorkerPromptTarget,
|
target: WorkerPromptTarget,
|
||||||
observed_cwd: Option<String>,
|
observed_cwd: Option<String>,
|
||||||
observed_prompt_preview: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn push_event(
|
fn push_event(
|
||||||
@@ -608,28 +575,28 @@ fn push_event(
|
|||||||
/// Write current worker state to `.claw/worker-state.json` under the worker's cwd.
|
/// Write current worker state to `.claw/worker-state.json` under the worker's cwd.
|
||||||
/// This is the file-based observability surface: external observers (clawhip, orchestrators)
|
/// This is the file-based observability surface: external observers (clawhip, orchestrators)
|
||||||
/// poll this file instead of requiring an HTTP route on the opencode binary.
|
/// poll this file instead of requiring an HTTP route on the opencode binary.
|
||||||
#[derive(serde::Serialize)]
|
|
||||||
struct StateSnapshot<'a> {
|
|
||||||
worker_id: &'a str,
|
|
||||||
status: WorkerStatus,
|
|
||||||
is_ready: bool,
|
|
||||||
trust_gate_cleared: bool,
|
|
||||||
prompt_in_flight: bool,
|
|
||||||
last_event: Option<&'a WorkerEvent>,
|
|
||||||
updated_at: u64,
|
|
||||||
/// Seconds since last state transition. Clawhip uses this to detect
|
|
||||||
/// stalled workers without computing epoch deltas.
|
|
||||||
seconds_since_update: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn emit_state_file(worker: &Worker) {
|
fn emit_state_file(worker: &Worker) {
|
||||||
let state_dir = std::path::Path::new(&worker.cwd).join(".claw");
|
let state_dir = std::path::Path::new(&worker.cwd).join(".claw");
|
||||||
if std::fs::create_dir_all(&state_dir).is_err() {
|
if let Err(_) = std::fs::create_dir_all(&state_dir) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let state_path = state_dir.join("worker-state.json");
|
let state_path = state_dir.join("worker-state.json");
|
||||||
let tmp_path = state_dir.join("worker-state.json.tmp");
|
let tmp_path = state_dir.join("worker-state.json.tmp");
|
||||||
|
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
struct StateSnapshot<'a> {
|
||||||
|
worker_id: &'a str,
|
||||||
|
status: WorkerStatus,
|
||||||
|
is_ready: bool,
|
||||||
|
trust_gate_cleared: bool,
|
||||||
|
prompt_in_flight: bool,
|
||||||
|
last_event: Option<&'a WorkerEvent>,
|
||||||
|
updated_at: u64,
|
||||||
|
/// Seconds since last state transition. Clawhip uses this to detect
|
||||||
|
/// stalled workers without computing epoch deltas.
|
||||||
|
seconds_since_update: u64,
|
||||||
|
}
|
||||||
|
|
||||||
let now = now_secs();
|
let now = now_secs();
|
||||||
let snapshot = StateSnapshot {
|
let snapshot = StateSnapshot {
|
||||||
worker_id: &worker.worker_id,
|
worker_id: &worker.worker_id,
|
||||||
@@ -732,7 +699,6 @@ fn detect_prompt_misdelivery(
|
|||||||
lowered: &str,
|
lowered: &str,
|
||||||
prompt: Option<&str>,
|
prompt: Option<&str>,
|
||||||
expected_cwd: &str,
|
expected_cwd: &str,
|
||||||
expected_receipt: Option<&WorkerTaskReceipt>,
|
|
||||||
) -> Option<PromptDeliveryObservation> {
|
) -> Option<PromptDeliveryObservation> {
|
||||||
let Some(prompt) = prompt else {
|
let Some(prompt) = prompt else {
|
||||||
return None;
|
return None;
|
||||||
@@ -747,30 +713,12 @@ fn detect_prompt_misdelivery(
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let prompt_visible = lowered.contains(&prompt_snippet);
|
let prompt_visible = lowered.contains(&prompt_snippet);
|
||||||
let observed_prompt_preview = detect_prompt_echo(screen_text);
|
|
||||||
|
|
||||||
if let Some(receipt) = expected_receipt {
|
|
||||||
let receipt_visible = task_receipt_visible(lowered, receipt);
|
|
||||||
let mismatched_prompt_visible = observed_prompt_preview
|
|
||||||
.as_deref()
|
|
||||||
.map(str::to_ascii_lowercase)
|
|
||||||
.is_some_and(|preview| !preview.contains(&prompt_snippet));
|
|
||||||
|
|
||||||
if (prompt_visible || mismatched_prompt_visible) && !receipt_visible {
|
|
||||||
return Some(PromptDeliveryObservation {
|
|
||||||
target: WorkerPromptTarget::WrongTask,
|
|
||||||
observed_cwd: detect_observed_shell_cwd(screen_text),
|
|
||||||
observed_prompt_preview,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(observed_cwd) = detect_observed_shell_cwd(screen_text) {
|
if let Some(observed_cwd) = detect_observed_shell_cwd(screen_text) {
|
||||||
if prompt_visible && !cwd_matches_observed_target(expected_cwd, &observed_cwd) {
|
if prompt_visible && !cwd_matches_observed_target(expected_cwd, &observed_cwd) {
|
||||||
return Some(PromptDeliveryObservation {
|
return Some(PromptDeliveryObservation {
|
||||||
target: WorkerPromptTarget::WrongTarget,
|
target: WorkerPromptTarget::WrongTarget,
|
||||||
observed_cwd: Some(observed_cwd),
|
observed_cwd: Some(observed_cwd),
|
||||||
observed_prompt_preview,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -788,7 +736,6 @@ fn detect_prompt_misdelivery(
|
|||||||
(shell_error && prompt_visible).then_some(PromptDeliveryObservation {
|
(shell_error && prompt_visible).then_some(PromptDeliveryObservation {
|
||||||
target: WorkerPromptTarget::Shell,
|
target: WorkerPromptTarget::Shell,
|
||||||
observed_cwd: None,
|
observed_cwd: None,
|
||||||
observed_prompt_preview,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -801,38 +748,10 @@ fn prompt_preview(prompt: &str) -> String {
|
|||||||
format!("{}…", preview.trim_end())
|
format!("{}…", preview.trim_end())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn detect_prompt_echo(screen_text: &str) -> Option<String> {
|
|
||||||
screen_text.lines().find_map(|line| {
|
|
||||||
line.trim_start()
|
|
||||||
.strip_prefix('›')
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
.map(str::to_string)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn task_receipt_visible(lowered_screen_text: &str, receipt: &WorkerTaskReceipt) -> bool {
|
|
||||||
let expected_tokens = [
|
|
||||||
receipt.repo.to_ascii_lowercase(),
|
|
||||||
receipt.task_kind.to_ascii_lowercase(),
|
|
||||||
receipt.source_surface.to_ascii_lowercase(),
|
|
||||||
receipt.objective_preview.to_ascii_lowercase(),
|
|
||||||
];
|
|
||||||
|
|
||||||
expected_tokens
|
|
||||||
.iter()
|
|
||||||
.all(|token| lowered_screen_text.contains(token))
|
|
||||||
&& receipt
|
|
||||||
.expected_artifacts
|
|
||||||
.iter()
|
|
||||||
.all(|artifact| lowered_screen_text.contains(&artifact.to_ascii_lowercase()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn prompt_misdelivery_detail(observation: &PromptDeliveryObservation) -> &'static str {
|
fn prompt_misdelivery_detail(observation: &PromptDeliveryObservation) -> &'static str {
|
||||||
match observation.target {
|
match observation.target {
|
||||||
WorkerPromptTarget::Shell => "shell misdelivery detected",
|
WorkerPromptTarget::Shell => "shell misdelivery detected",
|
||||||
WorkerPromptTarget::WrongTarget => "prompt landed in wrong target",
|
WorkerPromptTarget::WrongTarget => "prompt landed in wrong target",
|
||||||
WorkerPromptTarget::WrongTask => "prompt receipt mismatched expected task context",
|
|
||||||
WorkerPromptTarget::Unknown => "prompt delivery failure detected",
|
WorkerPromptTarget::Unknown => "prompt delivery failure detected",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -946,7 +865,7 @@ mod tests {
|
|||||||
WorkerFailureKind::TrustGate
|
WorkerFailureKind::TrustGate
|
||||||
);
|
);
|
||||||
|
|
||||||
let send_before_resolve = registry.send_prompt(&worker.worker_id, Some("ship it"), None);
|
let send_before_resolve = registry.send_prompt(&worker.worker_id, Some("ship it"));
|
||||||
assert!(send_before_resolve
|
assert!(send_before_resolve
|
||||||
.expect_err("prompt delivery should be gated")
|
.expect_err("prompt delivery should be gated")
|
||||||
.contains("not ready for prompt delivery"));
|
.contains("not ready for prompt delivery"));
|
||||||
@@ -986,7 +905,7 @@ mod tests {
|
|||||||
.expect("ready observe should succeed");
|
.expect("ready observe should succeed");
|
||||||
|
|
||||||
let running = registry
|
let running = registry
|
||||||
.send_prompt(&worker.worker_id, Some("Implement worker handshake"), None)
|
.send_prompt(&worker.worker_id, Some("Implement worker handshake"))
|
||||||
.expect("prompt send should succeed");
|
.expect("prompt send should succeed");
|
||||||
assert_eq!(running.status, WorkerStatus::Running);
|
assert_eq!(running.status, WorkerStatus::Running);
|
||||||
assert_eq!(running.prompt_delivery_attempts, 1);
|
assert_eq!(running.prompt_delivery_attempts, 1);
|
||||||
@@ -1022,8 +941,6 @@ mod tests {
|
|||||||
prompt_preview: "Implement worker handshake".to_string(),
|
prompt_preview: "Implement worker handshake".to_string(),
|
||||||
observed_target: WorkerPromptTarget::Shell,
|
observed_target: WorkerPromptTarget::Shell,
|
||||||
observed_cwd: None,
|
observed_cwd: None,
|
||||||
observed_prompt_preview: None,
|
|
||||||
task_receipt: None,
|
|
||||||
recovery_armed: false,
|
recovery_armed: false,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -1039,14 +956,12 @@ mod tests {
|
|||||||
prompt_preview: "Implement worker handshake".to_string(),
|
prompt_preview: "Implement worker handshake".to_string(),
|
||||||
observed_target: WorkerPromptTarget::Shell,
|
observed_target: WorkerPromptTarget::Shell,
|
||||||
observed_cwd: None,
|
observed_cwd: None,
|
||||||
observed_prompt_preview: None,
|
|
||||||
task_receipt: None,
|
|
||||||
recovery_armed: true,
|
recovery_armed: true,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
let replayed = registry
|
let replayed = registry
|
||||||
.send_prompt(&worker.worker_id, None, None)
|
.send_prompt(&worker.worker_id, None)
|
||||||
.expect("replay send should succeed");
|
.expect("replay send should succeed");
|
||||||
assert_eq!(replayed.status, WorkerStatus::Running);
|
assert_eq!(replayed.status, WorkerStatus::Running);
|
||||||
assert!(replayed.replay_prompt.is_none());
|
assert!(replayed.replay_prompt.is_none());
|
||||||
@@ -1061,11 +976,7 @@ mod tests {
|
|||||||
.observe(&worker.worker_id, "Ready for input\n>")
|
.observe(&worker.worker_id, "Ready for input\n>")
|
||||||
.expect("ready observe should succeed");
|
.expect("ready observe should succeed");
|
||||||
registry
|
registry
|
||||||
.send_prompt(
|
.send_prompt(&worker.worker_id, Some("Run the worker bootstrap tests"))
|
||||||
&worker.worker_id,
|
|
||||||
Some("Run the worker bootstrap tests"),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.expect("prompt send should succeed");
|
.expect("prompt send should succeed");
|
||||||
|
|
||||||
let recovered = registry
|
let recovered = registry
|
||||||
@@ -1096,8 +1007,6 @@ mod tests {
|
|||||||
prompt_preview: "Run the worker bootstrap tests".to_string(),
|
prompt_preview: "Run the worker bootstrap tests".to_string(),
|
||||||
observed_target: WorkerPromptTarget::WrongTarget,
|
observed_target: WorkerPromptTarget::WrongTarget,
|
||||||
observed_cwd: Some("/tmp/repo-target-b".to_string()),
|
observed_cwd: Some("/tmp/repo-target-b".to_string()),
|
||||||
observed_prompt_preview: None,
|
|
||||||
task_receipt: None,
|
|
||||||
recovery_armed: false,
|
recovery_armed: false,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -1140,75 +1049,6 @@ mod tests {
|
|||||||
assert!(ready.last_error.is_none());
|
assert!(ready.last_error.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn wrong_task_receipt_mismatch_is_detected_before_execution_continues() {
|
|
||||||
let registry = WorkerRegistry::new();
|
|
||||||
let worker = registry.create("/tmp/repo-task", &[], true);
|
|
||||||
registry
|
|
||||||
.observe(&worker.worker_id, "Ready for input\n>")
|
|
||||||
.expect("ready observe should succeed");
|
|
||||||
registry
|
|
||||||
.send_prompt(
|
|
||||||
&worker.worker_id,
|
|
||||||
Some("Implement worker handshake"),
|
|
||||||
Some(WorkerTaskReceipt {
|
|
||||||
repo: "claw-code".to_string(),
|
|
||||||
task_kind: "repo_code".to_string(),
|
|
||||||
source_surface: "omx_team".to_string(),
|
|
||||||
expected_artifacts: vec!["patch".to_string(), "tests".to_string()],
|
|
||||||
objective_preview: "Implement worker handshake".to_string(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.expect("prompt send should succeed");
|
|
||||||
|
|
||||||
let recovered = registry
|
|
||||||
.observe(
|
|
||||||
&worker.worker_id,
|
|
||||||
"› Explain this KakaoTalk screenshot for a friend\nI can help analyze the screenshot…",
|
|
||||||
)
|
|
||||||
.expect("mismatch observe should succeed");
|
|
||||||
|
|
||||||
assert_eq!(recovered.status, WorkerStatus::ReadyForPrompt);
|
|
||||||
assert_eq!(
|
|
||||||
recovered
|
|
||||||
.last_error
|
|
||||||
.expect("mismatch error should exist")
|
|
||||||
.kind,
|
|
||||||
WorkerFailureKind::PromptDelivery
|
|
||||||
);
|
|
||||||
let mismatch = recovered
|
|
||||||
.events
|
|
||||||
.iter()
|
|
||||||
.find(|event| event.kind == WorkerEventKind::PromptMisdelivery)
|
|
||||||
.expect("wrong-task event should exist");
|
|
||||||
assert_eq!(mismatch.status, WorkerStatus::Failed);
|
|
||||||
assert_eq!(
|
|
||||||
mismatch.payload,
|
|
||||||
Some(WorkerEventPayload::PromptDelivery {
|
|
||||||
prompt_preview: "Implement worker handshake".to_string(),
|
|
||||||
observed_target: WorkerPromptTarget::WrongTask,
|
|
||||||
observed_cwd: None,
|
|
||||||
observed_prompt_preview: Some(
|
|
||||||
"Explain this KakaoTalk screenshot for a friend".to_string()
|
|
||||||
),
|
|
||||||
task_receipt: Some(WorkerTaskReceipt {
|
|
||||||
repo: "claw-code".to_string(),
|
|
||||||
task_kind: "repo_code".to_string(),
|
|
||||||
source_surface: "omx_team".to_string(),
|
|
||||||
expected_artifacts: vec!["patch".to_string(), "tests".to_string()],
|
|
||||||
objective_preview: "Implement worker handshake".to_string(),
|
|
||||||
}),
|
|
||||||
recovery_armed: false,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
let replay = recovered
|
|
||||||
.events
|
|
||||||
.iter()
|
|
||||||
.find(|event| event.kind == WorkerEventKind::PromptReplayArmed)
|
|
||||||
.expect("replay event should exist");
|
|
||||||
assert_eq!(replay.status, WorkerStatus::ReadyForPrompt);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn restart_and_terminate_reset_or_finish_worker() {
|
fn restart_and_terminate_reset_or_finish_worker() {
|
||||||
let registry = WorkerRegistry::new();
|
let registry = WorkerRegistry::new();
|
||||||
@@ -1217,7 +1057,7 @@ mod tests {
|
|||||||
.observe(&worker.worker_id, "Ready for input\n>")
|
.observe(&worker.worker_id, "Ready for input\n>")
|
||||||
.expect("ready observe should succeed");
|
.expect("ready observe should succeed");
|
||||||
registry
|
registry
|
||||||
.send_prompt(&worker.worker_id, Some("Run tests"), None)
|
.send_prompt(&worker.worker_id, Some("Run tests"))
|
||||||
.expect("prompt send should succeed");
|
.expect("prompt send should succeed");
|
||||||
|
|
||||||
let restarted = registry
|
let restarted = registry
|
||||||
@@ -1246,7 +1086,7 @@ mod tests {
|
|||||||
.observe(&worker.worker_id, "Ready for input\n>")
|
.observe(&worker.worker_id, "Ready for input\n>")
|
||||||
.expect("ready observe should succeed");
|
.expect("ready observe should succeed");
|
||||||
registry
|
registry
|
||||||
.send_prompt(&worker.worker_id, Some("Run tests"), None)
|
.send_prompt(&worker.worker_id, Some("Run tests"))
|
||||||
.expect("prompt send should succeed");
|
.expect("prompt send should succeed");
|
||||||
|
|
||||||
let failed = registry
|
let failed = registry
|
||||||
@@ -1323,7 +1163,7 @@ mod tests {
|
|||||||
.observe(&worker.worker_id, "Ready for input\n>")
|
.observe(&worker.worker_id, "Ready for input\n>")
|
||||||
.expect("ready observe should succeed");
|
.expect("ready observe should succeed");
|
||||||
registry
|
registry
|
||||||
.send_prompt(&worker.worker_id, Some("Run tests"), None)
|
.send_prompt(&worker.worker_id, Some("Run tests"))
|
||||||
.expect("prompt send should succeed");
|
.expect("prompt send should succeed");
|
||||||
|
|
||||||
let finished = registry
|
let finished = registry
|
||||||
|
|||||||
@@ -304,7 +304,7 @@ fn worker_provider_failure_flows_through_recovery_to_policy() {
|
|||||||
.observe(&worker.worker_id, "Ready for your input\n>")
|
.observe(&worker.worker_id, "Ready for your input\n>")
|
||||||
.expect("ready observe should succeed");
|
.expect("ready observe should succeed");
|
||||||
registry
|
registry
|
||||||
.send_prompt(&worker.worker_id, Some("Run analysis"), None)
|
.send_prompt(&worker.worker_id, Some("Run analysis"))
|
||||||
.expect("prompt send should succeed");
|
.expect("prompt send should succeed");
|
||||||
|
|
||||||
// Session completes with provider failure (finish="unknown", tokens=0)
|
// Session completes with provider failure (finish="unknown", tokens=0)
|
||||||
|
|||||||
@@ -14,13 +14,14 @@ fn main() {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.map_or_else(|| "unknown".to_string(), |s| s.trim().to_string());
|
.map(|s| s.trim().to_string())
|
||||||
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
|
||||||
println!("cargo:rustc-env=GIT_SHA={git_sha}");
|
println!("cargo:rustc-env=GIT_SHA={}", git_sha);
|
||||||
|
|
||||||
// TARGET is always set by Cargo during build
|
// TARGET is always set by Cargo during build
|
||||||
let target = env::var("TARGET").unwrap_or_else(|_| "unknown".to_string());
|
let target = env::var("TARGET").unwrap_or_else(|_| "unknown".to_string());
|
||||||
println!("cargo:rustc-env=TARGET={target}");
|
println!("cargo:rustc-env=TARGET={}", target);
|
||||||
|
|
||||||
// Build date from SOURCE_DATE_EPOCH (reproducible builds) or current UTC date.
|
// Build date from SOURCE_DATE_EPOCH (reproducible builds) or current UTC date.
|
||||||
// Intentionally ignoring time component to keep output deterministic within a day.
|
// Intentionally ignoring time component to keep output deterministic within a day.
|
||||||
@@ -47,7 +48,8 @@ fn main() {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.map_or_else(|| "unknown".to_string(), |s| s.trim().to_string())
|
.map(|s| s.trim().to_string())
|
||||||
|
.unwrap_or_else(|| "unknown".to_string())
|
||||||
});
|
});
|
||||||
println!("cargo:rustc-env=BUILD_DATE={build_date}");
|
println!("cargo:rustc-env=BUILD_DATE={build_date}");
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const STARTER_CLAW_JSON: &str = concat!(
|
|||||||
"}\n",
|
"}\n",
|
||||||
);
|
);
|
||||||
const GITIGNORE_COMMENT: &str = "# Claw Code local artifacts";
|
const GITIGNORE_COMMENT: &str = "# Claw Code local artifacts";
|
||||||
const GITIGNORE_ENTRIES: [&str; 3] = [".claw/settings.local.json", ".claw/sessions/", ".clawhip/"];
|
const GITIGNORE_ENTRIES: [&str; 2] = [".claw/settings.local.json", ".claw/sessions/"];
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub(crate) enum InitStatus {
|
pub(crate) enum InitStatus {
|
||||||
@@ -375,7 +375,6 @@ mod tests {
|
|||||||
let gitignore = fs::read_to_string(root.join(".gitignore")).expect("read gitignore");
|
let gitignore = fs::read_to_string(root.join(".gitignore")).expect("read gitignore");
|
||||||
assert!(gitignore.contains(".claw/settings.local.json"));
|
assert!(gitignore.contains(".claw/settings.local.json"));
|
||||||
assert!(gitignore.contains(".claw/sessions/"));
|
assert!(gitignore.contains(".claw/sessions/"));
|
||||||
assert!(gitignore.contains(".clawhip/"));
|
|
||||||
let claude_md = fs::read_to_string(root.join("CLAUDE.md")).expect("read claude md");
|
let claude_md = fs::read_to_string(root.join("CLAUDE.md")).expect("read claude md");
|
||||||
assert!(claude_md.contains("Languages: Rust."));
|
assert!(claude_md.contains("Languages: Rust."));
|
||||||
assert!(claude_md.contains("cargo clippy --workspace --all-targets -- -D warnings"));
|
assert!(claude_md.contains("cargo clippy --workspace --all-targets -- -D warnings"));
|
||||||
@@ -408,7 +407,6 @@ mod tests {
|
|||||||
let gitignore = fs::read_to_string(root.join(".gitignore")).expect("read gitignore");
|
let gitignore = fs::read_to_string(root.join(".gitignore")).expect("read gitignore");
|
||||||
assert_eq!(gitignore.matches(".claw/settings.local.json").count(), 1);
|
assert_eq!(gitignore.matches(".claw/settings.local.json").count(), 1);
|
||||||
assert_eq!(gitignore.matches(".claw/sessions/").count(), 1);
|
assert_eq!(gitignore.matches(".claw/sessions/").count(), 1);
|
||||||
assert_eq!(gitignore.matches(".clawhip/").count(), 1);
|
|
||||||
|
|
||||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -639,16 +639,10 @@ fn apply_code_block_background(line: &str) -> String {
|
|||||||
/// fence markers of equal or greater length are wrapped with a longer fence.
|
/// fence markers of equal or greater length are wrapped with a longer fence.
|
||||||
///
|
///
|
||||||
/// LLMs frequently emit triple-backtick code blocks that contain triple-backtick
|
/// LLMs frequently emit triple-backtick code blocks that contain triple-backtick
|
||||||
/// examples. `CommonMark` (and pulldown-cmark) treats the inner marker as the
|
/// examples. CommonMark (and pulldown-cmark) treats the inner marker as the
|
||||||
/// closing fence, breaking the render. This function detects the situation and
|
/// closing fence, breaking the render. This function detects the situation and
|
||||||
/// upgrades the outer fence to use enough backticks (or tildes) that the inner
|
/// upgrades the outer fence to use enough backticks (or tildes) that the inner
|
||||||
/// markers become ordinary content.
|
/// markers become ordinary content.
|
||||||
#[allow(
|
|
||||||
clippy::too_many_lines,
|
|
||||||
clippy::items_after_statements,
|
|
||||||
clippy::manual_repeat_n,
|
|
||||||
clippy::manual_str_repeat
|
|
||||||
)]
|
|
||||||
fn normalize_nested_fences(markdown: &str) -> String {
|
fn normalize_nested_fences(markdown: &str) -> String {
|
||||||
// A fence line is either "labeled" (has an info string ⇒ always an opener)
|
// A fence line is either "labeled" (has an info string ⇒ always an opener)
|
||||||
// or "bare" (no info string ⇒ could be opener or closer).
|
// or "bare" (no info string ⇒ could be opener or closer).
|
||||||
|
|||||||
@@ -266,7 +266,7 @@ fn command_in(cwd: &Path) -> Command {
|
|||||||
|
|
||||||
fn write_session(root: &Path, label: &str) -> PathBuf {
|
fn write_session(root: &Path, label: &str) -> PathBuf {
|
||||||
let session_path = root.join(format!("{label}.jsonl"));
|
let session_path = root.join(format!("{label}.jsonl"));
|
||||||
let mut session = Session::new().with_workspace_root(root.to_path_buf());
|
let mut session = Session::new();
|
||||||
session
|
session
|
||||||
.push_user_text(format!("session fixture for {label}"))
|
.push_user_text(format!("session fixture for {label}"))
|
||||||
.expect("session write should succeed");
|
.expect("session write should succeed");
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ use std::process::{Command, Output};
|
|||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use runtime::Session;
|
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
|
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||||
@@ -46,24 +45,6 @@ fn status_and_sandbox_emit_json_when_requested() {
|
|||||||
assert!(sandbox["filesystem_mode"].as_str().is_some());
|
assert!(sandbox["filesystem_mode"].as_str().is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn acp_guidance_emits_json_when_requested() {
|
|
||||||
let root = unique_temp_dir("acp-json");
|
|
||||||
fs::create_dir_all(&root).expect("temp dir should exist");
|
|
||||||
|
|
||||||
let acp = assert_json_command(&root, &["--output-format", "json", "acp"]);
|
|
||||||
assert_eq!(acp["kind"], "acp");
|
|
||||||
assert_eq!(acp["status"], "discoverability_only");
|
|
||||||
assert_eq!(acp["supported"], false);
|
|
||||||
assert_eq!(acp["serve_alias_only"], true);
|
|
||||||
assert_eq!(acp["discoverability_tracking"], "ROADMAP #64a");
|
|
||||||
assert_eq!(acp["tracking"], "ROADMAP #76");
|
|
||||||
assert!(acp["message"]
|
|
||||||
.as_str()
|
|
||||||
.expect("acp message")
|
|
||||||
.contains("discoverability alias"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn inventory_commands_emit_structured_json_when_requested() {
|
fn inventory_commands_emit_structured_json_when_requested() {
|
||||||
let root = unique_temp_dir("inventory-json");
|
let root = unique_temp_dir("inventory-json");
|
||||||
@@ -192,15 +173,13 @@ fn dump_manifests_and_init_emit_json_when_requested() {
|
|||||||
fs::create_dir_all(&root).expect("temp dir should exist");
|
fs::create_dir_all(&root).expect("temp dir should exist");
|
||||||
|
|
||||||
let upstream = write_upstream_fixture(&root);
|
let upstream = write_upstream_fixture(&root);
|
||||||
let manifests = assert_json_command(
|
let manifests = assert_json_command_with_env(
|
||||||
&root,
|
&root,
|
||||||
&[
|
&["--output-format", "json", "dump-manifests"],
|
||||||
"--output-format",
|
&[(
|
||||||
"json",
|
"CLAUDE_CODE_UPSTREAM",
|
||||||
"dump-manifests",
|
|
||||||
"--manifests-dir",
|
|
||||||
upstream.to_str().expect("utf8 upstream"),
|
upstream.to_str().expect("utf8 upstream"),
|
||||||
],
|
)],
|
||||||
);
|
);
|
||||||
assert_eq!(manifests["kind"], "dump-manifests");
|
assert_eq!(manifests["kind"], "dump-manifests");
|
||||||
assert_eq!(manifests["commands"], 1);
|
assert_eq!(manifests["commands"], 1);
|
||||||
@@ -227,7 +206,7 @@ fn doctor_and_resume_status_emit_json_when_requested() {
|
|||||||
assert!(summary["failures"].as_u64().is_some());
|
assert!(summary["failures"].as_u64().is_some());
|
||||||
|
|
||||||
let checks = doctor["checks"].as_array().expect("doctor checks");
|
let checks = doctor["checks"].as_array().expect("doctor checks");
|
||||||
assert_eq!(checks.len(), 6);
|
assert_eq!(checks.len(), 5);
|
||||||
let check_names = checks
|
let check_names = checks
|
||||||
.iter()
|
.iter()
|
||||||
.map(|check| {
|
.map(|check| {
|
||||||
@@ -239,27 +218,7 @@ fn doctor_and_resume_status_emit_json_when_requested() {
|
|||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
check_names,
|
check_names,
|
||||||
vec![
|
vec!["auth", "config", "workspace", "sandbox", "system"]
|
||||||
"auth",
|
|
||||||
"config",
|
|
||||||
"install source",
|
|
||||||
"workspace",
|
|
||||||
"sandbox",
|
|
||||||
"system"
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
let install_source = checks
|
|
||||||
.iter()
|
|
||||||
.find(|check| check["name"] == "install source")
|
|
||||||
.expect("install source check");
|
|
||||||
assert_eq!(
|
|
||||||
install_source["official_repo"],
|
|
||||||
"https://github.com/ultraworkers/claw-code"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
install_source["deprecated_install"],
|
|
||||||
"cargo install claw-code"
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let workspace = checks
|
let workspace = checks
|
||||||
@@ -277,7 +236,12 @@ fn doctor_and_resume_status_emit_json_when_requested() {
|
|||||||
assert!(sandbox["enabled"].is_boolean());
|
assert!(sandbox["enabled"].is_boolean());
|
||||||
assert!(sandbox["fallback_reason"].is_null() || sandbox["fallback_reason"].is_string());
|
assert!(sandbox["fallback_reason"].is_null() || sandbox["fallback_reason"].is_string());
|
||||||
|
|
||||||
let session_path = write_session_fixture(&root, "resume-json", Some("hello"));
|
let session_path = root.join("session.jsonl");
|
||||||
|
fs::write(
|
||||||
|
&session_path,
|
||||||
|
"{\"type\":\"session_meta\",\"version\":3,\"session_id\":\"resume-json\",\"created_at_ms\":0,\"updated_at_ms\":0}\n{\"type\":\"message\",\"message\":{\"role\":\"user\",\"blocks\":[{\"type\":\"text\",\"text\":\"hello\"}]}}\n",
|
||||||
|
)
|
||||||
|
.expect("session should write");
|
||||||
let resumed = assert_json_command(
|
let resumed = assert_json_command(
|
||||||
&root,
|
&root,
|
||||||
&[
|
&[
|
||||||
@@ -289,8 +253,7 @@ fn doctor_and_resume_status_emit_json_when_requested() {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
assert_eq!(resumed["kind"], "status");
|
assert_eq!(resumed["kind"], "status");
|
||||||
// model is null in resume mode (not known without --model flag)
|
assert_eq!(resumed["model"], "restored-session");
|
||||||
assert!(resumed["model"].is_null());
|
|
||||||
assert_eq!(resumed["usage"]["messages"], 1);
|
assert_eq!(resumed["usage"]["messages"], 1);
|
||||||
assert!(resumed["workspace"]["cwd"].as_str().is_some());
|
assert!(resumed["workspace"]["cwd"].as_str().is_some());
|
||||||
assert!(resumed["sandbox"]["filesystem_mode"].as_str().is_some());
|
assert!(resumed["sandbox"]["filesystem_mode"].as_str().is_some());
|
||||||
@@ -304,7 +267,12 @@ fn resumed_inventory_commands_emit_structured_json_when_requested() {
|
|||||||
fs::create_dir_all(&config_home).expect("config home should exist");
|
fs::create_dir_all(&config_home).expect("config home should exist");
|
||||||
fs::create_dir_all(&home).expect("home should exist");
|
fs::create_dir_all(&home).expect("home should exist");
|
||||||
|
|
||||||
let session_path = write_session_fixture(&root, "resume-inventory-json", Some("inventory"));
|
let session_path = root.join("session.jsonl");
|
||||||
|
fs::write(
|
||||||
|
&session_path,
|
||||||
|
"{\"type\":\"session_meta\",\"version\":3,\"session_id\":\"resume-inventory-json\",\"created_at_ms\":0,\"updated_at_ms\":0}\n{\"type\":\"message\",\"message\":{\"role\":\"user\",\"blocks\":[{\"type\":\"text\",\"text\":\"inventory\"}]}}\n",
|
||||||
|
)
|
||||||
|
.expect("session should write");
|
||||||
|
|
||||||
let mcp = assert_json_command_with_env(
|
let mcp = assert_json_command_with_env(
|
||||||
&root,
|
&root,
|
||||||
@@ -355,7 +323,12 @@ fn resumed_version_and_init_emit_structured_json_when_requested() {
|
|||||||
let root = unique_temp_dir("resume-version-init-json");
|
let root = unique_temp_dir("resume-version-init-json");
|
||||||
fs::create_dir_all(&root).expect("temp dir should exist");
|
fs::create_dir_all(&root).expect("temp dir should exist");
|
||||||
|
|
||||||
let session_path = write_session_fixture(&root, "resume-version-init-json", None);
|
let session_path = root.join("session.jsonl");
|
||||||
|
fs::write(
|
||||||
|
&session_path,
|
||||||
|
"{\"type\":\"session_meta\",\"version\":3,\"session_id\":\"resume-version-init-json\",\"created_at_ms\":0,\"updated_at_ms\":0}\n",
|
||||||
|
)
|
||||||
|
.expect("session should write");
|
||||||
|
|
||||||
let version = assert_json_command(
|
let version = assert_json_command(
|
||||||
&root,
|
&root,
|
||||||
@@ -431,24 +404,6 @@ fn write_upstream_fixture(root: &Path) -> PathBuf {
|
|||||||
upstream
|
upstream
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write_session_fixture(root: &Path, session_id: &str, user_text: Option<&str>) -> PathBuf {
|
|
||||||
let session_path = root.join("session.jsonl");
|
|
||||||
let mut session = Session::new()
|
|
||||||
.with_workspace_root(root.to_path_buf())
|
|
||||||
.with_persistence_path(session_path.clone());
|
|
||||||
session.session_id = session_id.to_string();
|
|
||||||
if let Some(text) = user_text {
|
|
||||||
session
|
|
||||||
.push_user_text(text)
|
|
||||||
.expect("session fixture message should persist");
|
|
||||||
} else {
|
|
||||||
session
|
|
||||||
.save_to_path(&session_path)
|
|
||||||
.expect("session fixture should persist");
|
|
||||||
}
|
|
||||||
session_path
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_agent(root: &Path, name: &str, description: &str, model: &str, reasoning: &str) {
|
fn write_agent(root: &Path, name: &str, description: &str, model: &str, reasoning: &str) {
|
||||||
fs::create_dir_all(root).expect("agent root should exist");
|
fs::create_dir_all(root).expect("agent root should exist");
|
||||||
fs::write(
|
fs::write(
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ fn resumed_binary_accepts_slash_commands_with_arguments() {
|
|||||||
let session_path = temp_dir.join("session.jsonl");
|
let session_path = temp_dir.join("session.jsonl");
|
||||||
let export_path = temp_dir.join("notes.txt");
|
let export_path = temp_dir.join("notes.txt");
|
||||||
|
|
||||||
let mut session = workspace_session(&temp_dir);
|
let mut session = Session::new();
|
||||||
session
|
session
|
||||||
.push_user_text("ship the slash command harness")
|
.push_user_text("ship the slash command harness")
|
||||||
.expect("session write should succeed");
|
.expect("session write should succeed");
|
||||||
@@ -122,7 +122,7 @@ fn resumed_config_command_loads_settings_files_end_to_end() {
|
|||||||
fs::create_dir_all(&config_home).expect("config home should exist");
|
fs::create_dir_all(&config_home).expect("config home should exist");
|
||||||
|
|
||||||
let session_path = project_dir.join("session.jsonl");
|
let session_path = project_dir.join("session.jsonl");
|
||||||
workspace_session(&project_dir)
|
Session::new()
|
||||||
.with_persistence_path(&session_path)
|
.with_persistence_path(&session_path)
|
||||||
.save_to_path(&session_path)
|
.save_to_path(&session_path)
|
||||||
.expect("session should persist");
|
.expect("session should persist");
|
||||||
@@ -180,11 +180,13 @@ fn resume_latest_restores_the_most_recent_managed_session() {
|
|||||||
// given
|
// given
|
||||||
let temp_dir = unique_temp_dir("resume-latest");
|
let temp_dir = unique_temp_dir("resume-latest");
|
||||||
let project_dir = temp_dir.join("project");
|
let project_dir = temp_dir.join("project");
|
||||||
let store = runtime::SessionStore::from_cwd(&project_dir).expect("session store should build");
|
let sessions_dir = project_dir.join(".claw").join("sessions");
|
||||||
let older_path = store.create_handle("session-older").path;
|
fs::create_dir_all(&sessions_dir).expect("sessions dir should exist");
|
||||||
let newer_path = store.create_handle("session-newer").path;
|
|
||||||
|
|
||||||
let mut older = workspace_session(&project_dir).with_persistence_path(&older_path);
|
let older_path = sessions_dir.join("session-older.jsonl");
|
||||||
|
let newer_path = sessions_dir.join("session-newer.jsonl");
|
||||||
|
|
||||||
|
let mut older = Session::new().with_persistence_path(&older_path);
|
||||||
older
|
older
|
||||||
.push_user_text("older session")
|
.push_user_text("older session")
|
||||||
.expect("older session write should succeed");
|
.expect("older session write should succeed");
|
||||||
@@ -192,7 +194,7 @@ fn resume_latest_restores_the_most_recent_managed_session() {
|
|||||||
.save_to_path(&older_path)
|
.save_to_path(&older_path)
|
||||||
.expect("older session should persist");
|
.expect("older session should persist");
|
||||||
|
|
||||||
let mut newer = workspace_session(&project_dir).with_persistence_path(&newer_path);
|
let mut newer = Session::new().with_persistence_path(&newer_path);
|
||||||
newer
|
newer
|
||||||
.push_user_text("newer session")
|
.push_user_text("newer session")
|
||||||
.expect("newer session write should succeed");
|
.expect("newer session write should succeed");
|
||||||
@@ -227,7 +229,7 @@ fn resumed_status_command_emits_structured_json_when_requested() {
|
|||||||
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
|
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
|
||||||
let session_path = temp_dir.join("session.jsonl");
|
let session_path = temp_dir.join("session.jsonl");
|
||||||
|
|
||||||
let mut session = workspace_session(&temp_dir);
|
let mut session = Session::new();
|
||||||
session
|
session
|
||||||
.push_user_text("resume status json fixture")
|
.push_user_text("resume status json fixture")
|
||||||
.expect("session write should succeed");
|
.expect("session write should succeed");
|
||||||
@@ -259,8 +261,7 @@ fn resumed_status_command_emits_structured_json_when_requested() {
|
|||||||
let parsed: Value =
|
let parsed: Value =
|
||||||
serde_json::from_str(stdout.trim()).expect("resume status output should be json");
|
serde_json::from_str(stdout.trim()).expect("resume status output should be json");
|
||||||
assert_eq!(parsed["kind"], "status");
|
assert_eq!(parsed["kind"], "status");
|
||||||
// model is null in resume mode (not known without --model flag)
|
assert_eq!(parsed["model"], "restored-session");
|
||||||
assert!(parsed["model"].is_null());
|
|
||||||
assert_eq!(parsed["permission_mode"], "danger-full-access");
|
assert_eq!(parsed["permission_mode"], "danger-full-access");
|
||||||
assert_eq!(parsed["usage"]["messages"], 1);
|
assert_eq!(parsed["usage"]["messages"], 1);
|
||||||
assert!(parsed["usage"]["turns"].is_number());
|
assert!(parsed["usage"]["turns"].is_number());
|
||||||
@@ -274,47 +275,6 @@ fn resumed_status_command_emits_structured_json_when_requested() {
|
|||||||
assert!(parsed["sandbox"]["filesystem_mode"].as_str().is_some());
|
assert!(parsed["sandbox"]["filesystem_mode"].as_str().is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resumed_status_surfaces_persisted_model() {
|
|
||||||
// given — create a session with model already set
|
|
||||||
let temp_dir = unique_temp_dir("resume-status-model");
|
|
||||||
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
|
|
||||||
let session_path = temp_dir.join("session.jsonl");
|
|
||||||
|
|
||||||
let mut session = workspace_session(&temp_dir);
|
|
||||||
session.model = Some("claude-sonnet-4-6".to_string());
|
|
||||||
session
|
|
||||||
.push_user_text("model persistence fixture")
|
|
||||||
.expect("write ok");
|
|
||||||
session.save_to_path(&session_path).expect("persist ok");
|
|
||||||
|
|
||||||
// when
|
|
||||||
let output = run_claw(
|
|
||||||
&temp_dir,
|
|
||||||
&[
|
|
||||||
"--output-format",
|
|
||||||
"json",
|
|
||||||
"--resume",
|
|
||||||
session_path.to_str().expect("utf8 path"),
|
|
||||||
"/status",
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert!(
|
|
||||||
output.status.success(),
|
|
||||||
"stderr:\n{}",
|
|
||||||
String::from_utf8_lossy(&output.stderr)
|
|
||||||
);
|
|
||||||
let stdout = String::from_utf8(output.stdout).expect("utf8");
|
|
||||||
let parsed: Value = serde_json::from_str(stdout.trim()).expect("should be json");
|
|
||||||
assert_eq!(parsed["kind"], "status");
|
|
||||||
assert_eq!(
|
|
||||||
parsed["model"], "claude-sonnet-4-6",
|
|
||||||
"model should round-trip through session metadata"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resumed_sandbox_command_emits_structured_json_when_requested() {
|
fn resumed_sandbox_command_emits_structured_json_when_requested() {
|
||||||
// given
|
// given
|
||||||
@@ -322,7 +282,7 @@ fn resumed_sandbox_command_emits_structured_json_when_requested() {
|
|||||||
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
|
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
|
||||||
let session_path = temp_dir.join("session.jsonl");
|
let session_path = temp_dir.join("session.jsonl");
|
||||||
|
|
||||||
workspace_session(&temp_dir)
|
Session::new()
|
||||||
.save_to_path(&session_path)
|
.save_to_path(&session_path)
|
||||||
.expect("session should persist");
|
.expect("session should persist");
|
||||||
|
|
||||||
@@ -358,183 +318,10 @@ fn resumed_sandbox_command_emits_structured_json_when_requested() {
|
|||||||
assert!(parsed["markers"].is_array());
|
assert!(parsed["markers"].is_array());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resumed_version_command_emits_structured_json() {
|
|
||||||
let temp_dir = unique_temp_dir("resume-version-json");
|
|
||||||
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
|
|
||||||
let session_path = temp_dir.join("session.jsonl");
|
|
||||||
workspace_session(&temp_dir)
|
|
||||||
.save_to_path(&session_path)
|
|
||||||
.expect("session should persist");
|
|
||||||
|
|
||||||
let output = run_claw(
|
|
||||||
&temp_dir,
|
|
||||||
&[
|
|
||||||
"--output-format",
|
|
||||||
"json",
|
|
||||||
"--resume",
|
|
||||||
session_path.to_str().expect("utf8 path"),
|
|
||||||
"/version",
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
output.status.success(),
|
|
||||||
"stderr:\n{}",
|
|
||||||
String::from_utf8_lossy(&output.stderr)
|
|
||||||
);
|
|
||||||
let stdout = String::from_utf8(output.stdout).expect("utf8");
|
|
||||||
let parsed: Value = serde_json::from_str(stdout.trim()).expect("should be json");
|
|
||||||
assert_eq!(parsed["kind"], "version");
|
|
||||||
assert!(parsed["version"].as_str().is_some());
|
|
||||||
assert!(parsed["git_sha"].as_str().is_some());
|
|
||||||
assert!(parsed["target"].as_str().is_some());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resumed_export_command_emits_structured_json() {
|
|
||||||
let temp_dir = unique_temp_dir("resume-export-json");
|
|
||||||
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
|
|
||||||
let session_path = temp_dir.join("session.jsonl");
|
|
||||||
let mut session = workspace_session(&temp_dir);
|
|
||||||
session
|
|
||||||
.push_user_text("export json fixture")
|
|
||||||
.expect("write ok");
|
|
||||||
session.save_to_path(&session_path).expect("persist ok");
|
|
||||||
|
|
||||||
let output = run_claw(
|
|
||||||
&temp_dir,
|
|
||||||
&[
|
|
||||||
"--output-format",
|
|
||||||
"json",
|
|
||||||
"--resume",
|
|
||||||
session_path.to_str().expect("utf8 path"),
|
|
||||||
"/export",
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
output.status.success(),
|
|
||||||
"stderr:\n{}",
|
|
||||||
String::from_utf8_lossy(&output.stderr)
|
|
||||||
);
|
|
||||||
let stdout = String::from_utf8(output.stdout).expect("utf8");
|
|
||||||
let parsed: Value = serde_json::from_str(stdout.trim()).expect("should be json");
|
|
||||||
assert_eq!(parsed["kind"], "export");
|
|
||||||
assert!(parsed["file"].as_str().is_some());
|
|
||||||
assert_eq!(parsed["message_count"], 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resumed_help_command_emits_structured_json() {
|
|
||||||
let temp_dir = unique_temp_dir("resume-help-json");
|
|
||||||
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
|
|
||||||
let session_path = temp_dir.join("session.jsonl");
|
|
||||||
workspace_session(&temp_dir)
|
|
||||||
.save_to_path(&session_path)
|
|
||||||
.expect("persist ok");
|
|
||||||
|
|
||||||
let output = run_claw(
|
|
||||||
&temp_dir,
|
|
||||||
&[
|
|
||||||
"--output-format",
|
|
||||||
"json",
|
|
||||||
"--resume",
|
|
||||||
session_path.to_str().expect("utf8 path"),
|
|
||||||
"/help",
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
output.status.success(),
|
|
||||||
"stderr:\n{}",
|
|
||||||
String::from_utf8_lossy(&output.stderr)
|
|
||||||
);
|
|
||||||
let stdout = String::from_utf8(output.stdout).expect("utf8");
|
|
||||||
let parsed: Value = serde_json::from_str(stdout.trim()).expect("should be json");
|
|
||||||
assert_eq!(parsed["kind"], "help");
|
|
||||||
assert!(parsed["text"].as_str().is_some());
|
|
||||||
let text = parsed["text"].as_str().unwrap();
|
|
||||||
assert!(text.contains("/status"), "help text should list /status");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resumed_no_command_emits_restored_json() {
|
|
||||||
let temp_dir = unique_temp_dir("resume-no-cmd-json");
|
|
||||||
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
|
|
||||||
let session_path = temp_dir.join("session.jsonl");
|
|
||||||
let mut session = workspace_session(&temp_dir);
|
|
||||||
session
|
|
||||||
.push_user_text("restored json fixture")
|
|
||||||
.expect("write ok");
|
|
||||||
session.save_to_path(&session_path).expect("persist ok");
|
|
||||||
|
|
||||||
let output = run_claw(
|
|
||||||
&temp_dir,
|
|
||||||
&[
|
|
||||||
"--output-format",
|
|
||||||
"json",
|
|
||||||
"--resume",
|
|
||||||
session_path.to_str().expect("utf8 path"),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
output.status.success(),
|
|
||||||
"stderr:\n{}",
|
|
||||||
String::from_utf8_lossy(&output.stderr)
|
|
||||||
);
|
|
||||||
let stdout = String::from_utf8(output.stdout).expect("utf8");
|
|
||||||
let parsed: Value = serde_json::from_str(stdout.trim()).expect("should be json");
|
|
||||||
assert_eq!(parsed["kind"], "restored");
|
|
||||||
assert!(parsed["session_id"].as_str().is_some());
|
|
||||||
assert!(parsed["path"].as_str().is_some());
|
|
||||||
assert_eq!(parsed["message_count"], 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resumed_stub_command_emits_not_implemented_json() {
|
|
||||||
let temp_dir = unique_temp_dir("resume-stub-json");
|
|
||||||
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
|
|
||||||
let session_path = temp_dir.join("session.jsonl");
|
|
||||||
workspace_session(&temp_dir)
|
|
||||||
.save_to_path(&session_path)
|
|
||||||
.expect("persist ok");
|
|
||||||
|
|
||||||
let output = run_claw(
|
|
||||||
&temp_dir,
|
|
||||||
&[
|
|
||||||
"--output-format",
|
|
||||||
"json",
|
|
||||||
"--resume",
|
|
||||||
session_path.to_str().expect("utf8 path"),
|
|
||||||
"/allowed-tools",
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Stub commands exit with code 2
|
|
||||||
assert!(!output.status.success());
|
|
||||||
let stderr = String::from_utf8(output.stderr).expect("utf8");
|
|
||||||
let parsed: Value = serde_json::from_str(stderr.trim()).expect("should be json");
|
|
||||||
assert_eq!(parsed["type"], "error");
|
|
||||||
assert!(
|
|
||||||
parsed["error"]
|
|
||||||
.as_str()
|
|
||||||
.unwrap()
|
|
||||||
.contains("not yet implemented"),
|
|
||||||
"error should say not yet implemented: {:?}",
|
|
||||||
parsed["error"]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_claw(current_dir: &Path, args: &[&str]) -> Output {
|
fn run_claw(current_dir: &Path, args: &[&str]) -> Output {
|
||||||
run_claw_with_env(current_dir, args, &[])
|
run_claw_with_env(current_dir, args, &[])
|
||||||
}
|
}
|
||||||
|
|
||||||
fn workspace_session(root: &Path) -> Session {
|
|
||||||
Session::new().with_workspace_root(root.to_path_buf())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_claw_with_env(current_dir: &Path, args: &[&str], envs: &[(&str, &str)]) -> Output {
|
fn run_claw_with_env(current_dir: &Path, args: &[&str], envs: &[(&str, &str)]) -> Output {
|
||||||
let mut command = Command::new(env!("CARGO_BIN_EXE_claw"));
|
let mut command = Command::new(env!("CARGO_BIN_EXE_claw"));
|
||||||
command.current_dir(current_dir).args(args);
|
command.current_dir(current_dir).args(args);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user