Merge branch 'ultraworkers:main' into main

This commit is contained in:
Dowling_Wong 2026-04-20 10:55:45 +02:00 committed by GitHub
commit 3ab7583b1e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
50 changed files with 11133 additions and 1323 deletions

5
.claw.json Normal file
View File

@ -0,0 +1,5 @@
{
"aliases": {
"quick": "haiku"
}
}

5
.gitignore vendored
View File

@ -5,3 +5,8 @@ archive/
# Claude Code local artifacts
.claude/settings.local.json
.claude/sessions/
# Claw Code local artifacts
.claw/settings.local.json
.claw/sessions/
.clawhip/
status-help.txt

View File

@ -33,6 +33,8 @@ The canonical implementation lives in [`rust/`](./rust), and the current source
> [!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.
>
> **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

4624
ROADMAP.md

File diff suppressed because one or more lines are too long

View File

@ -21,7 +21,7 @@ cargo build --workspace
- Rust toolchain with `cargo`
- One of:
- `ANTHROPIC_API_KEY` for direct API access
- `claw login` for OAuth-based auth
- `ANTHROPIC_AUTH_TOKEN` for bearer-token auth
- Optional: `ANTHROPIC_BASE_URL` when targeting a proxy or local service
## Install / build the workspace
@ -105,8 +105,7 @@ export ANTHROPIC_API_KEY="sk-ant-..."
```bash
cd rust
./target/debug/claw login
./target/debug/claw logout
export ANTHROPIC_AUTH_TOKEN="anthropic-oauth-or-proxy-bearer-token"
```
### Which env var goes where
@ -116,7 +115,7 @@ cd rust
| 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) |
| OAuth access token (opaque) | `ANTHROPIC_AUTH_TOKEN` | `Authorization: Bearer ...` | `claw login` or an Anthropic-compatible proxy that mints Bearer tokens |
| OAuth access token (opaque) | `ANTHROPIC_AUTH_TOKEN` | `Authorization: Bearer ...` | an Anthropic-compatible proxy or OAuth flow 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) |
**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.
@ -125,7 +124,7 @@ cd rust
## Local Models
`claw` can talk to local servers and provider gateways through either Anthropic-compatible or OpenAI-compatible endpoints. Use `ANTHROPIC_BASE_URL` with `ANTHROPIC_AUTH_TOKEN` for Anthropic-compatible services, or `OPENAI_BASE_URL` with `OPENAI_API_KEY` for OpenAI-compatible services. OAuth is Anthropic-only, so when `OPENAI_BASE_URL` is set you should use API-key style auth instead of `claw login`.
`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.
### Anthropic-compatible endpoint
@ -192,7 +191,7 @@ Reasoning variants (`qwen-qwq-*`, `qwq-*`, `*-thinking`) automatically strip `te
| 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` or OAuth (`claw login`) | `ANTHROPIC_BASE_URL` | `https://api.anthropic.com` |
| **Anthropic** (direct) | Anthropic Messages API | `ANTHROPIC_API_KEY` or `ANTHROPIC_AUTH_TOKEN` | `ANTHROPIC_BASE_URL` | `https://api.anthropic.com` |
| **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` |
| **DashScope** (Alibaba) | OpenAI-compatible | `DASHSCOPE_API_KEY` | `DASHSCOPE_BASE_URL` | `https://dashscope.aliyuncs.com/compatible-mode/v1` |

236
docs/MODEL_COMPATIBILITY.md Normal file
View File

@ -0,0 +1,236 @@
# Model Compatibility Guide
This document describes model-specific handling in the OpenAI-compatible provider. When adding new models or providers, review this guide to ensure proper compatibility.
## Table of Contents
- [Overview](#overview)
- [Model-Specific Handling](#model-specific-handling)
- [Kimi Models (is_error Exclusion)](#kimi-models-is_error-exclusion)
- [Reasoning Models (Tuning Parameter Stripping)](#reasoning-models-tuning-parameter-stripping)
- [GPT-5 (max_completion_tokens)](#gpt-5-max_completion_tokens)
- [Qwen Models (DashScope Routing)](#qwen-models-dashscope-routing)
- [Implementation Details](#implementation-details)
- [Adding New Models](#adding-new-models)
- [Testing](#testing)
## Overview
The `openai_compat.rs` provider translates Claude Code's internal message format to OpenAI-compatible chat completion requests. Different models have varying requirements for:
- Tool result message fields (`is_error`)
- Sampling parameters (temperature, top_p, etc.)
- Token limit fields (`max_tokens` vs `max_completion_tokens`)
- Base URL routing
## Model-Specific Handling
### Kimi Models (is_error Exclusion)
**Affected models:** `kimi-k2.5`, `kimi-k1.5`, `kimi-moonshot`, and any model with `kimi` in the name (case-insensitive)
**Behavior:** The `is_error` field is **excluded** from tool result messages.
**Rationale:** Kimi models (via Moonshot AI and DashScope) reject the `is_error` field with a 400 Bad Request error:
```json
{
"error": {
"type": "invalid_request_error",
"message": "Unknown field: is_error"
}
}
```
**Detection:**
```rust
fn model_rejects_is_error_field(model: &str) -> bool {
let lowered = model.to_ascii_lowercase();
let canonical = lowered.rsplit('/').next().unwrap_or(lowered.as_str());
canonical.starts_with("kimi-")
}
```
**Testing:** See `model_rejects_is_error_field_detects_kimi_models` and related tests in `openai_compat.rs`.
---
### Reasoning Models (Tuning Parameter Stripping)
**Affected models:**
- OpenAI: `o1`, `o1-*`, `o3`, `o3-*`, `o4`, `o4-*`
- xAI: `grok-3-mini`
- Alibaba DashScope: `qwen-qwq-*`, `qwq-*`, `qwen3-*-thinking`
**Behavior:** The following tuning parameters are **stripped** from requests:
- `temperature`
- `top_p`
- `frequency_penalty`
- `presence_penalty`
**Rationale:** Reasoning/chain-of-thought models use fixed sampling strategies and reject these parameters with 400 errors.
**Exception:** `reasoning_effort` is included for compatible models when explicitly set.
**Detection:**
```rust
fn is_reasoning_model(model: &str) -> bool {
let canonical = model.to_ascii_lowercase()
.rsplit('/')
.next()
.unwrap_or(model);
canonical.starts_with("o1")
|| canonical.starts_with("o3")
|| canonical.starts_with("o4")
|| canonical == "grok-3-mini"
|| canonical.starts_with("qwen-qwq")
|| canonical.starts_with("qwq")
|| (canonical.starts_with("qwen3") && canonical.contains("-thinking"))
}
```
**Testing:** See `reasoning_model_strips_tuning_params`, `grok_3_mini_is_reasoning_model`, and `qwen_reasoning_variants_are_detected` tests.
---
### GPT-5 (max_completion_tokens)
**Affected models:** All models starting with `gpt-5`
**Behavior:** Uses `max_completion_tokens` instead of `max_tokens` in the request payload.
**Rationale:** GPT-5 models require the `max_completion_tokens` field. Legacy `max_tokens` causes request validation failures:
```json
{
"error": {
"message": "Unknown field: max_tokens"
}
}
```
**Implementation:**
```rust
let max_tokens_key = if wire_model.starts_with("gpt-5") {
"max_completion_tokens"
} else {
"max_tokens"
};
```
**Testing:** See `gpt5_uses_max_completion_tokens_not_max_tokens` and `non_gpt5_uses_max_tokens` tests.
---
### Qwen Models (DashScope Routing)
**Affected models:** All models with `qwen` prefix
**Behavior:** Routed to DashScope (`https://dashscope.aliyuncs.com/compatible-mode/v1`) rather than default providers.
**Rationale:** Qwen models are hosted by Alibaba Cloud's DashScope service, not OpenAI or Anthropic.
**Configuration:**
```rust
pub const DEFAULT_DASHSCOPE_BASE_URL: &str = "https://dashscope.aliyuncs.com/compatible-mode/v1";
```
**Authentication:** Uses `DASHSCOPE_API_KEY` environment variable.
**Note:** Some Qwen models are also reasoning models (see [Reasoning Models](#reasoning-models-tuning-parameter-stripping) above) and receive both treatments.
## Implementation Details
### File Location
All model-specific logic is in:
```
rust/crates/api/src/providers/openai_compat.rs
```
### Key Functions
| Function | Purpose |
|----------|---------|
| `model_rejects_is_error_field()` | Detects models that don't support `is_error` in tool results |
| `is_reasoning_model()` | Detects reasoning models that need tuning param stripping |
| `translate_message()` | Converts internal messages to OpenAI format (applies `is_error` logic) |
| `build_chat_completion_request()` | Constructs full request payload (applies all model-specific logic) |
### Provider Prefix Handling
All model detection functions strip provider prefixes (e.g., `dashscope/kimi-k2.5` → `kimi-k2.5`) before matching:
```rust
let canonical = model.to_ascii_lowercase()
.rsplit('/')
.next()
.unwrap_or(model);
```
This ensures consistent detection regardless of whether models are referenced with or without provider prefixes.
## Adding New Models
When adding support for new models:
1. **Check if the model is a reasoning model**
- Does it reject temperature/top_p parameters?
- Add to `is_reasoning_model()` detection
2. **Check tool result compatibility**
- Does it reject the `is_error` field?
- Add to `model_rejects_is_error_field()` detection
3. **Check token limit field**
- Does it require `max_completion_tokens` instead of `max_tokens`?
- Update the `max_tokens_key` logic
4. **Add tests**
- Unit test for detection function
- Integration test in `build_chat_completion_request`
5. **Update this documentation**
- Add the model to the affected lists
- Document any special behavior
## Testing
### Running Model-Specific Tests
```bash
# All OpenAI compatibility tests
cargo test --package api providers::openai_compat
# Specific test categories
cargo test --package api model_rejects_is_error_field
cargo test --package api reasoning_model
cargo test --package api gpt5
cargo test --package api qwen
```
### Test Files
- Unit tests: `rust/crates/api/src/providers/openai_compat.rs` (in `mod tests`)
- Integration tests: `rust/crates/api/tests/openai_compat_integration.rs`
### Verifying Model Detection
To verify a model is detected correctly without making API calls:
```rust
#[test]
fn my_new_model_is_detected() {
// is_error handling
assert!(model_rejects_is_error_field("my-model"));
// Reasoning model detection
assert!(is_reasoning_model("my-model"));
// Provider prefix handling
assert!(model_rejects_is_error_field("provider/my-model"));
}
```
---
*Last updated: 2026-04-16*
For questions or updates, see the implementation in `rust/crates/api/src/providers/openai_compat.rs`.

356
prd.json Normal file
View File

@ -0,0 +1,356 @@
{
"version": "1.0",
"description": "Clawable Coding Harness - Clear roadmap stories and commit each",
"stories": [
{
"id": "US-001",
"title": "Phase 1.6 - startup-no-evidence evidence bundle + classifier",
"description": "When startup times out, emit typed worker.startup_no_evidence event with evidence bundle including last known worker lifecycle state, pane command, prompt-send timestamp, prompt-acceptance state, trust-prompt detection result, and transport/MCP health summary. Classifier should down-rank into specific failure classes.",
"acceptanceCriteria": [
"worker.startup_no_evidence event emitted on startup timeout with evidence bundle",
"Evidence bundle includes: last lifecycle state, pane command, prompt-send timestamp, prompt-acceptance state, trust-prompt detection, transport/MCP health",
"Classifier attempts to categorize into: trust_required, prompt_misdelivery, prompt_acceptance_timeout, transport_dead, worker_crashed, or unknown",
"Tests verify evidence bundle structure and classifier behavior"
],
"passes": true,
"priority": "P0"
},
{
"id": "US-002",
"title": "Phase 2 - Canonical lane event schema (4.x series)",
"description": "Define typed events for lane lifecycle: lane.started, lane.ready, lane.prompt_misdelivery, lane.blocked, lane.red, lane.green, lane.commit.created, lane.pr.opened, lane.merge.ready, lane.finished, lane.failed, branch.stale_against_main. Also implement event ordering, reconciliation, provenance, deduplication, and projection contracts.",
"acceptanceCriteria": [
"LaneEvent enum with all required variants defined",
"Event ordering with monotonic sequence metadata attached",
"Event provenance labels (live_lane, test, healthcheck, replay, transport)",
"Session identity completeness at creation (title, workspace, purpose)",
"Duplicate terminal-event suppression with fingerprinting",
"Lane ownership/scope binding in events",
"Nudge acknowledgment with dedupe contract",
"clawhip consumes typed lane events instead of pane scraping"
],
"passes": true,
"priority": "P0"
},
{
"id": "US-003",
"title": "Phase 3 - Stale-branch detection before broad verification",
"description": "Before broad test runs, compare current branch to main and detect if known fixes are missing. Emit branch.stale_against_main event and suggest/auto-run rebase/merge-forward.",
"acceptanceCriteria": [
"Branch freshness comparison against main implemented",
"branch.stale_against_main event emitted when behind",
"Auto-rebase/merge-forward policy integration",
"Avoid misclassifying stale-branch failures as new regressions"
],
"passes": true,
"priority": "P1"
},
{
"id": "US-004",
"title": "Phase 3 - Recovery recipes with ledger",
"description": "Encode automatic recoveries for common failures (trust prompt, prompt misdelivery, stale branch, compile red, MCP startup). Expose recovery attempt ledger with recipe id, attempt count, state, timestamps, failure summary.",
"acceptanceCriteria": [
"Recovery recipes defined for: trust_prompt_unresolved, prompt_delivered_to_shell, stale_branch, compile_red_after_refactor, MCP_handshake_failure, partial_plugin_startup",
"Recovery attempt ledger with: recipe id, attempt count, state, timestamps, failure summary, escalation reason",
"One automatic recovery attempt before escalation",
"Ledger emitted as structured event data"
],
"passes": true,
"priority": "P1"
},
{
"id": "US-005",
"title": "Phase 4 - Typed task packet format",
"description": "Define structured task packet with fields: objective, scope, repo/worktree, branch policy, acceptance tests, commit policy, reporting contract, escalation policy.",
"acceptanceCriteria": [
"TaskPacket struct with all required fields",
"TaskScope resolution (workspace/module/single-file/custom)",
"Validation and serialization support",
"Integration into tools/src/lib.rs"
],
"passes": true,
"priority": "P1"
},
{
"id": "US-006",
"title": "Phase 4 - Policy engine for autonomous coding",
"description": "Encode automation rules: if green + scoped diff + review passed -> merge to dev; if stale branch -> merge-forward before broad tests; if startup blocked -> recover once, then escalate; if lane completed -> emit closeout and cleanup session.",
"acceptanceCriteria": [
"Policy rules engine implemented",
"Rules: green + scoped diff + review -> merge",
"Rules: stale branch -> merge-forward before tests",
"Rules: startup blocked -> recover once, then escalate",
"Rules: lane completed -> closeout and cleanup"
],
"passes": true,
"priority": "P2"
},
{
"id": "US-007",
"title": "Phase 5 - Plugin/MCP lifecycle maturity",
"description": "First-class plugin/MCP lifecycle contract: config validation, startup healthcheck, discovery result, degraded-mode behavior, shutdown/cleanup. Close gaps in end-to-end lifecycle.",
"acceptanceCriteria": [
"Plugin/MCP config validation contract",
"Startup healthcheck with structured results",
"Discovery result reporting",
"Degraded-mode behavior documented and implemented",
"Shutdown/cleanup contract",
"Partial startup and per-server failures reported structurally"
],
"passes": true,
"priority": "P2"
},
{
"id": "US-008",
"title": "Fix kimi-k2.5 model API compatibility",
"description": "The kimi-k2.5 model (and other kimi models) reject API requests containing the is_error field in tool result messages. The OpenAI-compatible provider currently always includes is_error for all models. Need to make this field conditional based on model support.",
"acceptanceCriteria": [
"translate_message function accepts model parameter",
"is_error field excluded for kimi models (kimi-k2.5, kimi-k1.5, etc.)",
"is_error field included for models that support it (openai, grok, xai, etc.)",
"build_chat_completion_request passes model to translate_message",
"Tests verify is_error presence/absence based on model",
"cargo test passes",
"cargo clippy passes",
"cargo fmt passes"
],
"passes": true,
"priority": "P0"
},
{
"id": "US-009",
"title": "Add unit tests for kimi model compatibility fix",
"description": "During dogfooding we discovered the existing test coverage for model-specific is_error handling is insufficient. Need to add dedicated tests for model_rejects_is_error_field function and translate_message behavior with different models.",
"acceptanceCriteria": [
"Test model_rejects_is_error_field identifies kimi-k2.5, kimi-k1.5, dashscope/kimi-k2.5",
"Test translate_message includes is_error for gpt-4, grok-3, claude models",
"Test translate_message excludes is_error for kimi models",
"Test build_chat_completion_request produces correct payload for kimi vs non-kimi",
"All new tests pass",
"cargo test --package api passes"
],
"passes": true,
"priority": "P1"
},
{
"id": "US-010",
"title": "Add model compatibility documentation",
"description": "Document which models require special handling (is_error exclusion, reasoning model tuning param stripping, etc.) in a MODEL_COMPATIBILITY.md file for operators and contributors.",
"acceptanceCriteria": [
"MODEL_COMPATIBILITY.md created in docs/ or repo root",
"Document kimi models is_error exclusion",
"Document reasoning models (o1, o3, grok-3-mini) tuning param stripping",
"Document gpt-5 max_completion_tokens requirement",
"Document qwen model routing through dashscope",
"Cross-reference with existing code comments"
],
"passes": true,
"priority": "P2"
},
{
"id": "US-011",
"title": "Performance optimization: reduce API request serialization overhead",
"description": "The translate_message function creates intermediate JSON Value objects that could be optimized. Profile and optimize the hot path for API request building, especially for conversations with many tool results.",
"acceptanceCriteria": [
"Profile current request building with criterion or similar",
"Identify bottlenecks in translate_message and build_chat_completion_request",
"Implement optimizations (Vec pre-allocation, reduced cloning, etc.)",
"Benchmark before/after showing improvement",
"No functional changes or API breakage"
],
"passes": true,
"priority": "P2"
},
{
"id": "US-012",
"title": "Trust prompt resolver with allowlist auto-trust",
"description": "Add allowlisted auto-trust behavior for known repos/worktrees. Trust prompts currently block TUI startup and require manual intervention. Implement automatic trust resolution for pre-approved repositories.",
"acceptanceCriteria": [
"TrustAllowlist config structure with repo patterns",
"Auto-trust behavior for allowlisted repos/worktrees",
"trust_required event emitted when trust prompt detected",
"trust_resolved event emitted when trust is granted",
"Non-allowlisted repos remain gated (manual trust required)",
"Integration with worker boot lifecycle",
"Tests for allowlist matching and event emission"
],
"passes": true,
"priority": "P1"
},
{
"id": "US-013",
"title": "Phase 2 - Session event ordering + terminal-state reconciliation",
"description": "When the same session emits contradictory lifecycle events (idle, error, completed, transport/server-down) in close succession, expose deterministic final truth. Attach monotonic sequence/causal ordering metadata, classify terminal vs advisory events, reconcile duplicate/out-of-order terminal events into one canonical lane outcome.",
"acceptanceCriteria": [
"Monotonic sequence / causal ordering metadata attached to session lifecycle events",
"Terminal vs advisory event classification implemented",
"Reconcile duplicate or out-of-order terminal events into one canonical outcome",
"Distinguish 'session terminal state unknown because transport died' from real 'completed'",
"Tests verify reconciliation behavior with out-of-order event bursts"
],
"passes": true,
"priority": "P1"
},
{
"id": "US-014",
"title": "Phase 2 - Event provenance / environment labeling",
"description": "Every emitted event should declare its source (live_lane, test, healthcheck, replay, transport) so claws do not mistake test noise for production truth. Include environment/channel label, emitter identity, and confidence/trust level.",
"acceptanceCriteria": [
"EventProvenance enum with live_lane, test, healthcheck, replay, transport variants",
"Environment/channel label attached to all events",
"Emitter identity field on events",
"Confidence/trust level field for downstream automation",
"Tests verify provenance labeling and filtering"
],
"passes": true,
"priority": "P1"
},
{
"id": "US-015",
"title": "Phase 2 - Session identity completeness at creation time",
"description": "A newly created session should emit stable title, workspace/worktree path, and lane/session purpose at creation time. If any field is not yet known, emit explicit typed placeholder reason rather than bare unknown string.",
"acceptanceCriteria": [
"Session creation emits stable title, workspace/worktree path, purpose immediately",
"Explicit typed placeholder when fields unknown (not bare 'unknown' strings)",
"Later-enriched metadata reconciles onto same session identity without ambiguity",
"Tests verify session identity completeness and placeholder handling"
],
"passes": true,
"priority": "P1"
},
{
"id": "US-016",
"title": "Phase 2 - Duplicate terminal-event suppression",
"description": "When the same session emits repeated completed/failed/terminal notifications, collapse duplicates before they trigger repeated downstream reactions. Attach canonical terminal-event fingerprint per lane/session outcome.",
"acceptanceCriteria": [
"Canonical terminal-event fingerprint attached per lane/session outcome",
"Suppress/coalesce repeated terminal notifications within reconciliation window",
"Preserve raw event history for audit while exposing one actionable outcome downstream",
"Surface when later duplicate materially differs from original terminal payload",
"Tests verify deduplication and material difference detection"
],
"passes": true,
"priority": "P2"
},
{
"id": "US-017",
"title": "Phase 2 - Lane ownership / scope binding",
"description": "Each session and lane event should declare who owns it and what workflow scope it belongs to. Attach owner/assignee identity, workflow scope (claw-code-dogfood, external-git-maintenance, infra-health, manual-operator), and mark whether watcher is expected to act, observe only, or ignore.",
"acceptanceCriteria": [
"Owner/assignee identity attached to sessions and lane events",
"Workflow scope field (claw-code-dogfood, external-git-maintenance, etc.)",
"Watcher action expectation field (act, observe-only, ignore)",
"Preserve scope through session restarts, resumes, and late terminal events",
"Tests verify ownership and scope binding"
],
"passes": true,
"priority": "P2"
},
{
"id": "US-018",
"title": "Phase 2 - Nudge acknowledgment / dedupe contract",
"description": "Periodic clawhip nudges should carry nudge id/cycle id and delivery timestamp. Expose whether claw has already acknowledged or responded for that cycle. Distinguish new nudge, retry nudge, and stale duplicate.",
"acceptanceCriteria": [
"Nudge id / cycle id and delivery timestamp attached",
"Acknowledgment state exposed (already acknowledged or not)",
"Distinguish new nudge vs retry nudge vs stale duplicate",
"Allow downstream summaries to bind reported pinpoint back to triggering nudge id",
"Tests verify nudge deduplication and acknowledgment tracking"
],
"passes": true,
"priority": "P2"
},
{
"id": "US-019",
"title": "Phase 2 - Stable roadmap-id assignment for newly filed pinpoints",
"description": "When a claw records a new pinpoint/follow-up, assign or expose a stable tracking id immediately. Expose that id in structured event/report payload and preserve across edits, reorderings, and summary compression.",
"acceptanceCriteria": [
"Canonical roadmap id assigned at filing time",
"Roadmap id exposed in structured event/report payload",
"Same id preserved across edits, reorderings, summary compression",
"Distinguish 'new roadmap filing' from 'update to existing roadmap item'",
"Tests verify stable id assignment and update detection"
],
"passes": true,
"priority": "P2"
},
{
"id": "US-020",
"title": "Phase 2 - Roadmap item lifecycle state contract",
"description": "Each roadmap pinpoint should carry machine-readable lifecycle state (filed, acknowledged, in_progress, blocked, done, superseded). Attach last state-change timestamp and preserve lineage when one pinpoint supersedes or merges into another.",
"acceptanceCriteria": [
"Lifecycle state enum with filed, acknowledged, in_progress, blocked, done, superseded",
"Last state-change timestamp attached",
"New report can declare first filing, status update, or closure",
"Preserve lineage when one pinpoint supersedes or merges into another",
"Tests verify lifecycle state transitions"
],
"passes": true,
"priority": "P2"
},
{
"id": "US-021",
"title": "Request body size pre-flight check for OpenAI-compatible provider",
"description": "Implement pre-flight request body size estimation to prevent 400 Bad Request errors from API gateways with size limits. Based on dogfood findings with kimi-k2.5 testing, DashScope API has a 6MB request body limit that was exceeded by large system prompts.",
"acceptanceCriteria": [
"Pre-flight size estimation before sending requests to OpenAI-compatible providers",
"Clear error message when request exceeds provider-specific size limit",
"Configuration for different provider limits (6MB DashScope, 100MB OpenAI, etc.)",
"Unit tests for size estimation and limit checking",
"Integration with existing error handling for actionable user messages"
],
"passes": true,
"priority": "P1"
},
{
"id": "US-022",
"title": "Enhanced error context for API failures",
"description": "Add structured error context to API failures including request ID tracking across retries, provider-specific error code mapping, and suggested user actions based on error type (e.g., 'Reduce prompt size' for 413, 'Check API key' for 401).",
"acceptanceCriteria": [
"Request ID tracking across retries with full context in error messages",
"Provider-specific error code mapping with actionable suggestions",
"Suggested user actions for common error types (401, 403, 413, 429, 500, 502-504)",
"Unit tests for error context extraction",
"All existing tests pass and clippy is clean"
],
"passes": true,
"priority": "P1"
},
{
"id": "US-023",
"title": "Add automatic routing for kimi models to DashScope",
"description": "Based on dogfood findings with kimi-k2.5 testing, users must manually prefix with dashscope/kimi-k2.5 instead of just using kimi-k2.5. Add automatic routing for kimi/ and kimi- prefixed models to DashScope (similar to qwen models), and add a 'kimi' alias to the model registry.",
"acceptanceCriteria": [
"kimi/ and kimi- prefix routing to DashScope in metadata_for_model()",
"'kimi' alias in MODEL_REGISTRY that resolves to 'kimi-k2.5'",
"resolve_model_alias() handles the kimi alias correctly",
"Unit tests for kimi routing (similar to qwen routing tests)",
"All tests pass and clippy is clean"
],
"passes": true,
"priority": "P1"
},
{
"id": "US-024",
"title": "Add token limit metadata for kimi models",
"description": "The model_token_limit() function has no entries for kimi-k2.5 or kimi-k1.5, causing preflight context window validation to skip these models. Add token limit metadata to enable preflight checks and accurate max token defaults. Per Moonshot AI documentation, kimi-k2.5 supports 256K context window and 16K max output tokens.",
"acceptanceCriteria": [
"model_token_limit('kimi-k2.5') returns Some(ModelTokenLimit { max_output_tokens: 16384, context_window_tokens: 256000 })",
"model_token_limit('kimi-k1.5') returns appropriate limits",
"model_token_limit('kimi') follows alias chain (kimi → kimi-k2.5) and returns k2.5 limits",
"preflight_message_request() validates context window for kimi models (via generic preflight, no provider-specific code needed)",
"Unit tests verify limits and preflight behavior for kimi models",
"All tests pass and clippy is clean"
],
"passes": true,
"priority": "P1"
}
],
"metadata": {
"lastUpdated": "2026-04-17",
"completedStories": ["US-001", "US-002", "US-003", "US-004", "US-005", "US-006", "US-007", "US-008", "US-009", "US-010", "US-011", "US-012", "US-013", "US-014", "US-015", "US-016", "US-017", "US-018", "US-019", "US-020", "US-021", "US-022", "US-023", "US-024"],
"inProgressStories": [],
"totalStories": 24,
"status": "completed"
}
}

133
progress.txt Normal file
View File

@ -0,0 +1,133 @@
Ralph Iteration Summary - claw-code Roadmap Implementation
===========================================================
Iteration 1: 2026-04-16
------------------------
US-001 COMPLETED (Phase 1.6 - startup-no-evidence evidence bundle + classifier)
- Files: rust/crates/runtime/src/worker_boot.rs
- Added StartupFailureClassification enum with 6 variants
- Added StartupEvidenceBundle with 8 fields
- Implemented classify_startup_failure() logic
- Added observe_startup_timeout() method to Worker
- Tests: 6 new tests verifying classification logic
US-002 COMPLETED (Phase 2 - Canonical lane event schema)
- Files: rust/crates/runtime/src/lane_events.rs
- Added EventProvenance enum with 5 labels
- Added SessionIdentity, LaneOwnership structs
- Added LaneEventMetadata with sequence/ordering
- Added LaneEventBuilder for construction
- Implemented is_terminal_event(), dedupe_terminal_events()
- Tests: 10 new tests for events and deduplication
US-005 COMPLETED (Phase 4 - Typed task packet format)
- Files:
- rust/crates/runtime/src/task_packet.rs
- rust/crates/runtime/src/task_registry.rs
- rust/crates/tools/src/lib.rs
- Added TaskScope enum (Workspace, Module, SingleFile, Custom)
- Updated TaskPacket with scope_path and worktree fields
- Added validate_scope_requirements() validation logic
- Fixed all test compilation errors in dependent modules
- Tests: Updated existing tests to use new types
PRE-EXISTING IMPLEMENTATIONS (verified working):
------------------------------------------------
US-003 COMPLETE (Phase 3 - Stale-branch detection)
- Files: rust/crates/runtime/src/stale_branch.rs
- BranchFreshness enum (Fresh, Stale, Diverged)
- StaleBranchPolicy (AutoRebase, AutoMergeForward, WarnOnly, Block)
- StaleBranchEvent with structured events
- check_freshness() with git integration
- apply_policy() with policy resolution
- Tests: 12 unit tests + 5 integration tests passing
US-004 COMPLETE (Phase 3 - Recovery recipes with ledger)
- Files: rust/crates/runtime/src/recovery_recipes.rs
- FailureScenario enum with 7 scenarios
- RecoveryStep enum with actionable steps
- RecoveryRecipe with step sequences
- RecoveryLedger for attempt tracking
- RecoveryEvent for structured emission
- attempt_recovery() with escalation logic
- Tests: 15 unit tests + 1 integration test passing
US-006 COMPLETE (Phase 4 - Policy engine for autonomous coding)
- Files: rust/crates/runtime/src/policy_engine.rs
- PolicyRule with condition/action/priority
- PolicyCondition (And, Or, GreenAt, StaleBranch, etc.)
- PolicyAction (MergeToDev, RecoverOnce, Escalate, etc.)
- LaneContext for evaluation context
- evaluate() for rule matching
- Tests: 18 unit tests + 6 integration tests passing
US-007 COMPLETE (Phase 5 - Plugin/MCP lifecycle maturity)
- Files: rust/crates/runtime/src/plugin_lifecycle.rs
- ServerStatus enum (Healthy, Degraded, Failed)
- ServerHealth with capabilities tracking
- PluginState with full lifecycle states
- PluginLifecycle event tracking
- PluginHealthcheck structured results
- DiscoveryResult for capability discovery
- DegradedMode behavior
- Tests: 11 unit tests passing
VERIFICATION STATUS:
------------------
- cargo build --workspace: PASSED
- cargo test --workspace: PASSED (476+ unit tests, 12 integration tests)
- cargo clippy --workspace: PASSED
All 7 stories from prd.json now have passes: true
Iteration 2: 2026-04-16
------------------------
US-009 COMPLETED (Add unit tests for kimi model compatibility fix)
- Files: rust/crates/api/src/providers/openai_compat.rs
- Added 4 comprehensive unit tests:
1. model_rejects_is_error_field_detects_kimi_models - verifies detection of kimi-k2.5, kimi-k1.5, dashscope/kimi-k2.5, case insensitivity
2. translate_message_includes_is_error_for_non_kimi_models - verifies gpt-4o, grok-3, claude include is_error
3. translate_message_excludes_is_error_for_kimi_models - verifies kimi models exclude is_error (prevents 400 Bad Request)
4. build_chat_completion_request_kimi_vs_non_kimi_tool_results - full integration test for request building
- Tests: 4 new tests, 119 unit tests total in api crate (+4), all passing
- Integration tests: 29 passing (no regressions)
US-010 COMPLETED (Add model compatibility documentation)
- Files: docs/MODEL_COMPATIBILITY.md
- Created comprehensive documentation covering:
1. Kimi Models (is_error Exclusion) - documents the 400 Bad Request issue and solution
2. Reasoning Models (Tuning Parameter Stripping) - covers o1, o3, o4, grok-3-mini, qwen-qwq, qwen3-thinking
3. GPT-5 (max_completion_tokens) - documents max_tokens vs max_completion_tokens requirement
4. Qwen Models (DashScope Routing) - explains routing and authentication
- Added implementation details section with key functions
- Added "Adding New Models" guide for future contributors
- Added testing section with example commands
- Cross-referenced with existing code comments in openai_compat.rs
- cargo clippy passes
US-011 COMPLETED (Performance optimization: reduce API request serialization overhead)
- Files:
- rust/crates/api/Cargo.toml (added criterion dev-dependency and bench config)
- rust/crates/api/benches/request_building.rs (new benchmark suite)
- rust/crates/api/src/providers/openai_compat.rs (optimizations)
- rust/crates/api/src/lib.rs (public exports for benchmarks)
- Optimizations implemented:
1. flatten_tool_result_content: Pre-allocate String capacity and avoid intermediate Vec
- Before: collected to Vec<String> then joined
- After: single String with pre-calculated capacity, push directly
2. Made key functions public for benchmarking: translate_message, build_chat_completion_request,
flatten_tool_result_content, is_reasoning_model, model_rejects_is_error_field
- Benchmark results:
- flatten_tool_result_content/single_text: ~17ns
- flatten_tool_result_content/multi_text (10 blocks): ~46ns
- flatten_tool_result_content/large_content (50 blocks): ~11.7µs
- translate_message/text_only: ~200ns
- translate_message/tool_result: ~348ns
- build_chat_completion_request/10 messages: ~16.4µs
- build_chat_completion_request/100 messages: ~209µs
- is_reasoning_model detection: ~26-42ns depending on model
- All tests pass (119 unit tests + 29 integration tests)
- cargo clippy passes

5
rust/.claw.json Normal file
View File

@ -0,0 +1,5 @@
{
"permissions": {
"defaultMode": "dontAsk"
}
}

View File

@ -1,2 +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"}
{"created_at_ms":1775777421902,"session_id":"session-1775777421902-1","type":"session_meta","updated_at_ms":1775777421902,"version":1}

4
rust/.gitignore vendored
View File

@ -1,3 +1,7 @@
target/
.omx/
.clawd-agents/
# Claw Code local artifacts
.claw/settings.local.json
.claw/sessions/
.clawhip/

15
rust/CLAUDE.md Normal file
View File

@ -0,0 +1,15 @@
# CLAUDE.md
This file provides guidance to Claw Code (clawcode.dev) when working with code in this repository.
## Detected stack
- Languages: Rust.
- Frameworks: none detected from the supported starter markers.
## Verification
- Run Rust verification from the repo root: `cargo fmt`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`
## Working agreement
- Prefer small, reviewable changes and keep generated bootstrap files aligned with actual repo workflows.
- Keep shared defaults in `.claw.json`; reserve `.claw/settings.local.json` for machine-local overrides.
- Do not overwrite existing `CLAUDE.md` content automatically; update it intentionally when repo workflows change.

264
rust/Cargo.lock generated
View File

@ -17,10 +17,23 @@ dependencies = [
"memchr",
]
[[package]]
name = "anes"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299"
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "api"
version = "0.1.0"
dependencies = [
"criterion",
"reqwest",
"runtime",
"serde",
@ -35,6 +48,12 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "autocfg"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "base64"
version = "0.22.1"
@ -77,6 +96,12 @@ version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]]
name = "cast"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
[[package]]
name = "cc"
version = "1.2.58"
@ -99,6 +124,58 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "ciborium"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e"
dependencies = [
"ciborium-io",
"ciborium-ll",
"serde",
]
[[package]]
name = "ciborium-io"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757"
[[package]]
name = "ciborium-ll"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9"
dependencies = [
"ciborium-io",
"half",
]
[[package]]
name = "clap"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
dependencies = [
"clap_builder",
]
[[package]]
name = "clap_builder"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
dependencies = [
"anstyle",
"clap_lex",
]
[[package]]
name = "clap_lex"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "clipboard-win"
version = "5.4.1"
@ -144,6 +221,67 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "criterion"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f"
dependencies = [
"anes",
"cast",
"ciborium",
"clap",
"criterion-plot",
"is-terminal",
"itertools",
"num-traits",
"once_cell",
"oorandom",
"plotters",
"rayon",
"regex",
"serde",
"serde_derive",
"serde_json",
"tinytemplate",
"walkdir",
]
[[package]]
name = "criterion-plot"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1"
dependencies = [
"cast",
"itertools",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crossterm"
version = "0.28.1"
@ -169,6 +307,12 @@ dependencies = [
"winapi",
]
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-common"
version = "0.1.7"
@ -209,6 +353,12 @@ dependencies = [
"syn",
]
[[package]]
name = "either"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "endian-type"
version = "0.1.2"
@ -245,7 +395,7 @@ checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78"
dependencies = [
"cfg-if",
"rustix 1.1.4",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@ -380,12 +530,29 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "half"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
dependencies = [
"cfg-if",
"crunchy",
"zerocopy",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
[[package]]
name = "hermit-abi"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
[[package]]
name = "home"
version = "0.5.12"
@ -622,6 +789,26 @@ dependencies = [
"serde",
]
[[package]]
name = "is-terminal"
version = "0.4.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
dependencies = [
"hermit-abi",
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "itertools"
version = "0.10.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.18"
@ -755,6 +942,15 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "once_cell"
version = "1.21.4"
@ -783,6 +979,12 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "oorandom"
version = "11.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
[[package]]
name = "parking_lot"
version = "0.12.5"
@ -837,6 +1039,34 @@ dependencies = [
"time",
]
[[package]]
name = "plotters"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747"
dependencies = [
"num-traits",
"plotters-backend",
"plotters-svg",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "plotters-backend"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a"
[[package]]
name = "plotters-svg"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670"
dependencies = [
"plotters-backend",
]
[[package]]
name = "plugins"
version = "0.1.0"
@ -1015,6 +1245,26 @@ dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "rayon"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
@ -1138,7 +1388,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.4.15",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@ -1522,6 +1772,16 @@ dependencies = [
"zerovec",
]
[[package]]
name = "tinytemplate"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "tinyvec"
version = "1.11.0"

View File

@ -34,10 +34,10 @@ export ANTHROPIC_API_KEY="sk-ant-..."
export ANTHROPIC_BASE_URL="https://your-proxy.com"
```
Or authenticate via OAuth and let the CLI persist credentials locally:
Or provide an OAuth bearer token directly:
```bash
cargo run -p rusty-claude-cli -- login
export ANTHROPIC_AUTH_TOKEN="anthropic-oauth-or-proxy-bearer-token"
```
## Mock parity harness
@ -80,7 +80,7 @@ Primary artifacts:
| Feature | Status |
|---------|--------|
| Anthropic / OpenAI-compatible provider flows + streaming | ✅ |
| OAuth login/logout | ✅ |
| Direct bearer-token auth via `ANTHROPIC_AUTH_TOKEN` | ✅ |
| Interactive REPL (rustyline) | ✅ |
| Tool system (bash, read, write, edit, grep, glob) | ✅ |
| Web tools (search, fetch) | ✅ |
@ -135,17 +135,18 @@ Top-level commands:
version
status
sandbox
acp [serve]
dump-manifests
bootstrap-plan
agents
mcp
skills
system-prompt
login
logout
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:
```bash
@ -159,8 +160,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:
- 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`, `/branch`, `/release-notes`, `/add-dir`
- discovery / debugging: `/mcp`, `/agents`, `/skills`, `/doctor`, `/tasks`, `/context`, `/desktop`, `/ide`
- workspace / git: `/compact`, `/clear`, `/config`, `/memory`, `/init`, `/diff`, `/commit`, `/pr`, `/issue`, `/export`, `/hooks`, `/files`, `/release-notes`
- discovery / debugging: `/mcp`, `/agents`, `/skills`, `/doctor`, `/tasks`, `/context`, `/desktop`
- automation / analysis: `/review`, `/advisor`, `/insights`, `/security-review`, `/subagent`, `/team`, `/telemetry`, `/providers`, `/cron`, and more
- plugin management: `/plugin` (with aliases `/plugins`, `/marketplace`)
@ -194,7 +195,7 @@ rust/
### Crate Responsibilities
- **api** — provider clients, SSE streaming, request/response types, auth (API key + OAuth bearer), request-size/context-window preflight
- **api** — provider clients, SSE streaming, request/response types, auth (`ANTHROPIC_API_KEY` + bearer-token support), request-size/context-window preflight
- **commands** — slash command definitions, parsing, help text generation, JSON/text command rendering
- **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

View File

@ -13,5 +13,12 @@ serde_json.workspace = true
telemetry = { path = "../telemetry" }
tokio = { version = "1", features = ["io-util", "macros", "net", "rt-multi-thread", "time"] }
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }
[lints]
workspace = true
[[bench]]
name = "request_building"
harness = false

View File

@ -0,0 +1,329 @@
// Benchmarks for API request building performance
// Benchmarks are exempt from strict linting as they are test/performance code
#![allow(
clippy::cognitive_complexity,
clippy::doc_markdown,
clippy::explicit_iter_loop,
clippy::format_in_format_args,
clippy::missing_docs_in_private_items,
clippy::must_use_candidate,
clippy::needless_pass_by_value,
clippy::clone_on_copy,
clippy::too_many_lines,
clippy::uninlined_format_args
)]
use api::{
build_chat_completion_request, flatten_tool_result_content, is_reasoning_model,
translate_message, InputContentBlock, InputMessage, MessageRequest, OpenAiCompatConfig,
ToolResultContentBlock,
};
use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion};
use serde_json::json;
/// Create a sample message request with various content types
fn create_sample_request(message_count: usize) -> MessageRequest {
let mut messages = Vec::with_capacity(message_count);
for i in 0..message_count {
match i % 4 {
0 => messages.push(InputMessage::user_text(format!("Message {}", i))),
1 => messages.push(InputMessage {
role: "assistant".to_string(),
content: vec![
InputContentBlock::Text {
text: format!("Assistant response {}", i),
},
InputContentBlock::ToolUse {
id: format!("call_{}", i),
name: "read_file".to_string(),
input: json!({"path": format!("/tmp/file{}", i)}),
},
],
}),
2 => messages.push(InputMessage {
role: "user".to_string(),
content: vec![InputContentBlock::ToolResult {
tool_use_id: format!("call_{}", i - 1),
content: vec![ToolResultContentBlock::Text {
text: format!("Tool result content {}", i),
}],
is_error: false,
}],
}),
_ => messages.push(InputMessage {
role: "assistant".to_string(),
content: vec![InputContentBlock::ToolUse {
id: format!("call_{}", i),
name: "write_file".to_string(),
input: json!({"path": format!("/tmp/out{}", i), "content": "data"}),
}],
}),
}
}
MessageRequest {
model: "gpt-4o".to_string(),
max_tokens: 1024,
messages,
stream: false,
system: Some("You are a helpful assistant.".to_string()),
temperature: Some(0.7),
top_p: None,
tools: None,
tool_choice: None,
frequency_penalty: None,
presence_penalty: None,
stop: None,
reasoning_effort: None,
}
}
/// Benchmark translate_message with various message types
fn bench_translate_message(c: &mut Criterion) {
let mut group = c.benchmark_group("translate_message");
// Text-only message
let text_message = InputMessage::user_text("Simple text message".to_string());
group.bench_with_input(
BenchmarkId::new("text_only", "single"),
&text_message,
|b, msg| {
b.iter(|| translate_message(black_box(msg), black_box("gpt-4o")));
},
);
// Assistant message with tool calls
let assistant_message = InputMessage {
role: "assistant".to_string(),
content: vec![
InputContentBlock::Text {
text: "I'll help you with that.".to_string(),
},
InputContentBlock::ToolUse {
id: "call_1".to_string(),
name: "read_file".to_string(),
input: json!({"path": "/tmp/test"}),
},
InputContentBlock::ToolUse {
id: "call_2".to_string(),
name: "write_file".to_string(),
input: json!({"path": "/tmp/out", "content": "data"}),
},
],
};
group.bench_with_input(
BenchmarkId::new("assistant_with_tools", "2_tools"),
&assistant_message,
|b, msg| {
b.iter(|| translate_message(black_box(msg), black_box("gpt-4o")));
},
);
// Tool result message
let tool_result_message = InputMessage {
role: "user".to_string(),
content: vec![InputContentBlock::ToolResult {
tool_use_id: "call_1".to_string(),
content: vec![ToolResultContentBlock::Text {
text: "File contents here".to_string(),
}],
is_error: false,
}],
};
group.bench_with_input(
BenchmarkId::new("tool_result", "single"),
&tool_result_message,
|b, msg| {
b.iter(|| translate_message(black_box(msg), black_box("gpt-4o")));
},
);
// Tool result for kimi model (is_error excluded)
group.bench_with_input(
BenchmarkId::new("tool_result_kimi", "kimi-k2.5"),
&tool_result_message,
|b, msg| {
b.iter(|| translate_message(black_box(msg), black_box("kimi-k2.5")));
},
);
// Large content message
let large_content = "x".repeat(10000);
let large_message = InputMessage::user_text(large_content);
group.bench_with_input(
BenchmarkId::new("large_text", "10kb"),
&large_message,
|b, msg| {
b.iter(|| translate_message(black_box(msg), black_box("gpt-4o")));
},
);
group.finish();
}
/// Benchmark build_chat_completion_request with various message counts
fn bench_build_request(c: &mut Criterion) {
let mut group = c.benchmark_group("build_chat_completion_request");
let config = OpenAiCompatConfig::openai();
for message_count in [10, 50, 100].iter() {
let request = create_sample_request(*message_count);
group.bench_with_input(
BenchmarkId::new("message_count", message_count),
&request,
|b, req| {
b.iter(|| build_chat_completion_request(black_box(req), config.clone()));
},
);
}
// Benchmark with reasoning model (tuning params stripped)
let mut reasoning_request = create_sample_request(50);
reasoning_request.model = "o1-mini".to_string();
group.bench_with_input(
BenchmarkId::new("reasoning_model", "o1-mini"),
&reasoning_request,
|b, req| {
b.iter(|| build_chat_completion_request(black_box(req), config.clone()));
},
);
// Benchmark with gpt-5 (max_completion_tokens)
let mut gpt5_request = create_sample_request(50);
gpt5_request.model = "gpt-5".to_string();
group.bench_with_input(
BenchmarkId::new("gpt5", "gpt-5"),
&gpt5_request,
|b, req| {
b.iter(|| build_chat_completion_request(black_box(req), config.clone()));
},
);
group.finish();
}
/// Benchmark flatten_tool_result_content
fn bench_flatten_tool_result(c: &mut Criterion) {
let mut group = c.benchmark_group("flatten_tool_result_content");
// Single text block
let single_text = vec![ToolResultContentBlock::Text {
text: "Simple result".to_string(),
}];
group.bench_with_input(
BenchmarkId::new("single_text", "1_block"),
&single_text,
|b, content| {
b.iter(|| flatten_tool_result_content(black_box(content)));
},
);
// Multiple text blocks
let multi_text: Vec<ToolResultContentBlock> = (0..10)
.map(|i| ToolResultContentBlock::Text {
text: format!("Line {}: some content here\n", i),
})
.collect();
group.bench_with_input(
BenchmarkId::new("multi_text", "10_blocks"),
&multi_text,
|b, content| {
b.iter(|| flatten_tool_result_content(black_box(content)));
},
);
// JSON content blocks
let json_content: Vec<ToolResultContentBlock> = (0..5)
.map(|i| ToolResultContentBlock::Json {
value: json!({"index": i, "data": "test content", "nested": {"key": "value"}}),
})
.collect();
group.bench_with_input(
BenchmarkId::new("json_content", "5_blocks"),
&json_content,
|b, content| {
b.iter(|| flatten_tool_result_content(black_box(content)));
},
);
// Mixed content
let mixed_content = vec![
ToolResultContentBlock::Text {
text: "Here's the result:".to_string(),
},
ToolResultContentBlock::Json {
value: json!({"status": "success", "count": 42}),
},
ToolResultContentBlock::Text {
text: "Processing complete.".to_string(),
},
];
group.bench_with_input(
BenchmarkId::new("mixed_content", "text+json"),
&mixed_content,
|b, content| {
b.iter(|| flatten_tool_result_content(black_box(content)));
},
);
// Large content - simulating typical tool output
let large_content: Vec<ToolResultContentBlock> = (0..50)
.map(|i| {
if i % 3 == 0 {
ToolResultContentBlock::Json {
value: json!({"line": i, "content": "x".repeat(100)}),
}
} else {
ToolResultContentBlock::Text {
text: format!("Line {}: {}", i, "some output content here"),
}
}
})
.collect();
group.bench_with_input(
BenchmarkId::new("large_content", "50_blocks"),
&large_content,
|b, content| {
b.iter(|| flatten_tool_result_content(black_box(content)));
},
);
group.finish();
}
/// Benchmark is_reasoning_model detection
fn bench_is_reasoning_model(c: &mut Criterion) {
let mut group = c.benchmark_group("is_reasoning_model");
let models = vec![
("gpt-4o", false),
("o1-mini", true),
("o3", true),
("grok-3", false),
("grok-3-mini", true),
("qwen/qwen-qwq-32b", true),
("qwen/qwen-plus", false),
];
for (model, expected) in models {
group.bench_with_input(
BenchmarkId::new(model, if expected { "reasoning" } else { "normal" }),
model,
|b, m| {
b.iter(|| is_reasoning_model(black_box(m)));
},
);
}
group.finish();
}
criterion_group!(
benches,
bench_translate_message,
bench_build_request,
bench_flatten_tool_result,
bench_is_reasoning_model
);
criterion_main!(benches);

View File

@ -232,10 +232,7 @@ mod tests {
openai_client.base_url()
);
}
other => panic!(
"Expected ProviderClient::OpenAi for qwen-plus, got: {:?}",
other
),
other => panic!("Expected ProviderClient::OpenAi for qwen-plus, got: {other:?}"),
}
}
}

View File

@ -24,7 +24,7 @@ pub enum ApiError {
env_vars: &'static [&'static str],
/// Optional, runtime-computed hint appended to the error Display
/// 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).
hint: Option<String>,
},
@ -53,6 +53,8 @@ pub enum ApiError {
request_id: Option<String>,
body: String,
retryable: bool,
/// Suggested user action based on error type (e.g., "Reduce prompt size" for 413)
suggested_action: Option<String>,
},
RetriesExhausted {
attempts: u32,
@ -63,6 +65,11 @@ pub enum ApiError {
attempt: u32,
base_delay: Duration,
},
RequestBodySizeExceeded {
estimated_bytes: usize,
max_bytes: usize,
provider: &'static str,
},
}
impl ApiError {
@ -129,7 +136,8 @@ impl ApiError {
| Self::Io(_)
| Self::Json { .. }
| Self::InvalidSseFrame(_)
| Self::BackoffOverflow { .. } => false,
| Self::BackoffOverflow { .. }
| Self::RequestBodySizeExceeded { .. } => false,
}
}
@ -147,7 +155,8 @@ impl ApiError {
| Self::Io(_)
| Self::Json { .. }
| Self::InvalidSseFrame(_)
| Self::BackoffOverflow { .. } => None,
| Self::BackoffOverflow { .. }
| Self::RequestBodySizeExceeded { .. } => None,
}
}
@ -172,6 +181,7 @@ impl ApiError {
"provider_transport"
}
Self::InvalidApiKeyEnv(_) | Self::Io(_) | Self::Json { .. } => "runtime_io",
Self::RequestBodySizeExceeded { .. } => "request_size",
}
}
@ -194,7 +204,8 @@ impl ApiError {
| Self::Io(_)
| Self::Json { .. }
| Self::InvalidSseFrame(_)
| Self::BackoffOverflow { .. } => false,
| Self::BackoffOverflow { .. }
| Self::RequestBodySizeExceeded { .. } => false,
}
}
@ -223,12 +234,14 @@ impl ApiError {
| Self::Io(_)
| Self::Json { .. }
| Self::InvalidSseFrame(_)
| Self::BackoffOverflow { .. } => false,
| Self::BackoffOverflow { .. }
| Self::RequestBodySizeExceeded { .. } => false,
}
}
}
impl Display for ApiError {
#[allow(clippy::too_many_lines)]
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::MissingCredentials {
@ -324,6 +337,14 @@ impl Display for ApiError {
f,
"retry backoff overflowed on attempt {attempt} with base delay {base_delay:?}"
),
Self::RequestBodySizeExceeded {
estimated_bytes,
max_bytes,
provider,
} => write!(
f,
"request body size ({estimated_bytes} bytes) exceeds {provider} limit ({max_bytes} bytes); reduce prompt length or context before retrying"
),
}
}
}
@ -469,6 +490,7 @@ mod tests {
request_id: Some("req_jobdori_123".to_string()),
body: String::new(),
retryable: true,
suggested_action: None,
};
assert!(error.is_generic_fatal_wrapper());
@ -491,6 +513,7 @@ mod tests {
request_id: Some("req_nested_456".to_string()),
body: String::new(),
retryable: true,
suggested_action: None,
}),
};
@ -511,6 +534,7 @@ mod tests {
request_id: Some("req_ctx_123".to_string()),
body: String::new(),
retryable: false,
suggested_action: None,
};
assert!(error.is_context_window_failure());

View File

@ -88,12 +88,12 @@ pub fn build_http_client_with(config: &ProxyConfig) -> Result<reqwest::Client, A
.as_deref()
.and_then(reqwest::NoProxy::from_string);
let (http_proxy_url, https_proxy_url) = match config.proxy_url.as_deref() {
let (http_proxy_url, https_url) = match config.proxy_url.as_deref() {
Some(unified) => (Some(unified), Some(unified)),
None => (config.http_proxy.as_deref(), config.https_proxy.as_deref()),
};
if let Some(url) = https_proxy_url {
if let Some(url) = https_url {
let mut proxy = reqwest::Proxy::https(url)?;
if let Some(filter) = no_proxy.clone() {
proxy = proxy.no_proxy(Some(filter));

View File

@ -19,7 +19,10 @@ pub use prompt_cache::{
PromptCacheStats,
};
pub use providers::anthropic::{AnthropicClient, AnthropicClient as ApiClient, AuthSource};
pub use providers::openai_compat::{OpenAiCompatClient, OpenAiCompatConfig};
pub use providers::openai_compat::{
build_chat_completion_request, flatten_tool_result_content, is_reasoning_model,
model_rejects_is_error_field, translate_message, OpenAiCompatClient, OpenAiCompatConfig,
};
pub use providers::{
detect_provider_kind, max_tokens_for_model, max_tokens_for_model_with_override,
resolve_model_alias, ProviderKind,

View File

@ -502,9 +502,8 @@ impl AnthropicClient {
// Best-effort refinement using the Anthropic count_tokens endpoint.
// On any failure (network, parse, auth), fall back to the local
// byte-estimate result which already passed above.
let counted_input_tokens = match self.count_tokens(request).await {
Ok(count) => count,
Err(_) => return Ok(()),
let Ok(counted_input_tokens) = self.count_tokens(request).await else {
return Ok(());
};
let estimated_total_tokens = counted_input_tokens.saturating_add(request.max_tokens);
if estimated_total_tokens > limit.context_window_tokens {
@ -631,21 +630,7 @@ impl AuthSource {
if let Some(bearer_token) = read_env_non_empty("ANTHROPIC_AUTH_TOKEN")? {
return Ok(Self::BearerToken(bearer_token));
}
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),
}
Err(anthropic_missing_credentials())
}
}
@ -665,14 +650,14 @@ pub fn resolve_saved_oauth_token(config: &OAuthConfig) -> Result<Option<OAuthTok
pub fn has_auth_from_env_or_saved() -> Result<bool, ApiError> {
Ok(read_env_non_empty("ANTHROPIC_API_KEY")?.is_some()
|| read_env_non_empty("ANTHROPIC_AUTH_TOKEN")?.is_some()
|| load_saved_oauth_token()?.is_some())
|| read_env_non_empty("ANTHROPIC_AUTH_TOKEN")?.is_some())
}
pub fn resolve_startup_auth_source<F>(load_oauth_config: F) -> Result<AuthSource, ApiError>
where
F: FnOnce() -> Result<Option<OAuthConfig>, ApiError>,
{
let _ = load_oauth_config;
if let Some(api_key) = read_env_non_empty("ANTHROPIC_API_KEY")? {
return match read_env_non_empty("ANTHROPIC_AUTH_TOKEN")? {
Some(bearer_token) => Ok(AuthSource::ApiKeyAndBearer {
@ -685,25 +670,7 @@ where
if let Some(bearer_token) = read_env_non_empty("ANTHROPIC_AUTH_TOKEN")? {
return Ok(AuthSource::BearerToken(bearer_token));
}
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,
)?))
Err(anthropic_missing_credentials())
}
fn resolve_saved_oauth_token_set(
@ -918,6 +885,7 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
request_id,
body,
retryable,
suggested_action: None,
})
}
@ -942,6 +910,7 @@ fn enrich_bearer_auth_error(error: ApiError, auth: &AuthSource) -> ApiError {
request_id,
body,
retryable,
suggested_action,
} = error
else {
return error;
@ -954,6 +923,7 @@ fn enrich_bearer_auth_error(error: ApiError, auth: &AuthSource) -> ApiError {
request_id,
body,
retryable,
suggested_action,
};
}
let Some(bearer_token) = auth.bearer_token() else {
@ -964,6 +934,7 @@ fn enrich_bearer_auth_error(error: ApiError, auth: &AuthSource) -> ApiError {
request_id,
body,
retryable,
suggested_action,
};
};
if !bearer_token.starts_with("sk-ant-") {
@ -974,6 +945,7 @@ fn enrich_bearer_auth_error(error: ApiError, auth: &AuthSource) -> ApiError {
request_id,
body,
retryable,
suggested_action,
};
}
// Only append the hint when the AuthSource is pure BearerToken. If both
@ -988,6 +960,7 @@ fn enrich_bearer_auth_error(error: ApiError, auth: &AuthSource) -> ApiError {
request_id,
body,
retryable,
suggested_action,
};
}
let enriched_message = match message {
@ -1001,6 +974,7 @@ fn enrich_bearer_auth_error(error: ApiError, auth: &AuthSource) -> ApiError {
request_id,
body,
retryable,
suggested_action,
}
}
@ -1016,7 +990,7 @@ fn strip_unsupported_beta_body_fields(body: &mut Value) {
object.remove("presence_penalty");
// Anthropic uses "stop_sequences" not "stop". Convert if present.
if let Some(stop_val) = object.remove("stop") {
if stop_val.as_array().map_or(false, |a| !a.is_empty()) {
if stop_val.as_array().is_some_and(|a| !a.is_empty()) {
object.insert("stop_sequences".to_string(), stop_val);
}
}
@ -1180,7 +1154,7 @@ mod tests {
}
#[test]
fn auth_source_from_saved_oauth_when_env_absent() {
fn auth_source_from_env_or_saved_ignores_saved_oauth_when_env_absent() {
let _guard = env_lock();
let config_home = temp_config_home();
std::env::set_var("CLAW_CONFIG_HOME", &config_home);
@ -1194,8 +1168,8 @@ mod tests {
})
.expect("save oauth credentials");
let auth = AuthSource::from_env_or_saved().expect("saved auth");
assert_eq!(auth.bearer_token(), Some("saved-access-token"));
let error = AuthSource::from_env_or_saved().expect_err("saved oauth should be ignored");
assert!(error.to_string().contains("ANTHROPIC_API_KEY"));
clear_oauth_credentials().expect("clear credentials");
std::env::remove_var("CLAW_CONFIG_HOME");
@ -1251,7 +1225,7 @@ mod tests {
}
#[test]
fn resolve_startup_auth_source_uses_saved_oauth_without_loading_config() {
fn resolve_startup_auth_source_ignores_saved_oauth_without_loading_config() {
let _guard = env_lock();
let config_home = temp_config_home();
std::env::set_var("CLAW_CONFIG_HOME", &config_home);
@ -1265,41 +1239,9 @@ mod tests {
})
.expect("save oauth credentials");
let auth = resolve_startup_auth_source(|| panic!("config should not be loaded"))
.expect("startup auth");
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"));
let error = resolve_startup_auth_source(|| panic!("config should not be loaded"))
.expect_err("saved oauth should be ignored");
assert!(error.to_string().contains("ANTHROPIC_API_KEY"));
clear_oauth_credentials().expect("clear credentials");
std::env::remove_var("CLAW_CONFIG_HOME");
@ -1620,6 +1562,7 @@ mod tests {
request_id: Some("req_varleg_001".to_string()),
body: String::new(),
retryable: false,
suggested_action: None,
};
// when
@ -1660,6 +1603,7 @@ mod tests {
request_id: None,
body: String::new(),
retryable: true,
suggested_action: None,
};
// when
@ -1688,6 +1632,7 @@ mod tests {
request_id: None,
body: String::new(),
retryable: false,
suggested_action: None,
};
// when
@ -1715,6 +1660,7 @@ mod tests {
request_id: None,
body: String::new(),
retryable: false,
suggested_action: None,
};
// when
@ -1739,6 +1685,7 @@ mod tests {
request_id: None,
body: String::new(),
retryable: false,
suggested_action: None,
};
// when

View File

@ -122,6 +122,15 @@ const MODEL_REGISTRY: &[(&str, ProviderMetadata)] = &[
default_base_url: openai_compat::DEFAULT_XAI_BASE_URL,
},
),
(
"kimi",
ProviderMetadata {
provider: ProviderKind::OpenAi,
auth_env: "DASHSCOPE_API_KEY",
base_url_env: "DASHSCOPE_BASE_URL",
default_base_url: openai_compat::DEFAULT_DASHSCOPE_BASE_URL,
},
),
];
#[must_use]
@ -144,7 +153,10 @@ pub fn resolve_model_alias(model: &str) -> String {
"grok-2" => "grok-2",
_ => trimmed,
},
ProviderKind::OpenAi => trimmed,
ProviderKind::OpenAi => match *alias {
"kimi" => "kimi-k2.5",
_ => trimmed,
},
})
})
.map_or_else(|| trimmed.to_string(), ToOwned::to_owned)
@ -194,6 +206,16 @@ pub fn metadata_for_model(model: &str) -> Option<ProviderMetadata> {
default_base_url: openai_compat::DEFAULT_DASHSCOPE_BASE_URL,
});
}
// Kimi models (kimi-k2.5, kimi-k1.5, etc.) via DashScope compatible-mode.
// Routes kimi/* and kimi-* model names to DashScope endpoint.
if canonical.starts_with("kimi/") || canonical.starts_with("kimi-") {
return Some(ProviderMetadata {
provider: ProviderKind::OpenAi,
auth_env: "DASHSCOPE_API_KEY",
base_url_env: "DASHSCOPE_BASE_URL",
default_base_url: openai_compat::DEFAULT_DASHSCOPE_BASE_URL,
});
}
None
}
@ -202,6 +224,15 @@ pub fn detect_provider_kind(model: &str) -> ProviderKind {
if let Some(metadata) = metadata_for_model(model) {
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) {
return ProviderKind::Anthropic;
}
@ -211,6 +242,11 @@ pub fn detect_provider_kind(model: &str) -> ProviderKind {
if openai_compat::has_api_key("XAI_API_KEY") {
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
}
@ -253,6 +289,12 @@ pub fn model_token_limit(model: &str) -> Option<ModelTokenLimit> {
max_output_tokens: 64_000,
context_window_tokens: 131_072,
}),
// Kimi models via DashScope (Moonshot AI)
// Source: https://platform.moonshot.cn/docs/intro
"kimi-k2.5" | "kimi-k1.5" => Some(ModelTokenLimit {
max_output_tokens: 16_384,
context_window_tokens: 256_000,
}),
_ => None,
}
}
@ -494,9 +536,10 @@ mod tests {
// ANTHROPIC_API_KEY was set because metadata_for_model returned None
// and detect_provider_kind fell through to auth-sniffer order.
// The model prefix must win over env-var presence.
let kind = super::metadata_for_model("openai/gpt-4.1-mini")
.map(|m| m.provider)
.unwrap_or_else(|| detect_provider_kind("openai/gpt-4.1-mini"));
let kind = super::metadata_for_model("openai/gpt-4.1-mini").map_or_else(
|| detect_provider_kind("openai/gpt-4.1-mini"),
|m| m.provider,
);
assert_eq!(
kind,
ProviderKind::OpenAi,
@ -505,8 +548,7 @@ mod tests {
// Also cover bare gpt- prefix
let kind2 = super::metadata_for_model("gpt-4o")
.map(|m| m.provider)
.unwrap_or_else(|| detect_provider_kind("gpt-4o"));
.map_or_else(|| detect_provider_kind("gpt-4o"), |m| m.provider);
assert_eq!(kind2, ProviderKind::OpenAi);
}
@ -540,6 +582,34 @@ mod tests {
);
}
#[test]
fn kimi_prefix_routes_to_dashscope() {
// Kimi models via DashScope (kimi-k2.5, kimi-k1.5, etc.)
let meta = super::metadata_for_model("kimi-k2.5")
.expect("kimi-k2.5 must resolve to DashScope metadata");
assert_eq!(meta.auth_env, "DASHSCOPE_API_KEY");
assert_eq!(meta.base_url_env, "DASHSCOPE_BASE_URL");
assert!(meta.default_base_url.contains("dashscope.aliyuncs.com"));
assert_eq!(meta.provider, ProviderKind::OpenAi);
// With provider prefix
let meta2 = super::metadata_for_model("kimi/kimi-k2.5")
.expect("kimi/kimi-k2.5 must resolve to DashScope metadata");
assert_eq!(meta2.auth_env, "DASHSCOPE_API_KEY");
assert_eq!(meta2.provider, ProviderKind::OpenAi);
// Different kimi variants
let meta3 = super::metadata_for_model("kimi-k1.5")
.expect("kimi-k1.5 must resolve to DashScope metadata");
assert_eq!(meta3.auth_env, "DASHSCOPE_API_KEY");
}
#[test]
fn kimi_alias_resolves_to_kimi_k2_5() {
assert_eq!(super::resolve_model_alias("kimi"), "kimi-k2.5");
assert_eq!(super::resolve_model_alias("KIMI"), "kimi-k2.5"); // case insensitive
}
#[test]
fn keeps_existing_max_token_heuristic() {
assert_eq!(max_tokens_for_model("opus"), 32_000);
@ -680,6 +750,69 @@ mod tests {
.expect("models without context metadata should skip the guarded preflight");
}
#[test]
fn returns_context_window_metadata_for_kimi_models() {
// kimi-k2.5
let k25_limit = model_token_limit("kimi-k2.5")
.expect("kimi-k2.5 should have token limit metadata");
assert_eq!(k25_limit.max_output_tokens, 16_384);
assert_eq!(k25_limit.context_window_tokens, 256_000);
// kimi-k1.5
let k15_limit = model_token_limit("kimi-k1.5")
.expect("kimi-k1.5 should have token limit metadata");
assert_eq!(k15_limit.max_output_tokens, 16_384);
assert_eq!(k15_limit.context_window_tokens, 256_000);
}
#[test]
fn kimi_alias_resolves_to_kimi_k25_token_limits() {
// The "kimi" alias resolves to "kimi-k2.5" via resolve_model_alias()
let alias_limit = model_token_limit("kimi")
.expect("kimi alias should resolve to kimi-k2.5 limits");
let direct_limit = model_token_limit("kimi-k2.5")
.expect("kimi-k2.5 should have limits");
assert_eq!(alias_limit.max_output_tokens, direct_limit.max_output_tokens);
assert_eq!(
alias_limit.context_window_tokens,
direct_limit.context_window_tokens
);
}
#[test]
fn preflight_blocks_oversized_requests_for_kimi_models() {
let request = MessageRequest {
model: "kimi-k2.5".to_string(),
max_tokens: 16_384,
messages: vec![InputMessage {
role: "user".to_string(),
content: vec![InputContentBlock::Text {
text: "x".repeat(1_000_000), // Large input to exceed context window
}],
}],
system: Some("Keep the answer short.".to_string()),
tools: None,
tool_choice: None,
stream: true,
..Default::default()
};
let error = preflight_message_request(&request)
.expect_err("oversized request should be rejected for kimi models");
match error {
ApiError::ContextWindowExceeded {
model,
context_window_tokens,
..
} => {
assert_eq!(model, "kimi-k2.5");
assert_eq!(context_window_tokens, 256_000);
}
other => panic!("expected context-window preflight failure, got {other:?}"),
}
}
#[test]
fn parse_dotenv_extracts_keys_handles_comments_quotes_and_export_prefix() {
// given
@ -981,4 +1114,31 @@ NO_EQUALS_LINE
"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().
}

View File

@ -31,12 +31,22 @@ pub struct OpenAiCompatConfig {
pub api_key_env: &'static str,
pub base_url_env: &'static str,
pub default_base_url: &'static str,
/// Maximum request body size in bytes. Provider-specific limits:
/// - `DashScope`: 6MB (`6_291_456` bytes) - observed in dogfood testing
/// - `OpenAI`: 100MB (`104_857_600` bytes)
/// - `xAI`: 50MB (`52_428_800` bytes)
pub max_request_body_bytes: usize,
}
const XAI_ENV_VARS: &[&str] = &["XAI_API_KEY"];
const OPENAI_ENV_VARS: &[&str] = &["OPENAI_API_KEY"];
const DASHSCOPE_ENV_VARS: &[&str] = &["DASHSCOPE_API_KEY"];
// Provider-specific request body size limits in bytes
const XAI_MAX_REQUEST_BODY_BYTES: usize = 52_428_800; // 50MB
const OPENAI_MAX_REQUEST_BODY_BYTES: usize = 104_857_600; // 100MB
const DASHSCOPE_MAX_REQUEST_BODY_BYTES: usize = 6_291_456; // 6MB (observed limit in dogfood)
impl OpenAiCompatConfig {
#[must_use]
pub const fn xai() -> Self {
@ -45,6 +55,7 @@ impl OpenAiCompatConfig {
api_key_env: "XAI_API_KEY",
base_url_env: "XAI_BASE_URL",
default_base_url: DEFAULT_XAI_BASE_URL,
max_request_body_bytes: XAI_MAX_REQUEST_BODY_BYTES,
}
}
@ -55,13 +66,14 @@ impl OpenAiCompatConfig {
api_key_env: "OPENAI_API_KEY",
base_url_env: "OPENAI_BASE_URL",
default_base_url: DEFAULT_OPENAI_BASE_URL,
max_request_body_bytes: OPENAI_MAX_REQUEST_BODY_BYTES,
}
}
/// 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.
/// 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]
pub const fn dashscope() -> Self {
Self {
@ -69,6 +81,7 @@ impl OpenAiCompatConfig {
api_key_env: "DASHSCOPE_API_KEY",
base_url_env: "DASHSCOPE_BASE_URL",
default_base_url: DEFAULT_DASHSCOPE_BASE_URL,
max_request_body_bytes: DASHSCOPE_MAX_REQUEST_BODY_BYTES,
}
}
@ -170,7 +183,7 @@ impl OpenAiCompatClient {
.to_string();
let code = err_obj
.get("code")
.and_then(|c| c.as_u64())
.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))
@ -183,6 +196,10 @@ impl OpenAiCompatClient {
request_id,
body,
retryable: false,
suggested_action: suggested_action_for_status(
reqwest::StatusCode::from_u16(code.unwrap_or(400))
.unwrap_or(reqwest::StatusCode::BAD_REQUEST),
),
});
}
}
@ -249,6 +266,9 @@ impl OpenAiCompatClient {
&self,
request: &MessageRequest,
) -> Result<reqwest::Response, ApiError> {
// Pre-flight check: verify request body size against provider limits
check_request_body_size(request, self.config())?;
let request_url = chat_completions_endpoint(&self.base_url);
self.http
.post(&request_url)
@ -750,9 +770,14 @@ struct ErrorBody {
}
/// 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.
fn is_reasoning_model(model: &str) -> bool {
/// Returns true for models known to reject tuning parameters like temperature,
/// `top_p`, `frequency_penalty`, and `presence_penalty`. These are typically
/// reasoning/chain-of-thought models with fixed sampling.
/// Public for benchmarking and testing purposes.
#[must_use]
pub fn is_reasoning_model(model: &str) -> bool {
let lowered = model.to_ascii_lowercase();
// Strip any provider/ prefix for the check (e.g. qwen/qwen-qwq -> qwen-qwq)
let canonical = lowered.rsplit('/').next().unwrap_or(lowered.as_str());
@ -776,7 +801,7 @@ fn strip_routing_prefix(model: &str) -> &str {
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") {
if matches!(prefix, "openai" | "xai" | "grok" | "qwen" | "kimi") {
&model[pos + 1..]
} else {
model
@ -786,7 +811,41 @@ fn strip_routing_prefix(model: &str) -> &str {
}
}
fn build_chat_completion_request(request: &MessageRequest, config: OpenAiCompatConfig) -> Value {
/// Estimate the serialized JSON size of a request payload in bytes.
/// This is a pre-flight check to avoid hitting provider-specific size limits.
pub fn estimate_request_body_size(request: &MessageRequest, config: OpenAiCompatConfig) -> usize {
let payload = build_chat_completion_request(request, config);
// serde_json::to_vec gives us the exact byte size of the serialized JSON
serde_json::to_vec(&payload).map_or(0, |v| v.len())
}
/// Pre-flight check for request body size against provider limits.
/// Returns Ok(()) if the request is within limits, or an error with
/// a clear message about the size limit being exceeded.
pub fn check_request_body_size(
request: &MessageRequest,
config: OpenAiCompatConfig,
) -> Result<(), ApiError> {
let estimated_bytes = estimate_request_body_size(request, config);
let max_bytes = config.max_request_body_bytes;
if estimated_bytes > max_bytes {
Err(ApiError::RequestBodySizeExceeded {
estimated_bytes,
max_bytes,
provider: config.provider_name,
})
} else {
Ok(())
}
}
/// Builds a chat completion request payload from a `MessageRequest`.
/// Public for benchmarking purposes.
pub fn build_chat_completion_request(
request: &MessageRequest,
config: OpenAiCompatConfig,
) -> Value {
let mut messages = Vec::new();
if let Some(system) = request.system.as_ref().filter(|value| !value.is_empty()) {
messages.push(json!({
@ -794,8 +853,10 @@ fn build_chat_completion_request(request: &MessageRequest, config: OpenAiCompatC
"content": system,
}));
}
// Strip routing prefix (e.g., "openai/gpt-4" → "gpt-4") for the wire.
let wire_model = strip_routing_prefix(&request.model);
for message in &request.messages {
messages.extend(translate_message(message));
messages.extend(translate_message(message, wire_model));
}
// Sanitize: drop any `role:"tool"` message that does not have a valid
// paired `role:"assistant"` with a `tool_calls` entry carrying the same
@ -806,9 +867,6 @@ fn build_chat_completion_request(request: &MessageRequest, config: OpenAiCompatC
// 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".
@ -868,7 +926,25 @@ fn build_chat_completion_request(request: &MessageRequest, config: OpenAiCompatC
payload
}
fn translate_message(message: &InputMessage) -> Vec<Value> {
/// Returns true for models that do NOT support the `is_error` field in tool results.
/// kimi models (via Moonshot AI/Dashscope) reject this field with 400 Bad Request.
/// Returns true for models that do NOT support the `is_error` field in tool results.
/// kimi models (via Moonshot AI/Dashscope) reject this field with 400 Bad Request.
/// Public for benchmarking and testing purposes.
#[must_use]
pub fn model_rejects_is_error_field(model: &str) -> bool {
let lowered = model.to_ascii_lowercase();
// Strip any provider/ prefix for the check
let canonical = lowered.rsplit('/').next().unwrap_or(lowered.as_str());
// kimi models (kimi-k2.5, kimi-k1.5, kimi-moonshot, etc.)
canonical.starts_with("kimi")
}
/// Translates an `InputMessage` into OpenAI-compatible message format.
/// Public for benchmarking purposes.
#[must_use]
pub fn translate_message(message: &InputMessage, model: &str) -> Vec<Value> {
let supports_is_error = !model_rejects_is_error_field(model);
match message.role.as_str() {
"assistant" => {
let mut text = String::new();
@ -914,12 +990,19 @@ fn translate_message(message: &InputMessage) -> Vec<Value> {
tool_use_id,
content,
is_error,
} => Some(json!({
"role": "tool",
"tool_call_id": tool_use_id,
"content": flatten_tool_result_content(content),
"is_error": is_error,
})),
} => {
let mut msg = json!({
"role": "tool",
"tool_call_id": tool_use_id,
"content": flatten_tool_result_content(content),
});
// Only include is_error for models that support it.
// kimi models reject this field with 400 Bad Request.
if supports_is_error {
msg["is_error"] = json!(is_error);
}
Some(msg)
}
InputContentBlock::ToolUse { .. } => None,
})
.collect(),
@ -938,7 +1021,10 @@ fn translate_message(message: &InputMessage) -> Vec<Value> {
/// `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> {
/// Remove `role:"tool"` messages from `messages` that have no valid paired
/// `role:"assistant"` message with a matching `tool_calls[].id` immediately
/// preceding them. Public for benchmarking purposes.
pub 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() {
@ -974,12 +1060,11 @@ fn sanitize_tool_message_pairing(messages: Vec<Value>) -> Vec<Value> {
}
let paired = preceding
.and_then(|m| m.get("tool_calls").and_then(|tc| tc.as_array()))
.map(|tool_calls| {
.is_some_and(|tool_calls| {
tool_calls
.iter()
.any(|tc| tc.get("id").and_then(|v| v.as_str()) == Some(tool_call_id))
})
.unwrap_or(false);
});
if !paired {
drop_indices.insert(i);
}
@ -995,20 +1080,41 @@ fn sanitize_tool_message_pairing(messages: Vec<Value>) -> Vec<Value> {
.collect()
}
fn flatten_tool_result_content(content: &[ToolResultContentBlock]) -> String {
content
/// Flattens tool result content blocks into a single string.
/// Optimized to pre-allocate capacity and avoid intermediate `Vec` construction.
#[must_use]
pub fn flatten_tool_result_content(content: &[ToolResultContentBlock]) -> String {
// Pre-calculate total capacity needed to avoid reallocations
let total_len: usize = content
.iter()
.map(|block| match block {
ToolResultContentBlock::Text { text } => text.clone(),
ToolResultContentBlock::Json { value } => value.to_string(),
ToolResultContentBlock::Text { text } => text.len(),
ToolResultContentBlock::Json { value } => value.to_string().len(),
})
.collect::<Vec<_>>()
.join("\n")
.sum();
// Add capacity for newlines between blocks
let capacity = total_len + content.len().saturating_sub(1);
let mut result = String::with_capacity(capacity);
for (i, block) in content.iter().enumerate() {
if i > 0 {
result.push('\n');
}
match block {
ToolResultContentBlock::Text { text } => result.push_str(text),
ToolResultContentBlock::Json { value } => {
// Use write! to append without creating intermediate String
result.push_str(&value.to_string());
}
}
}
result
}
/// 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
/// 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) {
@ -1173,7 +1279,7 @@ fn parse_sse_frame(
.to_string();
let code = err_obj
.get("code")
.and_then(|c| c.as_u64())
.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);
@ -1185,8 +1291,9 @@ fn parse_sse_frame(
.map(str::to_owned),
message: Some(msg),
request_id: None,
body: payload.to_string(),
body: payload.clone(),
retryable: false,
suggested_action: suggested_action_for_status(status),
});
}
}
@ -1244,6 +1351,8 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
let parsed_error = serde_json::from_str::<ErrorEnvelope>(&body).ok();
let retryable = is_retryable_status(status);
let suggested_action = suggested_action_for_status(status);
Err(ApiError::Api {
status,
error_type: parsed_error
@ -1255,6 +1364,7 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
request_id,
body,
retryable,
suggested_action,
})
}
@ -1262,6 +1372,20 @@ const fn is_retryable_status(status: reqwest::StatusCode) -> bool {
matches!(status.as_u16(), 408 | 409 | 429 | 500 | 502 | 503 | 504)
}
/// Generate a suggested user action based on the HTTP status code and error context.
/// This provides actionable guidance when API requests fail.
fn suggested_action_for_status(status: reqwest::StatusCode) -> Option<String> {
match status.as_u16() {
401 => Some("Check API key is set correctly and has not expired".to_string()),
403 => Some("Verify API key has required permissions for this operation".to_string()),
413 => Some("Reduce prompt size or context window before retrying".to_string()),
429 => Some("Wait a moment before retrying; consider reducing request rate".to_string()),
500 => Some("Provider server error - retry after a brief wait".to_string()),
502..=504 => Some("Provider gateway error - retry after a brief wait".to_string()),
_ => None,
}
}
fn normalize_finish_reason(value: &str) -> String {
match value {
"stop" => "end_turn",
@ -1642,6 +1766,16 @@ mod tests {
/// 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": "",
@ -1650,15 +1784,6 @@ mod tests {
"role": "assistant",
"tool_calls": null
}"#;
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>,
}
let delta: Delta = serde_json::from_str(json)
.expect("delta with tool_calls:null must deserialize without error");
assert!(
@ -1670,7 +1795,7 @@ mod tests {
/// 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).
/// `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};
@ -1695,13 +1820,12 @@ mod tests {
.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
"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).
/// 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};
@ -1733,7 +1857,7 @@ mod tests {
assert_eq!(tool_calls.as_array().unwrap().len(), 1);
}
/// Orphaned tool messages (no preceding assistant tool_calls) must be
/// 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]
@ -1795,4 +1919,292 @@ mod tests {
"gpt-4o must not emit max_completion_tokens"
);
}
// ============================================================================
// US-009: kimi model compatibility tests
// ============================================================================
#[test]
fn model_rejects_is_error_field_detects_kimi_models() {
// kimi models (various formats) should be detected
assert!(super::model_rejects_is_error_field("kimi-k2.5"));
assert!(super::model_rejects_is_error_field("kimi-k1.5"));
assert!(super::model_rejects_is_error_field("kimi-moonshot"));
assert!(super::model_rejects_is_error_field("KIMI-K2.5")); // case insensitive
assert!(super::model_rejects_is_error_field("dashscope/kimi-k2.5")); // with prefix
assert!(super::model_rejects_is_error_field("moonshot/kimi-k2.5")); // different prefix
// Non-kimi models should NOT be detected
assert!(!super::model_rejects_is_error_field("gpt-4o"));
assert!(!super::model_rejects_is_error_field("gpt-4"));
assert!(!super::model_rejects_is_error_field("claude-sonnet-4-6"));
assert!(!super::model_rejects_is_error_field("grok-3"));
assert!(!super::model_rejects_is_error_field("grok-3-mini"));
assert!(!super::model_rejects_is_error_field("xai/grok-3"));
assert!(!super::model_rejects_is_error_field("qwen/qwen-plus"));
assert!(!super::model_rejects_is_error_field("o1-mini"));
}
#[test]
fn translate_message_includes_is_error_for_non_kimi_models() {
use crate::types::{InputContentBlock, InputMessage, ToolResultContentBlock};
// Test with gpt-4o (should include is_error)
let message = InputMessage {
role: "user".to_string(),
content: vec![InputContentBlock::ToolResult {
tool_use_id: "call_1".to_string(),
content: vec![ToolResultContentBlock::Text {
text: "Error occurred".to_string(),
}],
is_error: true,
}],
};
let translated = super::translate_message(&message, "gpt-4o");
assert_eq!(translated.len(), 1);
let tool_msg = &translated[0];
assert_eq!(tool_msg["role"], json!("tool"));
assert_eq!(tool_msg["tool_call_id"], json!("call_1"));
assert_eq!(tool_msg["content"], json!("Error occurred"));
assert!(
tool_msg.get("is_error").is_some(),
"gpt-4o should include is_error field"
);
assert_eq!(tool_msg["is_error"], json!(true));
// Test with grok-3 (should include is_error)
let message2 = InputMessage {
role: "user".to_string(),
content: vec![InputContentBlock::ToolResult {
tool_use_id: "call_2".to_string(),
content: vec![ToolResultContentBlock::Text {
text: "Success".to_string(),
}],
is_error: false,
}],
};
let translated2 = super::translate_message(&message2, "grok-3");
assert!(
translated2[0].get("is_error").is_some(),
"grok-3 should include is_error field"
);
assert_eq!(translated2[0]["is_error"], json!(false));
// Test with claude model (should include is_error)
let translated3 = super::translate_message(&message, "claude-sonnet-4-6");
assert!(
translated3[0].get("is_error").is_some(),
"claude should include is_error field"
);
}
#[test]
fn translate_message_excludes_is_error_for_kimi_models() {
use crate::types::{InputContentBlock, InputMessage, ToolResultContentBlock};
// Test with kimi-k2.5 (should EXCLUDE is_error)
let message = InputMessage {
role: "user".to_string(),
content: vec![InputContentBlock::ToolResult {
tool_use_id: "call_1".to_string(),
content: vec![ToolResultContentBlock::Text {
text: "Error occurred".to_string(),
}],
is_error: true,
}],
};
let translated = super::translate_message(&message, "kimi-k2.5");
assert_eq!(translated.len(), 1);
let tool_msg = &translated[0];
assert_eq!(tool_msg["role"], json!("tool"));
assert_eq!(tool_msg["tool_call_id"], json!("call_1"));
assert_eq!(tool_msg["content"], json!("Error occurred"));
assert!(
tool_msg.get("is_error").is_none(),
"kimi-k2.5 must NOT include is_error field (would cause 400 Bad Request)"
);
// Test with kimi-k1.5
let translated2 = super::translate_message(&message, "kimi-k1.5");
assert!(
translated2[0].get("is_error").is_none(),
"kimi-k1.5 must NOT include is_error field"
);
// Test with dashscope/kimi-k2.5 (with provider prefix)
let translated3 = super::translate_message(&message, "dashscope/kimi-k2.5");
assert!(
translated3[0].get("is_error").is_none(),
"dashscope/kimi-k2.5 must NOT include is_error field"
);
}
#[test]
fn build_chat_completion_request_kimi_vs_non_kimi_tool_results() {
use crate::types::{InputContentBlock, InputMessage, ToolResultContentBlock};
// Helper to create a request with a tool result
let make_request = |model: &str| MessageRequest {
model: model.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"}),
}],
},
InputMessage {
role: "user".to_string(),
content: vec![InputContentBlock::ToolResult {
tool_use_id: "call_1".to_string(),
content: vec![ToolResultContentBlock::Text {
text: "file contents".to_string(),
}],
is_error: false,
}],
},
],
stream: false,
..Default::default()
};
// Non-kimi model: should have is_error field
let request_gpt = make_request("gpt-4o");
let payload_gpt = build_chat_completion_request(&request_gpt, OpenAiCompatConfig::openai());
let messages_gpt = payload_gpt["messages"].as_array().unwrap();
let tool_msg_gpt = messages_gpt.iter().find(|m| m["role"] == "tool").unwrap();
assert!(
tool_msg_gpt.get("is_error").is_some(),
"gpt-4o request should include is_error in tool result"
);
// kimi model: should NOT have is_error field
let request_kimi = make_request("kimi-k2.5");
let payload_kimi =
build_chat_completion_request(&request_kimi, OpenAiCompatConfig::dashscope());
let messages_kimi = payload_kimi["messages"].as_array().unwrap();
let tool_msg_kimi = messages_kimi.iter().find(|m| m["role"] == "tool").unwrap();
assert!(
tool_msg_kimi.get("is_error").is_none(),
"kimi-k2.5 request must NOT include is_error in tool result (would cause 400)"
);
// Verify both have the essential fields
assert_eq!(tool_msg_gpt["tool_call_id"], json!("call_1"));
assert_eq!(tool_msg_kimi["tool_call_id"], json!("call_1"));
assert_eq!(tool_msg_gpt["content"], json!("file contents"));
assert_eq!(tool_msg_kimi["content"], json!("file contents"));
}
// ============================================================================
// US-021: Request body size pre-flight check tests
// ============================================================================
#[test]
fn estimate_request_body_size_returns_reasonable_estimate() {
let request = MessageRequest {
model: "gpt-4o".to_string(),
max_tokens: 100,
messages: vec![InputMessage::user_text("Hello world".to_string())],
stream: false,
..Default::default()
};
let size = super::estimate_request_body_size(&request, OpenAiCompatConfig::openai());
// Should be non-zero and reasonable for a small request
assert!(size > 0, "estimated size should be positive");
assert!(size < 10_000, "small request should be under 10KB");
}
#[test]
fn check_request_body_size_passes_for_small_requests() {
let request = MessageRequest {
model: "gpt-4o".to_string(),
max_tokens: 100,
messages: vec![InputMessage::user_text("Hello".to_string())],
stream: false,
..Default::default()
};
// Should pass for all providers with a small request
assert!(super::check_request_body_size(&request, OpenAiCompatConfig::openai()).is_ok());
assert!(super::check_request_body_size(&request, OpenAiCompatConfig::xai()).is_ok());
assert!(super::check_request_body_size(&request, OpenAiCompatConfig::dashscope()).is_ok());
}
#[test]
fn check_request_body_size_fails_for_dashscope_when_exceeds_6mb() {
// Create a request that exceeds DashScope's 6MB limit
let large_content = "x".repeat(7_000_000); // 7MB of content
let request = MessageRequest {
model: "qwen-plus".to_string(),
max_tokens: 100,
messages: vec![InputMessage::user_text(large_content)],
stream: false,
..Default::default()
};
let result = super::check_request_body_size(&request, OpenAiCompatConfig::dashscope());
assert!(result.is_err(), "should fail for 7MB request to DashScope");
let err = result.unwrap_err();
match err {
crate::error::ApiError::RequestBodySizeExceeded {
estimated_bytes,
max_bytes,
provider,
} => {
assert_eq!(provider, "DashScope");
assert_eq!(max_bytes, 6_291_456); // 6MB limit
assert!(estimated_bytes > max_bytes);
}
_ => panic!("expected RequestBodySizeExceeded error, got {err:?}"),
}
}
#[test]
fn check_request_body_size_allows_large_requests_for_openai() {
// Create a request that exceeds DashScope's limit but is under OpenAI's 100MB limit
let large_content = "x".repeat(10_000_000); // 10MB of content
let request = MessageRequest {
model: "gpt-4o".to_string(),
max_tokens: 100,
messages: vec![InputMessage::user_text(large_content)],
stream: false,
..Default::default()
};
// Should pass for OpenAI (100MB limit)
assert!(
super::check_request_body_size(&request, OpenAiCompatConfig::openai()).is_ok(),
"10MB request should pass for OpenAI's 100MB limit"
);
// Should fail for DashScope (6MB limit)
assert!(
super::check_request_body_size(&request, OpenAiCompatConfig::dashscope()).is_err(),
"10MB request should fail for DashScope's 6MB limit"
);
}
#[test]
fn provider_specific_size_limits_are_correct() {
assert_eq!(OpenAiCompatConfig::dashscope().max_request_body_bytes, 6_291_456); // 6MB
assert_eq!(OpenAiCompatConfig::openai().max_request_body_bytes, 104_857_600); // 100MB
assert_eq!(OpenAiCompatConfig::xai().max_request_body_bytes, 52_428_800); // 50MB
}
#[test]
fn strip_routing_prefix_strips_kimi_provider_prefix() {
// US-023: kimi prefix should be stripped for wire format
assert_eq!(super::strip_routing_prefix("kimi/kimi-k2.5"), "kimi-k2.5");
assert_eq!(super::strip_routing_prefix("kimi-k2.5"), "kimi-k2.5"); // no prefix, unchanged
assert_eq!(super::strip_routing_prefix("kimi/kimi-k1.5"), "kimi-k1.5");
}
}

View File

@ -4,7 +4,7 @@ use std::fmt;
use std::fs;
use std::path::{Path, PathBuf};
use plugins::{PluginError, PluginManager, PluginSummary};
use plugins::{PluginError, PluginLoadFailure, PluginManager, PluginSummary};
use runtime::{
compact_session, CompactionConfig, ConfigLoader, ConfigSource, McpOAuthConfig, McpServerConfig,
ScopedMcpServerConfig, Session,
@ -257,20 +257,6 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
argument_hint: None,
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 {
name: "plan",
aliases: &[],
@ -1291,7 +1277,6 @@ impl SlashCommand {
Self::Tag { .. } => "/tag",
Self::OutputStyle { .. } => "/output-style",
Self::AddDir { .. } => "/add-dir",
Self::Unknown(_) => "/unknown",
Self::Sandbox => "/sandbox",
Self::Mcp { .. } => "/mcp",
Self::Export { .. } => "/export",
@ -1402,13 +1387,12 @@ pub fn validate_slash_command_input(
validate_no_args(command, &args)?;
SlashCommand::Doctor
}
"login" => {
validate_no_args(command, &args)?;
SlashCommand::Login
}
"logout" => {
validate_no_args(command, &args)?;
SlashCommand::Logout
"login" | "logout" => {
return Err(command_error(
"This auth flow was removed. Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN instead.",
command,
"",
));
}
"vim" => {
validate_no_args(command, &args)?;
@ -1893,20 +1877,12 @@ pub fn resume_supported_slash_commands() -> Vec<&'static SlashCommandSpec> {
fn slash_command_category(name: &str) -> &'static str {
match name {
"help" | "status" | "cost" | "resume" | "session" | "version" | "login" | "logout"
| "usage" | "stats" | "rename" | "clear" | "compact" | "history" | "tokens" | "cache"
| "exit" | "summary" | "tag" | "thinkback" | "copy" | "share" | "feedback" | "rewind"
| "pin" | "unpin" | "bookmarks" | "context" | "files" | "focus" | "unfocus" | "retry"
| "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",
"help" | "status" | "cost" | "resume" | "session" | "version" | "usage" | "stats"
| "rename" | "clear" | "compact" | "history" | "tokens" | "cache" | "exit" | "summary"
| "tag" | "thinkback" | "copy" | "share" | "feedback" | "rewind" | "pin" | "unpin"
| "bookmarks" | "context" | "files" | "focus" | "unfocus" | "retry" | "stop" | "undo" => {
"Session"
}
"model" | "permissions" | "config" | "memory" | "theme" | "vim" | "voice" | "color"
| "effort" | "fast" | "brief" | "output-style" | "keybindings" | "privacy-settings"
| "stickers" | "language" | "profile" | "max-tokens" | "temperature" | "system-prompt"
@ -2210,10 +2186,15 @@ pub fn handle_plugins_slash_command(
manager: &mut PluginManager,
) -> Result<PluginsCommandResult, PluginError> {
match action {
None | Some("list") => Ok(PluginsCommandResult {
message: render_plugins_report(&manager.list_installed_plugins()?),
reload_runtime: false,
}),
None | Some("list") => {
let report = manager.installed_plugin_registry_report()?;
let plugins = report.summaries();
let failures = report.failures();
Ok(PluginsCommandResult {
message: render_plugins_report_with_failures(&plugins, failures),
reload_runtime: false,
})
}
Some("install") => {
let Some(target) = target else {
return Ok(PluginsCommandResult {
@ -2472,7 +2453,8 @@ pub fn resolve_skill_invocation(
.map(|s| s.name.clone())
.collect();
if !names.is_empty() {
message.push_str(&format!("\n Available skills: {}", names.join(", ")));
message.push_str("\n Available skills: ");
message.push_str(&names.join(", "));
}
}
message.push_str("\n Usage: /skills [list|install <path>|help|<skill> [args]]");
@ -2667,6 +2649,48 @@ pub fn render_plugins_report(plugins: &[PluginSummary]) -> String {
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 {
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());
@ -4097,12 +4121,15 @@ mod tests {
handle_plugins_slash_command, handle_skills_slash_command_json, handle_slash_command,
load_agents_from_roots, load_skills_from_roots, render_agents_report,
render_agents_report_json, render_mcp_report_json_for, render_plugins_report,
render_skills_report, render_slash_command_help, render_slash_command_help_detail,
resolve_skill_path, resume_supported_slash_commands, slash_command_specs,
suggest_slash_commands, validate_slash_command_input, DefinitionSource, SkillOrigin,
SkillRoot, SkillSlashDispatch, SlashCommand,
render_plugins_report_with_failures, render_skills_report, render_slash_command_help,
render_slash_command_help_detail, resolve_skill_path, resume_supported_slash_commands,
slash_command_specs, suggest_slash_commands, validate_slash_command_input,
DefinitionSource, SkillOrigin, SkillRoot, SkillSlashDispatch, SlashCommand,
};
use plugins::{
PluginError, PluginKind, PluginLoadFailure, PluginManager, PluginManagerConfig,
PluginMetadata, PluginSummary,
};
use plugins::{PluginKind, PluginManager, PluginManagerConfig, PluginMetadata, PluginSummary};
use runtime::{
CompactionConfig, ConfigLoader, ContentBlock, ConversationMessage, MessageRole, Session,
};
@ -4125,6 +4152,24 @@ mod tests {
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>) {
match original {
Some(value) => std::env::set_var(key, value),
@ -4551,6 +4596,14 @@ mod tests {
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]
fn renders_help_from_shared_specs() {
let help = render_slash_command_help();
@ -4592,7 +4645,9 @@ mod tests {
assert!(help.contains("/agents [list|help]"));
assert!(help.contains("/skills [list|install <path>|help|<skill> [args]]"));
assert!(help.contains("aliases: /skill"));
assert_eq!(slash_command_specs().len(), 141);
assert!(!help.contains("/login"));
assert!(!help.contains("/logout"));
assert_eq!(slash_command_specs().len(), 139);
assert!(resume_supported_slash_commands().len() >= 39);
}
@ -4850,6 +4905,36 @@ mod tests {
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]
fn lists_agents_from_project_and_user_roots() {
let workspace = temp_dir("agents-workspace");
@ -5147,7 +5232,7 @@ mod tests {
#[test]
fn discovers_omc_skills_from_project_and_user_compatibility_roots() {
let _guard = env_lock().lock().expect("env lock");
let _guard = env_guard();
let workspace = temp_dir("skills-omc-workspace");
let user_home = temp_dir("skills-omc-home");
let claude_config_dir = temp_dir("skills-omc-claude-config");

View File

@ -18,6 +18,12 @@ impl UpstreamPaths {
}
}
/// Returns the repository root path.
#[must_use]
pub fn repo_root(&self) -> &Path {
&self.repo_root
}
#[must_use]
pub fn from_workspace_dir(workspace_dir: impl AsRef<Path>) -> Self {
let workspace_dir = workspace_dir

View File

@ -1,10 +1,13 @@
mod hooks;
#[cfg(test)]
pub mod test_isolation;
use std::collections::{BTreeMap, BTreeSet};
use std::fmt::{Display, Formatter};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
@ -2160,7 +2163,13 @@ fn materialize_source(
match source {
PluginInstallSource::LocalPath { path } => Ok(path.clone()),
PluginInstallSource::GitUrl { url } => {
let destination = temp_root.join(format!("plugin-{}", unix_time_ms()));
static MATERIALIZE_COUNTER: AtomicU64 = AtomicU64::new(0);
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")
.arg("clone")
.arg("--depth")
@ -2273,10 +2282,24 @@ fn ensure_object<'a>(root: &'a mut Map<String, Value>, key: &str) -> &'a mut Map
.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)]
mod tests {
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 {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@ -2285,6 +2308,18 @@ mod tests {
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) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("parent dir");
@ -2468,6 +2503,7 @@ mod tests {
#[test]
fn load_plugin_from_directory_validates_required_fields() {
let _guard = env_guard();
let root = temp_dir("manifest-required");
write_file(
root.join(MANIFEST_FILE_NAME).as_path(),
@ -2482,6 +2518,7 @@ mod tests {
#[test]
fn load_plugin_from_directory_reads_root_manifest_and_validates_entries() {
let _guard = env_guard();
let root = temp_dir("manifest-root");
write_loader_plugin(&root);
@ -2511,6 +2548,7 @@ mod tests {
#[test]
fn load_plugin_from_directory_supports_packaged_manifest_path() {
let _guard = env_guard();
let root = temp_dir("manifest-packaged");
write_external_plugin(&root, "packaged-demo", "1.0.0");
@ -2524,6 +2562,7 @@ mod tests {
#[test]
fn load_plugin_from_directory_defaults_optional_fields() {
let _guard = env_guard();
let root = temp_dir("manifest-defaults");
write_file(
root.join(MANIFEST_FILE_NAME).as_path(),
@ -2545,6 +2584,7 @@ mod tests {
#[test]
fn load_plugin_from_directory_rejects_duplicate_permissions_and_commands() {
let _guard = env_guard();
let root = temp_dir("manifest-duplicates");
write_file(
root.join("commands").join("sync.sh").as_path(),
@ -2840,6 +2880,7 @@ mod tests {
#[test]
fn discovers_builtin_and_bundled_plugins() {
let _guard = env_guard();
let manager = PluginManager::new(PluginManagerConfig::new(temp_dir("discover")));
let plugins = manager.list_plugins().expect("plugins should list");
assert!(plugins
@ -2852,6 +2893,7 @@ mod tests {
#[test]
fn installs_enables_updates_and_uninstalls_external_plugins() {
let _guard = env_guard();
let config_home = temp_dir("home");
let source_root = temp_dir("source");
write_external_plugin(&source_root, "demo", "1.0.0");
@ -2900,6 +2942,7 @@ mod tests {
#[test]
fn auto_installs_bundled_plugins_into_the_registry() {
let _guard = env_guard();
let config_home = temp_dir("bundled-home");
let bundled_root = temp_dir("bundled-root");
write_bundled_plugin(&bundled_root.join("starter"), "starter", "0.1.0", false);
@ -2931,6 +2974,7 @@ mod tests {
#[test]
fn default_bundled_root_loads_repo_bundles_as_installed_plugins() {
let _guard = env_guard();
let config_home = temp_dir("default-bundled-home");
let manager = PluginManager::new(PluginManagerConfig::new(&config_home));
@ -2949,6 +2993,7 @@ mod tests {
#[test]
fn bundled_sync_prunes_removed_bundled_registry_entries() {
let _guard = env_guard();
let config_home = temp_dir("bundled-prune-home");
let bundled_root = temp_dir("bundled-prune-root");
let stale_install_path = config_home
@ -3012,6 +3057,7 @@ mod tests {
#[test]
fn installed_plugin_discovery_keeps_registry_entries_outside_install_root() {
let _guard = env_guard();
let config_home = temp_dir("registry-fallback-home");
let bundled_root = temp_dir("registry-fallback-bundled");
let install_root = config_home.join("plugins").join("installed");
@ -3066,6 +3112,7 @@ mod tests {
#[test]
fn installed_plugin_discovery_prunes_stale_registry_entries() {
let _guard = env_guard();
let config_home = temp_dir("registry-prune-home");
let bundled_root = temp_dir("registry-prune-bundled");
let install_root = config_home.join("plugins").join("installed");
@ -3111,6 +3158,7 @@ mod tests {
#[test]
fn persists_bundled_plugin_enable_state_across_reloads() {
let _guard = env_guard();
let config_home = temp_dir("bundled-state-home");
let bundled_root = temp_dir("bundled-state-root");
write_bundled_plugin(&bundled_root.join("starter"), "starter", "0.1.0", false);
@ -3144,6 +3192,7 @@ mod tests {
#[test]
fn persists_bundled_plugin_disable_state_across_reloads() {
let _guard = env_guard();
let config_home = temp_dir("bundled-disabled-home");
let bundled_root = temp_dir("bundled-disabled-root");
write_bundled_plugin(&bundled_root.join("starter"), "starter", "0.1.0", true);
@ -3177,6 +3226,7 @@ mod tests {
#[test]
fn validates_plugin_source_before_install() {
let _guard = env_guard();
let config_home = temp_dir("validate-home");
let source_root = temp_dir("validate-source");
write_external_plugin(&source_root, "validator", "1.0.0");
@ -3191,6 +3241,7 @@ mod tests {
#[test]
fn plugin_registry_tracks_enabled_state_and_lookup() {
let _guard = env_guard();
let config_home = temp_dir("registry-home");
let source_root = temp_dir("registry-source");
write_external_plugin(&source_root, "registry-demo", "1.0.0");
@ -3218,6 +3269,7 @@ mod tests {
#[test]
fn plugin_registry_report_collects_load_failures_without_dropping_valid_plugins() {
let _guard = env_guard();
// given
let config_home = temp_dir("report-home");
let external_root = temp_dir("report-external");
@ -3262,6 +3314,7 @@ mod tests {
#[test]
fn installed_plugin_registry_report_collects_load_failures_from_install_root() {
let _guard = env_guard();
// given
let config_home = temp_dir("installed-report-home");
let bundled_root = temp_dir("installed-report-bundled");
@ -3292,6 +3345,7 @@ mod tests {
#[test]
fn rejects_plugin_sources_with_missing_hook_paths() {
let _guard = env_guard();
// given
let config_home = temp_dir("broken-home");
let source_root = temp_dir("broken-source");
@ -3319,6 +3373,7 @@ mod tests {
#[test]
fn rejects_plugin_sources_with_missing_failure_hook_paths() {
let _guard = env_guard();
// given
let config_home = temp_dir("broken-failure-home");
let source_root = temp_dir("broken-failure-source");
@ -3346,6 +3401,7 @@ mod tests {
#[test]
fn plugin_registry_runs_initialize_and_shutdown_for_enabled_plugins() {
let _guard = env_guard();
let config_home = temp_dir("lifecycle-home");
let source_root = temp_dir("lifecycle-source");
let _ = write_lifecycle_plugin(&source_root, "lifecycle-demo", "1.0.0");
@ -3369,6 +3425,7 @@ mod tests {
#[test]
fn aggregates_and_executes_plugin_tools() {
let _guard = env_guard();
let config_home = temp_dir("tool-home");
let source_root = temp_dir("tool-source");
write_tool_plugin(&source_root, "tool-demo", "1.0.0");
@ -3397,6 +3454,7 @@ mod tests {
#[test]
fn list_installed_plugins_scans_install_root_without_registry_entries() {
let _guard = env_guard();
let config_home = temp_dir("installed-scan-home");
let bundled_root = temp_dir("installed-scan-bundled");
let install_root = config_home.join("plugins").join("installed");
@ -3428,6 +3486,7 @@ mod tests {
#[test]
fn list_installed_plugins_scans_packaged_manifests_in_install_root() {
let _guard = env_guard();
let config_home = temp_dir("installed-packaged-scan-home");
let bundled_root = temp_dir("installed-packaged-scan-bundled");
let install_root = config_home.join("plugins").join("installed");
@ -3456,4 +3515,143 @@ mod tests {
let _ = fs::remove_dir_all(config_home);
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);
}
}

View File

@ -0,0 +1,73 @@
// 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());
}
}

View File

@ -8,6 +8,7 @@ use tokio::process::Command as TokioCommand;
use tokio::runtime::Builder;
use tokio::time::timeout;
use crate::lane_events::{LaneEvent, ShipMergeMethod, ShipProvenance};
use crate::sandbox::{
build_linux_sandbox_command, resolve_sandbox_status_for_request, FilesystemIsolationMode,
SandboxConfig, SandboxStatus,
@ -102,11 +103,76 @@ pub fn execute_bash(input: BashCommandInput) -> io::Result<BashCommandOutput> {
runtime.block_on(execute_bash_async(input, sandbox_status, cwd))
}
/// Detect git push to main and emit ship provenance event
fn detect_and_emit_ship_prepared(command: &str) {
let trimmed = command.trim();
// Simple detection: git push with main/master
if trimmed.contains("git push") && (trimmed.contains("main") || trimmed.contains("master")) {
// Emit ship.prepared event
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
let provenance = ShipProvenance {
source_branch: get_current_branch().unwrap_or_else(|| "unknown".to_string()),
base_commit: get_head_commit().unwrap_or_default(),
commit_count: 0, // Would need to calculate from range
commit_range: "unknown..HEAD".to_string(),
merge_method: ShipMergeMethod::DirectPush,
actor: get_git_actor().unwrap_or_else(|| "unknown".to_string()),
pr_number: None,
};
let _event = LaneEvent::ship_prepared(format!("{}", now), &provenance);
// Log to stderr as interim routing before event stream integration
eprintln!(
"[ship.prepared] branch={} -> main, commits={}, actor={}",
provenance.source_branch, provenance.commit_count, provenance.actor
);
}
}
fn get_current_branch() -> Option<String> {
let output = Command::new("git")
.args(["branch", "--show-current"])
.output()
.ok()?;
if output.status.success() {
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
None
}
}
fn get_head_commit() -> Option<String> {
let output = Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
.output()
.ok()?;
if output.status.success() {
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
None
}
}
fn get_git_actor() -> Option<String> {
let name = Command::new("git")
.args(["config", "user.name"])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())?;
Some(name)
}
async fn execute_bash_async(
input: BashCommandInput,
sandbox_status: SandboxStatus,
cwd: std::path::PathBuf,
) -> io::Result<BashCommandOutput> {
// Detect and emit ship provenance for git push operations
detect_and_emit_ship_prepared(&input.command);
let mut command = prepare_tokio_command(&input.command, &cwd, &sandbox_status, true);
let output_result = if let Some(timeout_ms) = input.timeout {

View File

@ -135,8 +135,7 @@ pub fn compact_session(session: &Session, config: CompactionConfig) -> Compactio
let starts_with_tool_result = first_preserved
.blocks
.first()
.map(|b| matches!(b, ContentBlock::ToolResult { .. }))
.unwrap_or(false);
.is_some_and(|b| matches!(b, ContentBlock::ToolResult { .. }));
if !starts_with_tool_result {
break;
}
@ -741,7 +740,7 @@ mod tests {
/// 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
/// 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() {
@ -795,8 +794,7 @@ mod tests {
let curr_is_tool_result = messages[i]
.blocks
.first()
.map(|b| matches!(b, ContentBlock::ToolResult { .. }))
.unwrap_or(false);
.is_some_and(|b| matches!(b, ContentBlock::ToolResult { .. }));
if curr_is_tool_result {
let prev_has_tool_use = messages[i - 1]
.blocks

View File

@ -292,6 +292,24 @@ 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)]
pub fn run_turn(
&mut self,
@ -299,6 +317,18 @@ where
mut prompter: Option<&mut dyn PermissionPrompter>,
) -> Result<TurnSummary, RuntimeError> {
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.session
.push_user_text(user_input)
@ -1581,6 +1611,88 @@ 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]
fn build_assistant_message_requires_message_stop_event() {
// given

View File

@ -308,12 +308,20 @@ pub fn glob_search(pattern: &str, path: Option<&str>) -> io::Result<GlobSearchOu
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 entries = glob::glob(&search_pattern)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error.to_string()))?;
for entry in entries.flatten() {
if entry.is_file() {
matches.push(entry);
for pat in &expanded {
let entries = glob::glob(pat)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error.to_string()))?;
for entry in entries.flatten() {
if entry.is_file() && seen.insert(entry.clone()) {
matches.push(entry);
}
}
}
@ -619,13 +627,35 @@ pub fn is_symlink_escape(path: &Path, workspace_root: &Path) -> io::Result<bool>
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)]
mod tests {
use std::time::{SystemTime, UNIX_EPOCH};
use super::{
edit_file, glob_search, grep_search, is_symlink_escape, read_file, read_file_in_workspace,
write_file, GrepSearchInput, MAX_WRITE_SIZE,
edit_file, expand_braces, glob_search, grep_search, is_symlink_escape, read_file,
read_file_in_workspace, write_file, GrepSearchInput, MAX_WRITE_SIZE,
};
fn temp_path(name: &str) -> std::path::PathBuf {
@ -759,4 +789,51 @@ mod tests {
.expect("grep should succeed");
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);
}
}

View File

@ -1,4 +1,5 @@
use std::ffi::OsStr;
use std::fmt::Write as FmtWrite;
use std::io::Write;
use std::process::{Command, Stdio};
use std::sync::{
@ -13,6 +14,8 @@ use serde_json::{json, Value};
use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig};
use crate::permissions::PermissionOverride;
const HOOK_PREVIEW_CHAR_LIMIT: usize = 160;
pub type HookPermissionDecision = PermissionOverride;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -437,7 +440,7 @@ impl HookRunner {
Ok(CommandExecution::Finished(output)) => {
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let parsed = parse_hook_output(&stdout);
let parsed = parse_hook_output(event, tool_name, command, &stdout, &stderr);
let primary_message = parsed.primary_message().map(ToOwned::to_owned);
match output.status.code() {
Some(0) => {
@ -532,16 +535,54 @@ fn merge_parsed_hook_output(target: &mut HookRunResult, parsed: ParsedHookOutput
}
}
fn parse_hook_output(stdout: &str) -> ParsedHookOutput {
fn parse_hook_output(
event: HookEvent,
tool_name: &str,
command: &str,
stdout: &str,
stderr: &str,
) -> ParsedHookOutput {
if stdout.is_empty() {
return ParsedHookOutput::default();
}
let Ok(Value::Object(root)) = serde_json::from_str::<Value>(stdout) else {
return ParsedHookOutput {
messages: vec![stdout.to_string()],
..ParsedHookOutput::default()
};
let root = match serde_json::from_str::<Value>(stdout) {
Ok(Value::Object(root)) => root,
Ok(value) => {
return ParsedHookOutput {
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();
@ -619,6 +660,69 @@ fn parse_tool_input(tool_input: &str) -> Value {
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 {
let mut message = format!("Hook `{command}` exited with status {code}");
if let Some(stdout) = stdout.filter(|stdout| !stdout.is_empty()) {
@ -935,6 +1039,31 @@ mod tests {
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]
fn abort_signal_cancels_long_running_hook_and_reports_progress() {
let runner = HookRunner::new(RuntimeHookConfig::new(

View File

@ -1,4 +1,4 @@
#![allow(clippy::similar_names)]
#![allow(clippy::similar_names, clippy::cast_possible_truncation)]
use serde::{Deserialize, Serialize};
use serde_json::Value;
@ -36,6 +36,17 @@ pub enum LaneEventName {
Closed,
#[serde(rename = "branch.stale_against_main")]
BranchStaleAgainstMain,
#[serde(rename = "branch.workspace_mismatch")]
BranchWorkspaceMismatch,
/// Ship/provenance events — §4.44.5
#[serde(rename = "ship.prepared")]
ShipPrepared,
#[serde(rename = "ship.commits_selected")]
ShipCommitsSelected,
#[serde(rename = "ship.merged")]
ShipMerged,
#[serde(rename = "ship.pushed_main")]
ShipPushedMain,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@ -67,14 +78,345 @@ pub enum LaneFailureClass {
McpHandshake,
GatewayRouting,
ToolRuntime,
WorkspaceMismatch,
Infra,
}
/// Provenance labels for event source classification.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EventProvenance {
/// Event from a live, active lane
LiveLane,
/// Event from a synthetic test
Test,
/// Event from a healthcheck probe
Healthcheck,
/// Event from a replay/log replay
Replay,
/// Event from the transport layer itself
Transport,
}
/// Session identity metadata captured at creation time.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SessionIdentity {
/// Stable title for the session
pub title: String,
/// Workspace/worktree path
pub workspace: String,
/// Lane/session purpose
pub purpose: String,
/// Placeholder reason if any field is unknown
#[serde(skip_serializing_if = "Option::is_none")]
pub placeholder_reason: Option<String>,
}
impl SessionIdentity {
/// Create complete session identity
#[must_use]
pub fn new(
title: impl Into<String>,
workspace: impl Into<String>,
purpose: impl Into<String>,
) -> Self {
Self {
title: title.into(),
workspace: workspace.into(),
purpose: purpose.into(),
placeholder_reason: None,
}
}
/// Create session identity with placeholder for missing fields
#[must_use]
pub fn with_placeholder(
title: impl Into<String>,
workspace: impl Into<String>,
purpose: impl Into<String>,
reason: impl Into<String>,
) -> Self {
Self {
title: title.into(),
workspace: workspace.into(),
purpose: purpose.into(),
placeholder_reason: Some(reason.into()),
}
}
}
/// Lane ownership and workflow scope binding.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LaneOwnership {
/// Owner/assignee identity
pub owner: String,
/// Workflow scope (e.g., claw-code-dogfood, external-git-maintenance)
pub workflow_scope: String,
/// Whether the watcher is expected to act, observe, or ignore
pub watcher_action: WatcherAction,
}
/// Watcher action expectation for a lane event.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WatcherAction {
/// Watcher should take action on this event
Act,
/// Watcher should only observe
Observe,
/// Watcher should ignore this event
Ignore,
}
/// Event metadata for ordering, provenance, deduplication, and ownership.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LaneEventMetadata {
/// Monotonic sequence number for event ordering
pub seq: u64,
/// Event provenance source
pub provenance: EventProvenance,
/// Session identity at creation
#[serde(skip_serializing_if = "Option::is_none")]
pub session_identity: Option<SessionIdentity>,
/// Lane ownership and scope
#[serde(skip_serializing_if = "Option::is_none")]
pub ownership: Option<LaneOwnership>,
/// Nudge ID for deduplication cycles
#[serde(skip_serializing_if = "Option::is_none")]
pub nudge_id: Option<String>,
/// Event fingerprint for terminal event deduplication
#[serde(skip_serializing_if = "Option::is_none")]
pub event_fingerprint: Option<String>,
/// Timestamp when event was observed/created
pub timestamp_ms: u64,
}
impl LaneEventMetadata {
/// Create new event metadata
#[must_use]
pub fn new(seq: u64, provenance: EventProvenance) -> Self {
Self {
seq,
provenance,
session_identity: None,
ownership: None,
nudge_id: None,
event_fingerprint: None,
timestamp_ms: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64,
}
}
/// Add session identity
#[must_use]
pub fn with_session_identity(mut self, identity: SessionIdentity) -> Self {
self.session_identity = Some(identity);
self
}
/// Add ownership info
#[must_use]
pub fn with_ownership(mut self, ownership: LaneOwnership) -> Self {
self.ownership = Some(ownership);
self
}
/// Add nudge ID for dedupe
#[must_use]
pub fn with_nudge_id(mut self, nudge_id: impl Into<String>) -> Self {
self.nudge_id = Some(nudge_id.into());
self
}
/// Compute and add event fingerprint for terminal events
#[must_use]
pub fn with_fingerprint(mut self, fingerprint: impl Into<String>) -> Self {
self.event_fingerprint = Some(fingerprint.into());
self
}
}
/// Builder for constructing [`LaneEvent`]s with proper metadata.
#[derive(Debug, Clone)]
pub struct LaneEventBuilder {
event: LaneEventName,
status: LaneEventStatus,
emitted_at: String,
metadata: LaneEventMetadata,
detail: Option<String>,
failure_class: Option<LaneFailureClass>,
data: Option<serde_json::Value>,
}
impl LaneEventBuilder {
/// Start building a new lane event
#[must_use]
pub fn new(
event: LaneEventName,
status: LaneEventStatus,
emitted_at: impl Into<String>,
seq: u64,
provenance: EventProvenance,
) -> Self {
Self {
event,
status,
emitted_at: emitted_at.into(),
metadata: LaneEventMetadata::new(seq, provenance),
detail: None,
failure_class: None,
data: None,
}
}
/// Add session identity
#[must_use]
pub fn with_session_identity(mut self, identity: SessionIdentity) -> Self {
self.metadata = self.metadata.with_session_identity(identity);
self
}
/// Add ownership info
#[must_use]
pub fn with_ownership(mut self, ownership: LaneOwnership) -> Self {
self.metadata = self.metadata.with_ownership(ownership);
self
}
/// Add nudge ID
#[must_use]
pub fn with_nudge_id(mut self, nudge_id: impl Into<String>) -> Self {
self.metadata = self.metadata.with_nudge_id(nudge_id);
self
}
/// Add detail
#[must_use]
pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
self.detail = Some(detail.into());
self
}
/// Add failure class
#[must_use]
pub fn with_failure_class(mut self, failure_class: LaneFailureClass) -> Self {
self.failure_class = Some(failure_class);
self
}
/// Add data payload
#[must_use]
pub fn with_data(mut self, data: serde_json::Value) -> Self {
self.data = Some(data);
self
}
/// Compute fingerprint and build terminal event
#[must_use]
pub fn build_terminal(mut self) -> LaneEvent {
let fingerprint = compute_event_fingerprint(&self.event, &self.status, self.data.as_ref());
self.metadata = self.metadata.with_fingerprint(fingerprint);
self.build()
}
/// Build the event
#[must_use]
pub fn build(self) -> LaneEvent {
LaneEvent {
event: self.event,
status: self.status,
emitted_at: self.emitted_at,
failure_class: self.failure_class,
detail: self.detail,
data: self.data,
metadata: self.metadata,
}
}
}
/// Check if an event kind is terminal (completed, failed, superseded, closed).
#[must_use]
pub fn is_terminal_event(event: LaneEventName) -> bool {
matches!(
event,
LaneEventName::Finished
| LaneEventName::Failed
| LaneEventName::Superseded
| LaneEventName::Closed
| LaneEventName::Merged
)
}
/// Compute a fingerprint for terminal event deduplication.
#[must_use]
pub fn compute_event_fingerprint(
event: &LaneEventName,
status: &LaneEventStatus,
data: Option<&serde_json::Value>,
) -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
format!("{event:?}").hash(&mut hasher);
format!("{status:?}").hash(&mut hasher);
if let Some(d) = data {
serde_json::to_string(d)
.unwrap_or_default()
.hash(&mut hasher);
}
format!("{:016x}", hasher.finish())
}
/// Deduplicate terminal events within a reconciliation window.
/// Returns only the first occurrence of each terminal fingerprint.
#[must_use]
pub fn dedupe_terminal_events(events: &[LaneEvent]) -> Vec<LaneEvent> {
let mut seen_fingerprints = std::collections::HashSet::new();
let mut result = Vec::new();
for event in events {
if is_terminal_event(event.event) {
if let Some(fp) = &event.metadata.event_fingerprint {
if seen_fingerprints.contains(fp) {
continue; // Skip duplicate terminal event
}
seen_fingerprints.insert(fp.clone());
}
}
result.push(event.clone());
}
result
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum BlockedSubphase {
#[serde(rename = "blocked.trust_prompt")]
TrustPrompt { gate_repo: String },
#[serde(rename = "blocked.prompt_delivery")]
PromptDelivery { attempt: u32 },
#[serde(rename = "blocked.plugin_init")]
PluginInit { plugin_name: String },
#[serde(rename = "blocked.mcp_handshake")]
McpHandshake { server_name: String, attempt: u32 },
#[serde(rename = "blocked.branch_freshness")]
BranchFreshness { behind_main: u32 },
#[serde(rename = "blocked.test_hang")]
TestHang { elapsed_secs: u32, test_name: Option<String> },
#[serde(rename = "blocked.report_pending")]
ReportPending { since_secs: u32 },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LaneEventBlocker {
#[serde(rename = "failureClass")]
pub failure_class: LaneFailureClass,
pub detail: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub subphase: Option<BlockedSubphase>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@ -91,6 +433,29 @@ pub struct LaneCommitProvenance {
pub lineage: Vec<String>,
}
/// Ship/provenance metadata — §4.44.5
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ShipProvenance {
pub source_branch: String,
pub base_commit: String,
pub commit_count: u32,
pub commit_range: String,
pub merge_method: ShipMergeMethod,
pub actor: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub pr_number: Option<u32>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ShipMergeMethod {
DirectPush,
FastForward,
MergeCommit,
SquashMerge,
RebaseMerge,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LaneEvent {
pub event: LaneEventName,
@ -103,9 +468,13 @@ pub struct LaneEvent {
pub detail: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<Value>,
/// Event metadata for ordering, provenance, dedupe, and ownership
pub metadata: LaneEventMetadata,
}
impl LaneEvent {
/// Create a new lane event with minimal metadata (seq=0, provenance=LiveLane)
/// Use `LaneEventBuilder` for events requiring full metadata.
#[must_use]
pub fn new(
event: LaneEventName,
@ -119,6 +488,7 @@ impl LaneEvent {
failure_class: None,
detail: None,
data: None,
metadata: LaneEventMetadata::new(0, EventProvenance::LiveLane),
}
}
@ -169,16 +539,56 @@ impl LaneEvent {
#[must_use]
pub fn blocked(emitted_at: impl Into<String>, blocker: &LaneEventBlocker) -> Self {
Self::new(LaneEventName::Blocked, LaneEventStatus::Blocked, emitted_at)
let mut event = Self::new(LaneEventName::Blocked, LaneEventStatus::Blocked, emitted_at)
.with_failure_class(blocker.failure_class)
.with_detail(blocker.detail.clone())
.with_detail(blocker.detail.clone());
if let Some(ref subphase) = blocker.subphase {
event = event.with_data(serde_json::to_value(subphase).expect("subphase should serialize"));
}
event
}
#[must_use]
pub fn failed(emitted_at: impl Into<String>, blocker: &LaneEventBlocker) -> Self {
Self::new(LaneEventName::Failed, LaneEventStatus::Failed, emitted_at)
let mut event = Self::new(LaneEventName::Failed, LaneEventStatus::Failed, emitted_at)
.with_failure_class(blocker.failure_class)
.with_detail(blocker.detail.clone())
.with_detail(blocker.detail.clone());
if let Some(ref subphase) = blocker.subphase {
event = event.with_data(serde_json::to_value(subphase).expect("subphase should serialize"));
}
event
}
/// Ship prepared — §4.44.5
#[must_use]
pub fn ship_prepared(emitted_at: impl Into<String>, provenance: &ShipProvenance) -> Self {
Self::new(LaneEventName::ShipPrepared, LaneEventStatus::Ready, emitted_at)
.with_data(serde_json::to_value(provenance).expect("ship provenance should serialize"))
}
/// Ship commits selected — §4.44.5
#[must_use]
pub fn ship_commits_selected(
emitted_at: impl Into<String>,
commit_count: u32,
commit_range: impl Into<String>,
) -> Self {
Self::new(LaneEventName::ShipCommitsSelected, LaneEventStatus::Ready, emitted_at)
.with_detail(format!("{} commits: {}", commit_count, commit_range.into()))
}
/// Ship merged — §4.44.5
#[must_use]
pub fn ship_merged(emitted_at: impl Into<String>, provenance: &ShipProvenance) -> Self {
Self::new(LaneEventName::ShipMerged, LaneEventStatus::Completed, emitted_at)
.with_data(serde_json::to_value(provenance).expect("ship provenance should serialize"))
}
/// Ship pushed to main — §4.44.5
#[must_use]
pub fn ship_pushed_main(emitted_at: impl Into<String>, provenance: &ShipProvenance) -> Self {
Self::new(LaneEventName::ShipPushedMain, LaneEventStatus::Completed, emitted_at)
.with_data(serde_json::to_value(provenance).expect("ship provenance should serialize"))
}
#[must_use]
@ -251,8 +661,11 @@ mod tests {
use serde_json::json;
use super::{
dedupe_superseded_commit_events, LaneCommitProvenance, LaneEvent, LaneEventBlocker,
LaneEventName, LaneEventStatus, LaneFailureClass,
compute_event_fingerprint, dedupe_superseded_commit_events, dedupe_terminal_events,
is_terminal_event, BlockedSubphase, EventProvenance, LaneCommitProvenance, LaneEvent,
LaneEventBlocker, LaneEventBuilder, LaneEventMetadata, LaneEventName, LaneEventStatus,
LaneFailureClass, LaneOwnership, SessionIdentity, ShipMergeMethod, ShipProvenance,
WatcherAction,
};
#[test]
@ -277,6 +690,14 @@ mod tests {
LaneEventName::BranchStaleAgainstMain,
"branch.stale_against_main",
),
(
LaneEventName::BranchWorkspaceMismatch,
"branch.workspace_mismatch",
),
(LaneEventName::ShipPrepared, "ship.prepared"),
(LaneEventName::ShipCommitsSelected, "ship.commits_selected"),
(LaneEventName::ShipMerged, "ship.merged"),
(LaneEventName::ShipPushedMain, "ship.pushed_main"),
];
for (event, expected) in cases {
@ -300,6 +721,7 @@ mod tests {
(LaneFailureClass::McpHandshake, "mcp_handshake"),
(LaneFailureClass::GatewayRouting, "gateway_routing"),
(LaneFailureClass::ToolRuntime, "tool_runtime"),
(LaneFailureClass::WorkspaceMismatch, "workspace_mismatch"),
(LaneFailureClass::Infra, "infra"),
];
@ -316,6 +738,10 @@ mod tests {
let blocker = LaneEventBlocker {
failure_class: LaneFailureClass::McpStartup,
detail: "broken server".to_string(),
subphase: Some(BlockedSubphase::McpHandshake {
server_name: "test-server".to_string(),
attempt: 1,
}),
};
let blocked = LaneEvent::blocked("2026-04-04T00:00:00Z", &blocker);
@ -329,6 +755,66 @@ mod tests {
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]
fn ship_provenance_events_serialize_to_expected_wire_values() {
let provenance = ShipProvenance {
source_branch: "feature/provenance".to_string(),
base_commit: "dd73962".to_string(),
commit_count: 6,
commit_range: "dd73962..c956f78".to_string(),
merge_method: ShipMergeMethod::DirectPush,
actor: "Jobdori".to_string(),
pr_number: None,
};
let prepared = LaneEvent::ship_prepared("2026-04-20T14:30:00Z", &provenance);
let prepared_json = serde_json::to_value(&prepared).expect("ship event should serialize");
assert_eq!(prepared_json["event"], "ship.prepared");
assert_eq!(prepared_json["data"]["commit_count"], 6);
assert_eq!(prepared_json["data"]["source_branch"], "feature/provenance");
let pushed = LaneEvent::ship_pushed_main("2026-04-20T14:35:00Z", &provenance);
let pushed_json = serde_json::to_value(&pushed).expect("ship event should serialize");
assert_eq!(pushed_json["event"], "ship.pushed_main");
assert_eq!(pushed_json["data"]["merge_method"], "direct_push");
let round_trip: LaneEvent =
serde_json::from_value(pushed_json).expect("ship event should deserialize");
assert_eq!(round_trip.event, LaneEventName::ShipPushedMain);
}
#[test]
fn commit_events_can_carry_worktree_and_supersession_metadata() {
let event = LaneEvent::commit_created(
@ -380,4 +866,222 @@ mod tests {
assert_eq!(retained.len(), 1);
assert_eq!(retained[0].detail.as_deref(), Some("new"));
}
#[test]
fn lane_event_metadata_includes_monotonic_sequence() {
let meta1 = LaneEventMetadata::new(0, EventProvenance::LiveLane);
let meta2 = LaneEventMetadata::new(1, EventProvenance::LiveLane);
let meta3 = LaneEventMetadata::new(2, EventProvenance::Test);
assert_eq!(meta1.seq, 0);
assert_eq!(meta2.seq, 1);
assert_eq!(meta3.seq, 2);
assert!(meta1.timestamp_ms <= meta2.timestamp_ms);
}
#[test]
fn event_provenance_round_trips_through_serialization() {
let cases = [
(EventProvenance::LiveLane, "live_lane"),
(EventProvenance::Test, "test"),
(EventProvenance::Healthcheck, "healthcheck"),
(EventProvenance::Replay, "replay"),
(EventProvenance::Transport, "transport"),
];
for (provenance, expected) in cases {
let json = serde_json::to_value(provenance).expect("should serialize");
assert_eq!(json, serde_json::json!(expected));
let round_trip: EventProvenance =
serde_json::from_value(json).expect("should deserialize");
assert_eq!(round_trip, provenance);
}
}
#[test]
fn session_identity_is_complete_at_creation() {
let identity = SessionIdentity::new("my-lane", "/tmp/repo", "implement feature X");
assert_eq!(identity.title, "my-lane");
assert_eq!(identity.workspace, "/tmp/repo");
assert_eq!(identity.purpose, "implement feature X");
assert!(identity.placeholder_reason.is_none());
// Test with placeholder
let with_placeholder = SessionIdentity::with_placeholder(
"untitled",
"/tmp/unknown",
"unknown",
"session created before title was known",
);
assert_eq!(
with_placeholder.placeholder_reason,
Some("session created before title was known".to_string())
);
}
#[test]
fn lane_ownership_binding_includes_workflow_scope() {
let ownership = LaneOwnership {
owner: "claw-1".to_string(),
workflow_scope: "claw-code-dogfood".to_string(),
watcher_action: WatcherAction::Act,
};
assert_eq!(ownership.owner, "claw-1");
assert_eq!(ownership.workflow_scope, "claw-code-dogfood");
assert_eq!(ownership.watcher_action, WatcherAction::Act);
}
#[test]
fn watcher_action_round_trips_through_serialization() {
let cases = [
(WatcherAction::Act, "act"),
(WatcherAction::Observe, "observe"),
(WatcherAction::Ignore, "ignore"),
];
for (action, expected) in cases {
let json = serde_json::to_value(action).expect("should serialize");
assert_eq!(json, serde_json::json!(expected));
let round_trip: WatcherAction =
serde_json::from_value(json).expect("should deserialize");
assert_eq!(round_trip, action);
}
}
#[test]
fn is_terminal_event_detects_terminal_states() {
assert!(is_terminal_event(LaneEventName::Finished));
assert!(is_terminal_event(LaneEventName::Failed));
assert!(is_terminal_event(LaneEventName::Superseded));
assert!(is_terminal_event(LaneEventName::Closed));
assert!(is_terminal_event(LaneEventName::Merged));
assert!(!is_terminal_event(LaneEventName::Started));
assert!(!is_terminal_event(LaneEventName::Ready));
assert!(!is_terminal_event(LaneEventName::Blocked));
}
#[test]
fn compute_event_fingerprint_is_deterministic() {
let fp1 = compute_event_fingerprint(
&LaneEventName::Finished,
&LaneEventStatus::Completed,
Some(&json!({"commit": "abc123"})),
);
let fp2 = compute_event_fingerprint(
&LaneEventName::Finished,
&LaneEventStatus::Completed,
Some(&json!({"commit": "abc123"})),
);
assert_eq!(fp1, fp2, "same inputs should produce same fingerprint");
assert!(!fp1.is_empty());
assert_eq!(fp1.len(), 16, "fingerprint should be 16 hex chars");
}
#[test]
fn compute_event_fingerprint_differs_for_different_inputs() {
let fp1 =
compute_event_fingerprint(&LaneEventName::Finished, &LaneEventStatus::Completed, None);
let fp2 = compute_event_fingerprint(&LaneEventName::Failed, &LaneEventStatus::Failed, None);
let fp3 = compute_event_fingerprint(
&LaneEventName::Finished,
&LaneEventStatus::Completed,
Some(&json!({"commit": "abc123"})),
);
assert_ne!(fp1, fp2, "different event/status should differ");
assert_ne!(fp1, fp3, "different data should differ");
}
#[test]
fn dedupe_terminal_events_suppresses_duplicates() {
let event1 = LaneEventBuilder::new(
LaneEventName::Finished,
LaneEventStatus::Completed,
"2026-04-04T00:00:00Z",
0,
EventProvenance::LiveLane,
)
.build_terminal();
let event2 = LaneEventBuilder::new(
LaneEventName::Started,
LaneEventStatus::Running,
"2026-04-04T00:00:01Z",
1,
EventProvenance::LiveLane,
)
.build();
let event3 = LaneEventBuilder::new(
LaneEventName::Finished,
LaneEventStatus::Completed,
"2026-04-04T00:00:02Z",
2,
EventProvenance::LiveLane,
)
.build_terminal(); // Same fingerprint as event1
let deduped = dedupe_terminal_events(&[event1.clone(), event2.clone(), event3.clone()]);
assert_eq!(deduped.len(), 2, "should have 2 events after dedupe");
assert_eq!(deduped[0].event, LaneEventName::Finished);
assert_eq!(deduped[1].event, LaneEventName::Started);
// event3 should be suppressed as duplicate of event1
}
#[test]
fn lane_event_builder_constructs_event_with_metadata() {
let event = LaneEventBuilder::new(
LaneEventName::Started,
LaneEventStatus::Running,
"2026-04-04T00:00:00Z",
42,
EventProvenance::Test,
)
.with_session_identity(SessionIdentity::new("test-lane", "/tmp", "test"))
.with_ownership(LaneOwnership {
owner: "bot-1".to_string(),
workflow_scope: "test-suite".to_string(),
watcher_action: WatcherAction::Observe,
})
.with_nudge_id("nudge-123")
.with_detail("starting test run")
.build();
assert_eq!(event.event, LaneEventName::Started);
assert_eq!(event.metadata.seq, 42);
assert_eq!(event.metadata.provenance, EventProvenance::Test);
assert_eq!(
event.metadata.session_identity.as_ref().unwrap().title,
"test-lane"
);
assert_eq!(event.metadata.ownership.as_ref().unwrap().owner, "bot-1");
assert_eq!(event.metadata.nudge_id, Some("nudge-123".to_string()));
assert_eq!(event.detail, Some("starting test run".to_string()));
}
#[test]
fn lane_event_metadata_round_trips_through_serialization() {
let meta = LaneEventMetadata::new(5, EventProvenance::Healthcheck)
.with_session_identity(SessionIdentity::new("lane-1", "/tmp", "purpose"))
.with_nudge_id("nudge-abc");
let json = serde_json::to_value(&meta).expect("should serialize");
assert_eq!(json["seq"], 5);
assert_eq!(json["provenance"], "healthcheck");
assert_eq!(json["nudge_id"], "nudge-abc");
assert!(json["timestamp_ms"].as_u64().is_some());
let round_trip: LaneEventMetadata =
serde_json::from_value(json).expect("should deserialize");
assert_eq!(round_trip.seq, 5);
assert_eq!(round_trip.provenance, EventProvenance::Healthcheck);
assert_eq!(round_trip.nudge_id, Some("nudge-abc".to_string()));
}
}

View File

@ -83,8 +83,11 @@ pub use hooks::{
HookAbortSignal, HookEvent, HookProgressEvent, HookProgressReporter, HookRunResult, HookRunner,
};
pub use lane_events::{
dedupe_superseded_commit_events, LaneCommitProvenance, LaneEvent, LaneEventBlocker,
LaneEventName, LaneEventStatus, LaneFailureClass,
compute_event_fingerprint, dedupe_superseded_commit_events, dedupe_terminal_events,
is_terminal_event, BlockedSubphase, EventProvenance, LaneCommitProvenance, LaneEvent,
LaneEventBlocker, LaneEventBuilder, LaneEventMetadata, LaneEventName, LaneEventStatus,
LaneFailureClass, LaneOwnership, SessionIdentity, ShipMergeMethod, ShipProvenance,
WatcherAction,
};
pub use mcp::{
mcp_server_signature, mcp_tool_name, mcp_tool_prefix, normalize_name_for_mcp,

View File

@ -65,6 +65,40 @@ impl PermissionEnforcer {
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]
pub fn active_mode(&self) -> PermissionMode {
self.policy.active_mode()

View File

@ -48,7 +48,9 @@ impl FailureScenario {
WorkerFailureKind::TrustGate => Self::TrustPromptUnresolved,
WorkerFailureKind::PromptDelivery => Self::PromptMisdelivery,
WorkerFailureKind::Protocol => Self::McpHandshakeFailure,
WorkerFailureKind::Provider => Self::ProviderFailure,
WorkerFailureKind::Provider | WorkerFailureKind::StartupNoEvidence => {
Self::ProviderFailure
}
}
}
}

View File

@ -13,6 +13,7 @@ const SESSION_VERSION: u32 = 1;
const ROTATE_AFTER_BYTES: u64 = 256 * 1024;
const MAX_ROTATED_FILES: usize = 3;
static SESSION_ID_COUNTER: AtomicU64 = AtomicU64::new(0);
static LAST_TIMESTAMP_MS: AtomicU64 = AtomicU64::new(0);
/// Speaker role associated with a persisted conversation message.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -96,6 +97,11 @@ pub struct Session {
pub fork: Option<SessionFork>,
pub workspace_root: Option<PathBuf>,
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>,
}
@ -110,6 +116,7 @@ impl PartialEq for Session {
&& self.fork == other.fork
&& self.workspace_root == other.workspace_root
&& self.prompt_history == other.prompt_history
&& self.last_health_check_ms == other.last_health_check_ms
}
}
@ -161,6 +168,8 @@ impl Session {
fork: None,
workspace_root: None,
prompt_history: Vec::new(),
last_health_check_ms: None,
model: None,
persistence: None,
}
}
@ -263,6 +272,8 @@ impl Session {
}),
workspace_root: self.workspace_root.clone(),
prompt_history: self.prompt_history.clone(),
last_health_check_ms: self.last_health_check_ms,
model: self.model.clone(),
persistence: None,
}
}
@ -371,6 +382,10 @@ impl Session {
.collect()
})
.unwrap_or_default();
let model = object
.get("model")
.and_then(JsonValue::as_str)
.map(String::from);
Ok(Self {
version,
session_id,
@ -381,6 +396,8 @@ impl Session {
fork,
workspace_root,
prompt_history,
last_health_check_ms: None,
model,
persistence: None,
})
}
@ -394,6 +411,7 @@ impl Session {
let mut compaction = None;
let mut fork = None;
let mut workspace_root = None;
let mut model = None;
let mut prompt_history = Vec::new();
for (line_number, raw_line) in contents.lines().enumerate() {
@ -433,6 +451,10 @@ impl Session {
.get("workspace_root")
.and_then(JsonValue::as_str)
.map(PathBuf::from);
model = object
.get("model")
.and_then(JsonValue::as_str)
.map(String::from);
}
"message" => {
let message_value = object.get("message").ok_or_else(|| {
@ -475,6 +497,8 @@ impl Session {
fork,
workspace_root,
prompt_history,
last_health_check_ms: None,
model,
persistence: None,
})
}
@ -580,6 +604,9 @@ impl Session {
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))
}
@ -1004,10 +1031,27 @@ fn normalize_optional_string(value: Option<String>) -> Option<String> {
}
fn current_time_millis() -> u64 {
SystemTime::now()
let wall_clock = SystemTime::now()
.duration_since(UNIX_EPOCH)
.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 {
@ -1099,8 +1143,8 @@ fn cleanup_rotated_logs(path: &Path) -> Result<(), SessionError> {
#[cfg(test)]
mod tests {
use super::{
cleanup_rotated_logs, rotate_session_file_if_needed, ContentBlock, ConversationMessage,
MessageRole, Session, SessionFork,
cleanup_rotated_logs, current_time_millis, rotate_session_file_if_needed, ContentBlock,
ConversationMessage, MessageRole, Session, SessionFork,
};
use crate::json::JsonValue;
use crate::usage::TokenUsage;
@ -1108,6 +1152,16 @@ mod tests {
use std::path::{Path, PathBuf};
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]
fn persists_and_restores_session_jsonl() {
let mut session = Session::new();
@ -1441,12 +1495,8 @@ mod tests {
/// Called by external consumers (e.g. clawhip) to enumerate sessions for a CWD.
#[allow(dead_code)]
pub fn workspace_sessions_dir(cwd: &std::path::Path) -> Result<std::path::PathBuf, SessionError> {
let store = crate::session_control::SessionStore::from_cwd(cwd).map_err(|e| {
SessionError::Io(std::io::Error::new(
std::io::ErrorKind::Other,
e.to_string(),
))
})?;
let store = crate::session_control::SessionStore::from_cwd(cwd)
.map_err(|e| SessionError::Io(std::io::Error::other(e.to_string())))?;
Ok(store.sessions_dir().to_path_buf())
}
@ -1463,8 +1513,7 @@ mod workspace_sessions_dir_tests {
let result = workspace_sessions_dir(&tmp);
assert!(
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();
// The returned path should be non-empty and end with a hash component

View File

@ -74,6 +74,7 @@ impl SessionStore {
&self.workspace_root
}
#[must_use]
pub fn create_handle(&self, session_id: &str) -> SessionHandle {
let id = session_id.to_string();
let path = self
@ -121,6 +122,17 @@ impl SessionStore {
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(
format_missing_session_reference(session_id),
))
@ -128,68 +140,11 @@ impl SessionStore {
pub fn list_sessions(&self) -> Result<Vec<ManagedSessionSummary>, SessionControlError> {
let mut sessions = Vec::new();
let read_result = fs::read_dir(&self.sessions_root);
let entries = match read_result {
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,
});
self.collect_sessions_from_dir(&self.sessions_root, &mut sessions)?;
if let Some(legacy_root) = self.legacy_sessions_root() {
self.collect_sessions_from_dir(&legacy_root, &mut sessions)?;
}
sessions.sort_by(|left, right| {
right
.modified_epoch_millis
.cmp(&left.modified_epoch_millis)
.then_with(|| right.id.cmp(&left.id))
});
sort_managed_sessions(&mut sessions);
Ok(sessions)
}
@ -206,6 +161,7 @@ impl SessionStore {
) -> Result<LoadedManagedSession, SessionControlError> {
let handle = self.resolve_reference(reference)?;
let session = Session::load_from_path(&handle.path)?;
self.validate_loaded_session(&handle.path, &session)?;
Ok(LoadedManagedSession {
handle: SessionHandle {
id: session.session_id.clone(),
@ -221,7 +177,9 @@ impl SessionStore {
branch_name: Option<String>,
) -> Result<ForkedManagedSession, SessionControlError> {
let parent_session_id = session.session_id.clone();
let forked = session.fork(branch_name);
let forked = session
.fork(branch_name)
.with_workspace_root(self.workspace_root.clone());
let handle = self.create_handle(&forked.session_id);
let branch_name = forked
.fork
@ -236,6 +194,98 @@ impl SessionStore {
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.
@ -269,12 +319,23 @@ pub struct SessionHandle {
pub struct ManagedSessionSummary {
pub id: String,
pub path: PathBuf,
pub updated_at_ms: u64,
pub modified_epoch_millis: u128,
pub message_count: usize,
pub parent_session_id: 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)]
pub struct LoadedManagedSession {
pub handle: SessionHandle,
@ -294,6 +355,7 @@ pub enum SessionControlError {
Io(std::io::Error),
Session(SessionError),
Format(String),
WorkspaceMismatch { expected: PathBuf, actual: PathBuf },
}
impl Display for SessionControlError {
@ -302,6 +364,12 @@ impl Display for SessionControlError {
Self::Io(error) => write!(f, "{error}"),
Self::Session(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()
),
}
}
}
@ -327,9 +395,8 @@ pub fn sessions_dir() -> Result<PathBuf, SessionControlError> {
pub fn managed_sessions_dir_for(
base_dir: impl AsRef<Path>,
) -> Result<PathBuf, SessionControlError> {
let path = base_dir.as_ref().join(".claw").join("sessions");
fs::create_dir_all(&path)?;
Ok(path)
let store = SessionStore::from_cwd(base_dir)?;
Ok(store.sessions_dir().to_path_buf())
}
pub fn create_managed_session_handle(
@ -342,10 +409,8 @@ pub fn create_managed_session_handle_for(
base_dir: impl AsRef<Path>,
session_id: &str,
) -> Result<SessionHandle, SessionControlError> {
let id = session_id.to_string();
let path =
managed_sessions_dir_for(base_dir)?.join(format!("{id}.{PRIMARY_SESSION_EXTENSION}"));
Ok(SessionHandle { id, path })
let store = SessionStore::from_cwd(base_dir)?;
Ok(store.create_handle(session_id))
}
pub fn resolve_session_reference(reference: &str) -> Result<SessionHandle, SessionControlError> {
@ -356,36 +421,8 @@ pub fn resolve_session_reference_for(
base_dir: impl AsRef<Path>,
reference: &str,
) -> Result<SessionHandle, SessionControlError> {
let base_dir = base_dir.as_ref();
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,
})
let store = SessionStore::from_cwd(base_dir)?;
store.resolve_reference(reference)
}
pub fn resolve_managed_session_path(session_id: &str) -> Result<PathBuf, SessionControlError> {
@ -396,16 +433,8 @@ pub fn resolve_managed_session_path_for(
base_dir: impl AsRef<Path>,
session_id: &str,
) -> Result<PathBuf, SessionControlError> {
let directory = managed_sessions_dir_for(base_dir)?;
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),
))
let store = SessionStore::from_cwd(base_dir)?;
store.resolve_managed_path(session_id)
}
#[must_use]
@ -424,64 +453,8 @@ pub fn list_managed_sessions() -> Result<Vec<ManagedSessionSummary>, SessionCont
pub fn list_managed_sessions_for(
base_dir: impl AsRef<Path>,
) -> Result<Vec<ManagedSessionSummary>, SessionControlError> {
let mut sessions = Vec::new();
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)
let store = SessionStore::from_cwd(base_dir)?;
store.list_sessions()
}
pub fn latest_managed_session() -> Result<ManagedSessionSummary, SessionControlError> {
@ -491,10 +464,8 @@ pub fn latest_managed_session() -> Result<ManagedSessionSummary, SessionControlE
pub fn latest_managed_session_for(
base_dir: impl AsRef<Path>,
) -> Result<ManagedSessionSummary, SessionControlError> {
list_managed_sessions_for(base_dir)?
.into_iter()
.next()
.ok_or_else(|| SessionControlError::Format(format_no_managed_sessions()))
let store = SessionStore::from_cwd(base_dir)?;
store.latest_session()
}
pub fn load_managed_session(reference: &str) -> Result<LoadedManagedSession, SessionControlError> {
@ -505,15 +476,8 @@ pub fn load_managed_session_for(
base_dir: impl AsRef<Path>,
reference: &str,
) -> Result<LoadedManagedSession, SessionControlError> {
let handle = resolve_session_reference_for(base_dir, reference)?;
let session = Session::load_from_path(&handle.path)?;
Ok(LoadedManagedSession {
handle: SessionHandle {
id: session.session_id.clone(),
path: handle.path,
},
session,
})
let store = SessionStore::from_cwd(base_dir)?;
store.load_session(reference)
}
pub fn fork_managed_session(
@ -528,21 +492,8 @@ pub fn fork_managed_session_for(
session: &Session,
branch_name: Option<String>,
) -> Result<ForkedManagedSession, SessionControlError> {
let parent_session_id = session.session_id.clone();
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,
})
let store = SessionStore::from_cwd(base_dir)?;
store.fork_session(session, branch_name)
}
#[must_use]
@ -574,12 +525,36 @@ 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)]
mod tests {
use super::{
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,
workspace_fingerprint, ManagedSessionSummary, SessionStore, LATEST_SESSION_REFERENCE,
workspace_fingerprint, ManagedSessionSummary, SessionControlError, SessionStore,
LATEST_SESSION_REFERENCE,
};
use crate::session::Session;
use std::fs;
@ -595,7 +570,7 @@ mod tests {
}
fn persist_session(root: &Path, text: &str) -> Session {
let mut session = Session::new();
let mut session = Session::new().with_workspace_root(root.to_path_buf());
session
.push_user_text(text)
.expect("session message should save");
@ -631,6 +606,35 @@ mod tests {
.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]
fn creates_and_lists_managed_sessions() {
// given
@ -708,7 +712,7 @@ mod tests {
// ------------------------------------------------------------------
fn persist_session_via_store(store: &SessionStore, text: &str) -> Session {
let mut session = Session::new();
let mut session = Session::new().with_workspace_root(store.workspace_root().to_path_buf());
session
.push_user_text(text)
.expect("session message should save");
@ -820,6 +824,95 @@ mod tests {
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]
fn session_store_latest_and_resolve_reference() {
// given

View File

@ -1,11 +1,42 @@
use serde::{Deserialize, Serialize};
use std::fmt::{Display, Formatter};
/// Task scope resolution for defining the granularity of work.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TaskScope {
/// Work across the entire workspace
Workspace,
/// Work within a specific module/crate
Module,
/// Work on a single file
SingleFile,
/// Custom scope defined by the user
Custom,
}
impl std::fmt::Display for TaskScope {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::Workspace => write!(f, "workspace"),
Self::Module => write!(f, "module"),
Self::SingleFile => write!(f, "single-file"),
Self::Custom => write!(f, "custom"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TaskPacket {
pub objective: String,
pub scope: String,
pub scope: TaskScope,
/// Optional scope path when scope is `Module`, `SingleFile`, or `Custom`
#[serde(skip_serializing_if = "Option::is_none")]
pub scope_path: Option<String>,
pub repo: String,
/// Worktree path for the task
#[serde(skip_serializing_if = "Option::is_none")]
pub worktree: Option<String>,
pub branch_policy: String,
pub acceptance_tests: Vec<String>,
pub commit_policy: String,
@ -57,7 +88,6 @@ pub fn validate_packet(packet: TaskPacket) -> Result<ValidatedPacket, TaskPacket
let mut errors = Vec::new();
validate_required("objective", &packet.objective, &mut errors);
validate_required("scope", &packet.scope, &mut errors);
validate_required("repo", &packet.repo, &mut errors);
validate_required("branch_policy", &packet.branch_policy, &mut errors);
validate_required("commit_policy", &packet.commit_policy, &mut errors);
@ -68,6 +98,9 @@ pub fn validate_packet(packet: TaskPacket) -> Result<ValidatedPacket, TaskPacket
);
validate_required("escalation_policy", &packet.escalation_policy, &mut errors);
// Validate scope-specific requirements
validate_scope_requirements(&packet, &mut errors);
for (index, test) in packet.acceptance_tests.iter().enumerate() {
if test.trim().is_empty() {
errors.push(format!(
@ -83,6 +116,26 @@ pub fn validate_packet(packet: TaskPacket) -> Result<ValidatedPacket, TaskPacket
}
}
fn validate_scope_requirements(packet: &TaskPacket, errors: &mut Vec<String>) {
// Scope path is required for Module, SingleFile, and Custom scopes
let needs_scope_path = matches!(
packet.scope,
TaskScope::Module | TaskScope::SingleFile | TaskScope::Custom
);
if needs_scope_path
&& packet
.scope_path
.as_ref()
.is_none_or(|p| p.trim().is_empty())
{
errors.push(format!(
"scope_path is required for scope '{}'",
packet.scope
));
}
}
fn validate_required(field: &str, value: &str, errors: &mut Vec<String>) {
if value.trim().is_empty() {
errors.push(format!("{field} must not be empty"));
@ -96,8 +149,10 @@ mod tests {
fn sample_packet() -> TaskPacket {
TaskPacket {
objective: "Implement typed task packet format".to_string(),
scope: "runtime/task system".to_string(),
scope: TaskScope::Module,
scope_path: Some("runtime/task system".to_string()),
repo: "claw-code-parity".to_string(),
worktree: Some("/tmp/wt-1".to_string()),
branch_policy: "origin/main only".to_string(),
acceptance_tests: vec![
"cargo build --workspace".to_string(),
@ -119,9 +174,12 @@ mod tests {
#[test]
fn invalid_packet_accumulates_errors() {
use super::TaskScope;
let packet = TaskPacket {
objective: " ".to_string(),
scope: String::new(),
scope: TaskScope::Workspace,
scope_path: None,
worktree: None,
repo: String::new(),
branch_policy: "\t".to_string(),
acceptance_tests: vec!["ok".to_string(), " ".to_string()],
@ -136,9 +194,6 @@ mod tests {
assert!(error
.errors()
.contains(&"objective must not be empty".to_string()));
assert!(error
.errors()
.contains(&"scope must not be empty".to_string()));
assert!(error
.errors()
.contains(&"repo must not be empty".to_string()));

View File

@ -85,11 +85,12 @@ impl TaskRegistry {
packet: TaskPacket,
) -> Result<Task, TaskPacketValidationError> {
let packet = validate_packet(packet)?.into_inner();
Ok(self.create_task(
packet.objective.clone(),
Some(packet.scope.clone()),
Some(packet),
))
// Use scope_path as description if available, otherwise use scope as string
let description = packet
.scope_path
.clone()
.or_else(|| Some(packet.scope.to_string()));
Ok(self.create_task(packet.objective.clone(), description, Some(packet)))
}
fn create_task(
@ -249,10 +250,13 @@ mod tests {
#[test]
fn creates_task_from_packet() {
use crate::task_packet::TaskScope;
let registry = TaskRegistry::new();
let packet = TaskPacket {
objective: "Ship task packet support".to_string(),
scope: "runtime/task system".to_string(),
scope: TaskScope::Module,
scope_path: Some("runtime/task system".to_string()),
worktree: Some("/tmp/wt-task".to_string()),
repo: "claw-code-parity".to_string(),
branch_policy: "origin/main only".to_string(),
acceptance_tests: vec!["cargo test --workspace".to_string()],

View File

@ -56,6 +56,7 @@ pub enum WorkerFailureKind {
PromptDelivery,
Protocol,
Provider,
StartupNoEvidence,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@ -78,6 +79,7 @@ pub enum WorkerEventKind {
Restarted,
Finished,
Failed,
StartupNoEvidence,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@ -92,9 +94,50 @@ pub enum WorkerTrustResolution {
pub enum WorkerPromptTarget {
Shell,
WrongTarget,
WrongTask,
Unknown,
}
/// Classification of startup failure when no evidence is available.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StartupFailureClassification {
/// Trust prompt is required but not detected/resolved
TrustRequired,
/// Prompt was delivered to wrong target (shell misdelivery)
PromptMisdelivery,
/// Prompt was sent but acceptance timed out
PromptAcceptanceTimeout,
/// Transport layer is dead/unresponsive
TransportDead,
/// Worker process crashed during startup
WorkerCrashed,
/// Cannot determine specific cause
Unknown,
}
/// Evidence bundle collected when worker startup times out without clear evidence.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct StartupEvidenceBundle {
/// Last known worker lifecycle state before timeout
pub last_lifecycle_state: WorkerStatus,
/// The pane/command that was being executed
pub pane_command: String,
/// Timestamp when prompt was sent (if any), unix epoch seconds
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt_sent_at: Option<u64>,
/// Whether prompt acceptance was detected
pub prompt_acceptance_state: bool,
/// Result of trust prompt detection at timeout
pub trust_prompt_detected: bool,
/// Transport health summary (true = healthy/responsive)
pub transport_healthy: bool,
/// MCP health summary (true = all servers healthy)
pub mcp_healthy: bool,
/// Seconds since worker creation
pub elapsed_seconds: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum WorkerEventPayload {
@ -108,8 +151,26 @@ pub enum WorkerEventPayload {
observed_target: WorkerPromptTarget,
#[serde(skip_serializing_if = "Option::is_none")]
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,
},
StartupNoEvidence {
evidence: StartupEvidenceBundle,
classification: StartupFailureClassification,
},
}
#[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)]
@ -134,6 +195,7 @@ pub struct Worker {
pub prompt_delivery_attempts: u32,
pub prompt_in_flight: bool,
pub last_prompt: Option<String>,
pub expected_receipt: Option<WorkerTaskReceipt>,
pub replay_prompt: Option<String>,
pub last_error: Option<WorkerFailure>,
pub created_at: u64,
@ -182,6 +244,7 @@ impl WorkerRegistry {
prompt_delivery_attempts: 0,
prompt_in_flight: false,
last_prompt: None,
expected_receipt: None,
replay_prompt: None,
last_error: None,
created_at: ts,
@ -257,6 +320,7 @@ impl WorkerRegistry {
&lowered,
worker.last_prompt.as_deref(),
&worker.cwd,
worker.expected_receipt.as_ref(),
)
})
.flatten()
@ -272,6 +336,10 @@ impl WorkerRegistry {
"worker prompt landed in the wrong target instead of {}: {}",
worker.cwd, prompt_preview
),
WorkerPromptTarget::WrongTask => format!(
"worker prompt receipt mismatched the expected task context for {}: {}",
worker.cwd, prompt_preview
),
WorkerPromptTarget::Unknown => format!(
"worker prompt delivery failed before reaching coding agent: {prompt_preview}"
),
@ -291,6 +359,8 @@ impl WorkerRegistry {
prompt_preview: prompt_preview.clone(),
observed_target: observation.target,
observed_cwd: observation.observed_cwd.clone(),
observed_prompt_preview: observation.observed_prompt_preview.clone(),
task_receipt: worker.expected_receipt.clone(),
recovery_armed: false,
}),
);
@ -306,6 +376,8 @@ impl WorkerRegistry {
prompt_preview,
observed_target: observation.target,
observed_cwd: observation.observed_cwd,
observed_prompt_preview: observation.observed_prompt_preview,
task_receipt: worker.expected_receipt.clone(),
recovery_armed: true,
}),
);
@ -374,7 +446,12 @@ impl WorkerRegistry {
Ok(worker.clone())
}
pub fn send_prompt(&self, worker_id: &str, prompt: Option<&str>) -> Result<Worker, String> {
pub fn send_prompt(
&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 worker = inner
.workers
@ -398,6 +475,7 @@ impl WorkerRegistry {
worker.prompt_delivery_attempts += 1;
worker.prompt_in_flight = true;
worker.last_prompt = Some(next_prompt.clone());
worker.expected_receipt = task_receipt;
worker.replay_prompt = None;
worker.last_error = None;
worker.status = WorkerStatus::Running;
@ -528,6 +606,117 @@ impl WorkerRegistry {
Ok(worker.clone())
}
/// Handle startup timeout by emitting typed `worker.startup_no_evidence` event with evidence bundle.
/// Classifier attempts to down-rank the vague bucket into a specific failure classification.
pub fn observe_startup_timeout(
&self,
worker_id: &str,
pane_command: &str,
transport_healthy: bool,
mcp_healthy: bool,
) -> Result<Worker, String> {
let mut inner = self.inner.lock().expect("worker registry lock poisoned");
let worker = inner
.workers
.get_mut(worker_id)
.ok_or_else(|| format!("worker not found: {worker_id}"))?;
let now = now_secs();
let elapsed = now.saturating_sub(worker.created_at);
// Build evidence bundle
let evidence = StartupEvidenceBundle {
last_lifecycle_state: worker.status,
pane_command: pane_command.to_string(),
prompt_sent_at: if worker.prompt_delivery_attempts > 0 {
Some(worker.updated_at)
} else {
None
},
prompt_acceptance_state: worker.status == WorkerStatus::Running
&& !worker.prompt_in_flight,
trust_prompt_detected: worker
.events
.iter()
.any(|e| e.kind == WorkerEventKind::TrustRequired),
transport_healthy,
mcp_healthy,
elapsed_seconds: elapsed,
};
// Classify the failure
let classification = classify_startup_failure(&evidence);
// Emit failure with evidence
worker.last_error = Some(WorkerFailure {
kind: WorkerFailureKind::StartupNoEvidence,
message: format!(
"worker startup stalled after {elapsed}s — classified as {classification:?}"
),
created_at: now,
});
worker.status = WorkerStatus::Failed;
worker.prompt_in_flight = false;
push_event(
worker,
WorkerEventKind::StartupNoEvidence,
WorkerStatus::Failed,
Some(format!(
"startup timeout with evidence: last_state={:?}, trust_detected={}, prompt_accepted={}",
evidence.last_lifecycle_state,
evidence.trust_prompt_detected,
evidence.prompt_acceptance_state
)),
Some(WorkerEventPayload::StartupNoEvidence {
evidence,
classification,
}),
);
Ok(worker.clone())
}
}
/// Classify startup failure based on evidence bundle.
/// Attempts to down-rank the vague `startup-no-evidence` bucket into a specific failure class.
fn classify_startup_failure(evidence: &StartupEvidenceBundle) -> StartupFailureClassification {
// Check for transport death first
if !evidence.transport_healthy {
return StartupFailureClassification::TransportDead;
}
// Check for trust prompt that wasn't resolved
if evidence.trust_prompt_detected
&& evidence.last_lifecycle_state == WorkerStatus::TrustRequired
{
return StartupFailureClassification::TrustRequired;
}
// Check for prompt acceptance timeout
if evidence.prompt_sent_at.is_some()
&& !evidence.prompt_acceptance_state
&& evidence.last_lifecycle_state == WorkerStatus::Running
{
return StartupFailureClassification::PromptAcceptanceTimeout;
}
// Check for misdelivery when prompt was sent but not accepted
if evidence.prompt_sent_at.is_some()
&& !evidence.prompt_acceptance_state
&& evidence.elapsed_seconds > 30
{
return StartupFailureClassification::PromptMisdelivery;
}
// If MCP is unhealthy but transport is fine, worker may have crashed
if !evidence.mcp_healthy && evidence.transport_healthy {
return StartupFailureClassification::WorkerCrashed;
}
// Default to unknown if no stronger classification exists
StartupFailureClassification::Unknown
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@ -548,6 +737,7 @@ fn prompt_misdelivery_is_relevant(worker: &Worker) -> bool {
struct PromptDeliveryObservation {
target: WorkerPromptTarget,
observed_cwd: Option<String>,
observed_prompt_preview: Option<String>,
}
fn push_event(
@ -575,28 +765,28 @@ fn push_event(
/// 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)
/// 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) {
let state_dir = std::path::Path::new(&worker.cwd).join(".claw");
if let Err(_) = std::fs::create_dir_all(&state_dir) {
if std::fs::create_dir_all(&state_dir).is_err() {
return;
}
let state_path = state_dir.join("worker-state.json");
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 snapshot = StateSnapshot {
worker_id: &worker.worker_id,
@ -699,6 +889,7 @@ fn detect_prompt_misdelivery(
lowered: &str,
prompt: Option<&str>,
expected_cwd: &str,
expected_receipt: Option<&WorkerTaskReceipt>,
) -> Option<PromptDeliveryObservation> {
let Some(prompt) = prompt else {
return None;
@ -713,12 +904,30 @@ fn detect_prompt_misdelivery(
return None;
}
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 prompt_visible && !cwd_matches_observed_target(expected_cwd, &observed_cwd) {
return Some(PromptDeliveryObservation {
target: WorkerPromptTarget::WrongTarget,
observed_cwd: Some(observed_cwd),
observed_prompt_preview,
});
}
}
@ -736,6 +945,7 @@ fn detect_prompt_misdelivery(
(shell_error && prompt_visible).then_some(PromptDeliveryObservation {
target: WorkerPromptTarget::Shell,
observed_cwd: None,
observed_prompt_preview,
})
}
@ -748,10 +958,38 @@ fn prompt_preview(prompt: &str) -> String {
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 {
match observation.target {
WorkerPromptTarget::Shell => "shell misdelivery detected",
WorkerPromptTarget::WrongTarget => "prompt landed in wrong target",
WorkerPromptTarget::WrongTask => "prompt receipt mismatched expected task context",
WorkerPromptTarget::Unknown => "prompt delivery failure detected",
}
}
@ -865,7 +1103,7 @@ mod tests {
WorkerFailureKind::TrustGate
);
let send_before_resolve = registry.send_prompt(&worker.worker_id, Some("ship it"));
let send_before_resolve = registry.send_prompt(&worker.worker_id, Some("ship it"), None);
assert!(send_before_resolve
.expect_err("prompt delivery should be gated")
.contains("not ready for prompt delivery"));
@ -905,7 +1143,7 @@ mod tests {
.expect("ready observe should succeed");
let running = registry
.send_prompt(&worker.worker_id, Some("Implement worker handshake"))
.send_prompt(&worker.worker_id, Some("Implement worker handshake"), None)
.expect("prompt send should succeed");
assert_eq!(running.status, WorkerStatus::Running);
assert_eq!(running.prompt_delivery_attempts, 1);
@ -941,6 +1179,8 @@ mod tests {
prompt_preview: "Implement worker handshake".to_string(),
observed_target: WorkerPromptTarget::Shell,
observed_cwd: None,
observed_prompt_preview: None,
task_receipt: None,
recovery_armed: false,
})
);
@ -956,12 +1196,14 @@ mod tests {
prompt_preview: "Implement worker handshake".to_string(),
observed_target: WorkerPromptTarget::Shell,
observed_cwd: None,
observed_prompt_preview: None,
task_receipt: None,
recovery_armed: true,
})
);
let replayed = registry
.send_prompt(&worker.worker_id, None)
.send_prompt(&worker.worker_id, None, None)
.expect("replay send should succeed");
assert_eq!(replayed.status, WorkerStatus::Running);
assert!(replayed.replay_prompt.is_none());
@ -976,7 +1218,11 @@ mod tests {
.observe(&worker.worker_id, "Ready for input\n>")
.expect("ready observe should succeed");
registry
.send_prompt(&worker.worker_id, Some("Run the worker bootstrap tests"))
.send_prompt(
&worker.worker_id,
Some("Run the worker bootstrap tests"),
None,
)
.expect("prompt send should succeed");
let recovered = registry
@ -1007,6 +1253,8 @@ mod tests {
prompt_preview: "Run the worker bootstrap tests".to_string(),
observed_target: WorkerPromptTarget::WrongTarget,
observed_cwd: Some("/tmp/repo-target-b".to_string()),
observed_prompt_preview: None,
task_receipt: None,
recovery_armed: false,
})
);
@ -1049,6 +1297,75 @@ mod tests {
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]
fn restart_and_terminate_reset_or_finish_worker() {
let registry = WorkerRegistry::new();
@ -1057,7 +1374,7 @@ mod tests {
.observe(&worker.worker_id, "Ready for input\n>")
.expect("ready observe should succeed");
registry
.send_prompt(&worker.worker_id, Some("Run tests"))
.send_prompt(&worker.worker_id, Some("Run tests"), None)
.expect("prompt send should succeed");
let restarted = registry
@ -1086,7 +1403,7 @@ mod tests {
.observe(&worker.worker_id, "Ready for input\n>")
.expect("ready observe should succeed");
registry
.send_prompt(&worker.worker_id, Some("Run tests"))
.send_prompt(&worker.worker_id, Some("Run tests"), None)
.expect("prompt send should succeed");
let failed = registry
@ -1163,7 +1480,7 @@ mod tests {
.observe(&worker.worker_id, "Ready for input\n>")
.expect("ready observe should succeed");
registry
.send_prompt(&worker.worker_id, Some("Run tests"))
.send_prompt(&worker.worker_id, Some("Run tests"), None)
.expect("prompt send should succeed");
let finished = registry
@ -1177,4 +1494,215 @@ mod tests {
.iter()
.any(|event| event.kind == WorkerEventKind::Finished));
}
#[test]
fn startup_timeout_emits_evidence_bundle_with_classification() {
let registry = WorkerRegistry::new();
let worker = registry.create("/tmp/repo-timeout", &[], true);
// Simulate startup timeout with transport dead
let timed_out = registry
.observe_startup_timeout(&worker.worker_id, "cargo test", false, true)
.expect("startup timeout observe should succeed");
assert_eq!(timed_out.status, WorkerStatus::Failed);
let error = timed_out
.last_error
.expect("startup timeout error should exist");
assert_eq!(error.kind, WorkerFailureKind::StartupNoEvidence);
// Check for "TransportDead" (the Debug representation of the enum variant)
assert!(
error.message.contains("TransportDead"),
"expected TransportDead in: {}",
error.message
);
let event = timed_out
.events
.iter()
.find(|e| e.kind == WorkerEventKind::StartupNoEvidence)
.expect("startup no evidence event should exist");
match event.payload.as_ref() {
Some(WorkerEventPayload::StartupNoEvidence {
evidence,
classification,
}) => {
assert_eq!(
evidence.last_lifecycle_state,
WorkerStatus::Spawning,
"last state should be spawning"
);
assert_eq!(evidence.pane_command, "cargo test");
assert!(!evidence.transport_healthy);
assert!(evidence.mcp_healthy);
assert_eq!(*classification, StartupFailureClassification::TransportDead);
}
_ => panic!(
"expected StartupNoEvidence payload, got {:?}",
event.payload
),
}
}
#[test]
fn startup_timeout_classifies_trust_required_when_prompt_blocked() {
let registry = WorkerRegistry::new();
let worker = registry.create("/tmp/repo-trust", &[], false);
// Simulate trust prompt detected but not resolved
registry
.observe(
&worker.worker_id,
"Do you trust the files in this folder?\n1. Yes, proceed\n2. No",
)
.expect("trust observe should succeed");
// Now simulate startup timeout
let timed_out = registry
.observe_startup_timeout(&worker.worker_id, "claw prompt", true, true)
.expect("startup timeout observe should succeed");
let event = timed_out
.events
.iter()
.find(|e| e.kind == WorkerEventKind::StartupNoEvidence)
.expect("startup no evidence event should exist");
match event.payload.as_ref() {
Some(WorkerEventPayload::StartupNoEvidence { classification, .. }) => {
assert_eq!(
*classification,
StartupFailureClassification::TrustRequired,
"should classify as trust_required when trust prompt detected"
);
}
_ => panic!("expected StartupNoEvidence payload"),
}
}
#[test]
fn startup_timeout_classifies_prompt_acceptance_timeout() {
let registry = WorkerRegistry::new();
let worker = registry.create("/tmp/repo-accept", &[], true);
// Get worker to ReadyForPrompt
registry
.observe(&worker.worker_id, "Ready for your input\n>")
.expect("ready observe should succeed");
// Send prompt but don't get acceptance
registry
.send_prompt(&worker.worker_id, Some("Run tests"), None)
.expect("prompt send should succeed");
// Simulate startup timeout while prompt is still in flight
let timed_out = registry
.observe_startup_timeout(&worker.worker_id, "claw prompt", true, true)
.expect("startup timeout observe should succeed");
let event = timed_out
.events
.iter()
.find(|e| e.kind == WorkerEventKind::StartupNoEvidence)
.expect("startup no evidence event should exist");
match event.payload.as_ref() {
Some(WorkerEventPayload::StartupNoEvidence {
evidence,
classification,
}) => {
assert!(
evidence.prompt_sent_at.is_some(),
"should have prompt_sent_at"
);
assert!(!evidence.prompt_acceptance_state, "prompt not yet accepted");
assert_eq!(
*classification,
StartupFailureClassification::PromptAcceptanceTimeout
);
}
_ => panic!("expected StartupNoEvidence payload"),
}
}
#[test]
fn startup_evidence_bundle_serializes_correctly() {
let bundle = StartupEvidenceBundle {
last_lifecycle_state: WorkerStatus::Running,
pane_command: "test command".to_string(),
prompt_sent_at: Some(1_234_567_890),
prompt_acceptance_state: false,
trust_prompt_detected: true,
transport_healthy: true,
mcp_healthy: false,
elapsed_seconds: 60,
};
let json = serde_json::to_string(&bundle).expect("should serialize");
assert!(json.contains("\"last_lifecycle_state\""));
assert!(json.contains("\"pane_command\""));
assert!(json.contains("\"prompt_sent_at\":1234567890"));
assert!(json.contains("\"trust_prompt_detected\":true"));
assert!(json.contains("\"transport_healthy\":true"));
assert!(json.contains("\"mcp_healthy\":false"));
let deserialized: StartupEvidenceBundle =
serde_json::from_str(&json).expect("should deserialize");
assert_eq!(deserialized.last_lifecycle_state, WorkerStatus::Running);
assert_eq!(deserialized.prompt_sent_at, Some(1_234_567_890));
}
#[test]
fn classify_startup_failure_detects_transport_dead() {
let evidence = StartupEvidenceBundle {
last_lifecycle_state: WorkerStatus::Spawning,
pane_command: "test".to_string(),
prompt_sent_at: None,
prompt_acceptance_state: false,
trust_prompt_detected: false,
transport_healthy: false,
mcp_healthy: true,
elapsed_seconds: 30,
};
let classification = classify_startup_failure(&evidence);
assert_eq!(classification, StartupFailureClassification::TransportDead);
}
#[test]
fn classify_startup_failure_defaults_to_unknown() {
let evidence = StartupEvidenceBundle {
last_lifecycle_state: WorkerStatus::Spawning,
pane_command: "test".to_string(),
prompt_sent_at: None,
prompt_acceptance_state: false,
trust_prompt_detected: false,
transport_healthy: true,
mcp_healthy: true,
elapsed_seconds: 10,
};
let classification = classify_startup_failure(&evidence);
assert_eq!(classification, StartupFailureClassification::Unknown);
}
#[test]
fn classify_startup_failure_detects_worker_crashed() {
// Worker crashed scenario: transport healthy but MCP unhealthy
// Don't have prompt in flight (no prompt_sent_at) to avoid matching PromptAcceptanceTimeout
let evidence = StartupEvidenceBundle {
last_lifecycle_state: WorkerStatus::Spawning,
pane_command: "test".to_string(),
prompt_sent_at: None, // No prompt sent yet
prompt_acceptance_state: false,
trust_prompt_detected: false,
transport_healthy: true,
mcp_healthy: false, // MCP unhealthy but transport healthy suggests crash
elapsed_seconds: 45,
};
let classification = classify_startup_failure(&evidence);
assert_eq!(classification, StartupFailureClassification::WorkerCrashed);
}
}

View File

@ -304,7 +304,7 @@ fn worker_provider_failure_flows_through_recovery_to_policy() {
.observe(&worker.worker_id, "Ready for your input\n>")
.expect("ready observe should succeed");
registry
.send_prompt(&worker.worker_id, Some("Run analysis"))
.send_prompt(&worker.worker_id, Some("Run analysis"), None)
.expect("prompt send should succeed");
// Session completes with provider failure (finish="unknown", tokens=0)

View File

@ -14,14 +14,13 @@ fn main() {
None
}
})
.map(|s| s.trim().to_string())
.unwrap_or_else(|| "unknown".to_string());
.map_or_else(|| "unknown".to_string(), |s| s.trim().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
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.
// Intentionally ignoring time component to keep output deterministic within a day.
@ -48,8 +47,7 @@ fn main() {
None
}
})
.map(|s| s.trim().to_string())
.unwrap_or_else(|| "unknown".to_string())
.map_or_else(|| "unknown".to_string(), |s| s.trim().to_string())
});
println!("cargo:rustc-env=BUILD_DATE={build_date}");

View File

@ -9,7 +9,7 @@ const STARTER_CLAW_JSON: &str = concat!(
"}\n",
);
const GITIGNORE_COMMENT: &str = "# Claw Code local artifacts";
const GITIGNORE_ENTRIES: [&str; 2] = [".claw/settings.local.json", ".claw/sessions/"];
const GITIGNORE_ENTRIES: [&str; 3] = [".claw/settings.local.json", ".claw/sessions/", ".clawhip/"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum InitStatus {
@ -375,6 +375,7 @@ mod tests {
let gitignore = fs::read_to_string(root.join(".gitignore")).expect("read gitignore");
assert!(gitignore.contains(".claw/settings.local.json"));
assert!(gitignore.contains(".claw/sessions/"));
assert!(gitignore.contains(".clawhip/"));
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("cargo clippy --workspace --all-targets -- -D warnings"));
@ -407,6 +408,7 @@ mod tests {
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/sessions/").count(), 1);
assert_eq!(gitignore.matches(".clawhip/").count(), 1);
fs::remove_dir_all(root).expect("cleanup temp dir");
}

File diff suppressed because it is too large Load Diff

View File

@ -639,10 +639,16 @@ fn apply_code_block_background(line: &str) -> String {
/// fence markers of equal or greater length are wrapped with a longer fence.
///
/// 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
/// upgrades the outer fence to use enough backticks (or tildes) that the inner
/// 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 {
// A fence line is either "labeled" (has an info string ⇒ always an opener)
// or "bare" (no info string ⇒ could be opener or closer).

View File

@ -266,7 +266,7 @@ fn command_in(cwd: &Path) -> Command {
fn write_session(root: &Path, label: &str) -> PathBuf {
let session_path = root.join(format!("{label}.jsonl"));
let mut session = Session::new();
let mut session = Session::new().with_workspace_root(root.to_path_buf());
session
.push_user_text(format!("session fixture for {label}"))
.expect("session write should succeed");

View File

@ -4,6 +4,7 @@ use std::process::{Command, Output};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use runtime::Session;
use serde_json::Value;
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
@ -45,6 +46,24 @@ fn status_and_sandbox_emit_json_when_requested() {
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]
fn inventory_commands_emit_structured_json_when_requested() {
let root = unique_temp_dir("inventory-json");
@ -173,13 +192,15 @@ fn dump_manifests_and_init_emit_json_when_requested() {
fs::create_dir_all(&root).expect("temp dir should exist");
let upstream = write_upstream_fixture(&root);
let manifests = assert_json_command_with_env(
let manifests = assert_json_command(
&root,
&["--output-format", "json", "dump-manifests"],
&[(
"CLAUDE_CODE_UPSTREAM",
&[
"--output-format",
"json",
"dump-manifests",
"--manifests-dir",
upstream.to_str().expect("utf8 upstream"),
)],
],
);
assert_eq!(manifests["kind"], "dump-manifests");
assert_eq!(manifests["commands"], 1);
@ -206,7 +227,7 @@ fn doctor_and_resume_status_emit_json_when_requested() {
assert!(summary["failures"].as_u64().is_some());
let checks = doctor["checks"].as_array().expect("doctor checks");
assert_eq!(checks.len(), 5);
assert_eq!(checks.len(), 6);
let check_names = checks
.iter()
.map(|check| {
@ -218,7 +239,27 @@ fn doctor_and_resume_status_emit_json_when_requested() {
.collect::<Vec<_>>();
assert_eq!(
check_names,
vec!["auth", "config", "workspace", "sandbox", "system"]
vec![
"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
@ -236,12 +277,7 @@ fn doctor_and_resume_status_emit_json_when_requested() {
assert!(sandbox["enabled"].is_boolean());
assert!(sandbox["fallback_reason"].is_null() || sandbox["fallback_reason"].is_string());
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 session_path = write_session_fixture(&root, "resume-json", Some("hello"));
let resumed = assert_json_command(
&root,
&[
@ -268,12 +304,7 @@ fn resumed_inventory_commands_emit_structured_json_when_requested() {
fs::create_dir_all(&config_home).expect("config home should exist");
fs::create_dir_all(&home).expect("home should exist");
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 session_path = write_session_fixture(&root, "resume-inventory-json", Some("inventory"));
let mcp = assert_json_command_with_env(
&root,
@ -324,12 +355,7 @@ fn resumed_version_and_init_emit_structured_json_when_requested() {
let root = unique_temp_dir("resume-version-init-json");
fs::create_dir_all(&root).expect("temp dir should exist");
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 session_path = write_session_fixture(&root, "resume-version-init-json", None);
let version = assert_json_command(
&root,
@ -405,6 +431,24 @@ fn write_upstream_fixture(root: &Path) -> PathBuf {
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) {
fs::create_dir_all(root).expect("agent root should exist");
fs::write(

View File

@ -20,7 +20,7 @@ fn resumed_binary_accepts_slash_commands_with_arguments() {
let session_path = temp_dir.join("session.jsonl");
let export_path = temp_dir.join("notes.txt");
let mut session = Session::new();
let mut session = workspace_session(&temp_dir);
session
.push_user_text("ship the slash command harness")
.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");
let session_path = project_dir.join("session.jsonl");
Session::new()
workspace_session(&project_dir)
.with_persistence_path(&session_path)
.save_to_path(&session_path)
.expect("session should persist");
@ -180,13 +180,11 @@ fn resume_latest_restores_the_most_recent_managed_session() {
// given
let temp_dir = unique_temp_dir("resume-latest");
let project_dir = temp_dir.join("project");
let sessions_dir = project_dir.join(".claw").join("sessions");
fs::create_dir_all(&sessions_dir).expect("sessions dir should exist");
let store = runtime::SessionStore::from_cwd(&project_dir).expect("session store should build");
let older_path = store.create_handle("session-older").path;
let newer_path = store.create_handle("session-newer").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);
let mut older = workspace_session(&project_dir).with_persistence_path(&older_path);
older
.push_user_text("older session")
.expect("older session write should succeed");
@ -194,7 +192,7 @@ fn resume_latest_restores_the_most_recent_managed_session() {
.save_to_path(&older_path)
.expect("older session should persist");
let mut newer = Session::new().with_persistence_path(&newer_path);
let mut newer = workspace_session(&project_dir).with_persistence_path(&newer_path);
newer
.push_user_text("newer session")
.expect("newer session write should succeed");
@ -229,7 +227,7 @@ fn resumed_status_command_emits_structured_json_when_requested() {
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
let session_path = temp_dir.join("session.jsonl");
let mut session = Session::new();
let mut session = workspace_session(&temp_dir);
session
.push_user_text("resume status json fixture")
.expect("session write should succeed");
@ -276,6 +274,47 @@ fn resumed_status_command_emits_structured_json_when_requested() {
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]
fn resumed_sandbox_command_emits_structured_json_when_requested() {
// given
@ -283,7 +322,7 @@ fn resumed_sandbox_command_emits_structured_json_when_requested() {
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
let session_path = temp_dir.join("session.jsonl");
Session::new()
workspace_session(&temp_dir)
.save_to_path(&session_path)
.expect("session should persist");
@ -324,7 +363,7 @@ 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");
Session::new()
workspace_session(&temp_dir)
.save_to_path(&session_path)
.expect("session should persist");
@ -357,7 +396,7 @@ 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 = Session::new();
let mut session = workspace_session(&temp_dir);
session
.push_user_text("export json fixture")
.expect("write ok");
@ -391,7 +430,7 @@ 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");
Session::new()
workspace_session(&temp_dir)
.save_to_path(&session_path)
.expect("persist ok");
@ -424,7 +463,7 @@ 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 = Session::new();
let mut session = workspace_session(&temp_dir);
session
.push_user_text("restored json fixture")
.expect("write ok");
@ -458,7 +497,7 @@ 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");
Session::new()
workspace_session(&temp_dir)
.save_to_path(&session_path)
.expect("persist ok");
@ -492,6 +531,10 @@ fn run_claw(current_dir: &Path, args: &[&str]) -> Output {
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 {
let mut command = Command::new(env!("CARGO_BIN_EXE_claw"));
command.current_dir(current_dir).args(args);

File diff suppressed because it is too large Load Diff