mirror of
https://github.com/instructkr/claw-code.git
synced 2026-06-17 16:46:14 -04:00
Compare commits
106 Commits
a7b1fef176
...
feat/jobdo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f079b7b616 | ||
|
|
93da4f14ab | ||
|
|
d305178591 | ||
|
|
0cbff5dc76 | ||
|
|
dd73962d0b | ||
|
|
027efb2f9f | ||
|
|
866f030713 | ||
|
|
d2a83415dc | ||
|
|
8122029eba | ||
|
|
d284ef774e | ||
|
|
7370546c1c | ||
|
|
b56841c5f4 | ||
|
|
debbcbe7fb | ||
|
|
bb76ec9730 | ||
|
|
2bf2a11943 | ||
|
|
d1608aede4 | ||
|
|
b81e6422b4 | ||
|
|
78592221ec | ||
|
|
3848ea64e3 | ||
|
|
b9331ae61b | ||
|
|
f2d653896d | ||
|
|
ad02761918 | ||
|
|
ca09b6b374 | ||
|
|
43eac4d94b | ||
|
|
8b25daf915 | ||
|
|
a049bd29b1 | ||
|
|
b2366d113a | ||
|
|
16244cec34 | ||
|
|
21b2773233 | ||
|
|
91c79baf20 | ||
|
|
a436f9e2d6 | ||
|
|
71e77290b9 | ||
|
|
6580903d20 | ||
|
|
7447232688 | ||
|
|
6a16f0824d | ||
|
|
eabd257968 | ||
|
|
d63d58f3d0 | ||
|
|
63a0d30f57 | ||
|
|
0e263bee42 | ||
|
|
7a172a2534 | ||
|
|
3ab920ac30 | ||
|
|
8db8e4902b | ||
|
|
b7539e679e | ||
|
|
7f76e6bbd6 | ||
|
|
bab66bb226 | ||
|
|
d0de86e8bc | ||
|
|
478ba55063 | ||
|
|
64b29f16d5 | ||
|
|
9882f07e7d | ||
|
|
82bd8bbf77 | ||
|
|
d6003be373 | ||
|
|
586a92ba79 | ||
|
|
2eb6e0c1ee | ||
|
|
70a0f0cf44 | ||
|
|
e58c1947c1 | ||
|
|
1743e600e1 | ||
|
|
a48575fd83 | ||
|
|
688295ea6c | ||
|
|
9deaa29710 | ||
|
|
d05c8686b8 | ||
|
|
00d0eb61d4 | ||
|
|
8d8e2c3afd | ||
|
|
d037f9faa8 | ||
|
|
330dc28fc2 | ||
|
|
cec8d17ca8 | ||
|
|
4cb1db9faa | ||
|
|
5e65b33042 | ||
|
|
87b982ece5 | ||
|
|
f65d15fb2f | ||
|
|
3e4e1585b5 | ||
|
|
110d568bcf | ||
|
|
866ae7562c | ||
|
|
6376694669 | ||
|
|
1d5748f71f | ||
|
|
77fb62a9f1 | ||
|
|
21909da0b5 | ||
|
|
ac45bbec15 | ||
|
|
64e058f720 | ||
|
|
e874bc6a44 | ||
|
|
6a957560bd | ||
|
|
42bb6cdba6 | ||
|
|
f91d156f85 | ||
|
|
6b4bb4ac26 | ||
|
|
e75d67dfd3 | ||
|
|
2e34949507 | ||
|
|
8f53524bd3 | ||
|
|
b5e30e2975 | ||
|
|
dbc2824a3e | ||
|
|
f309ff8642 | ||
|
|
3b806702e7 | ||
|
|
26b89e583f | ||
|
|
17e21bc4ad | ||
|
|
4f83a81cf6 | ||
|
|
1d83e67802 | ||
|
|
763437a0b3 | ||
|
|
491386f0a5 | ||
|
|
5c85e5ad12 | ||
|
|
b825713db3 | ||
|
|
06d1b8ac87 | ||
|
|
4f84607ad6 | ||
|
|
8eb93e906c | ||
|
|
264fdc214e | ||
|
|
a4921cb262 | ||
|
|
d40929cada | ||
|
|
2d5f836988 | ||
|
|
4e199ec52a |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -8,4 +8,5 @@ archive/
|
||||
# Claw Code local artifacts
|
||||
.claw/settings.local.json
|
||||
.claw/sessions/
|
||||
.clawhip/
|
||||
status-help.txt
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
4606
ROADMAP.md
4606
ROADMAP.md
File diff suppressed because it is too large
Load Diff
236
docs/MODEL_COMPATIBILITY.md
Normal file
236
docs/MODEL_COMPATIBILITY.md
Normal 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
356
prd.json
Normal 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
133
progress.txt
Normal 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
|
||||
@@ -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}
|
||||
|
||||
264
rust/Cargo.lock
generated
264
rust/Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -135,6 +135,7 @@ Top-level commands:
|
||||
version
|
||||
status
|
||||
sandbox
|
||||
acp [serve]
|
||||
dump-manifests
|
||||
bootstrap-plan
|
||||
agents
|
||||
@@ -144,6 +145,8 @@ Top-level commands:
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
329
rust/crates/api/benches/request_building.rs
Normal file
329
rust/crates/api/benches/request_building.rs
Normal 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);
|
||||
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -885,6 +885,7 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
|
||||
request_id,
|
||||
body,
|
||||
retryable,
|
||||
suggested_action: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -909,6 +910,7 @@ fn enrich_bearer_auth_error(error: ApiError, auth: &AuthSource) -> ApiError {
|
||||
request_id,
|
||||
body,
|
||||
retryable,
|
||||
suggested_action,
|
||||
} = error
|
||||
else {
|
||||
return error;
|
||||
@@ -921,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 {
|
||||
@@ -931,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-") {
|
||||
@@ -941,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
|
||||
@@ -955,6 +960,7 @@ fn enrich_bearer_auth_error(error: ApiError, auth: &AuthSource) -> ApiError {
|
||||
request_id,
|
||||
body,
|
||||
retryable,
|
||||
suggested_action,
|
||||
};
|
||||
}
|
||||
let enriched_message = match message {
|
||||
@@ -968,6 +974,7 @@ fn enrich_bearer_auth_error(error: ApiError, auth: &AuthSource) -> ApiError {
|
||||
request_id,
|
||||
body,
|
||||
retryable,
|
||||
suggested_action,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1555,6 +1562,7 @@ mod tests {
|
||||
request_id: Some("req_varleg_001".to_string()),
|
||||
body: String::new(),
|
||||
retryable: false,
|
||||
suggested_action: None,
|
||||
};
|
||||
|
||||
// when
|
||||
@@ -1595,6 +1603,7 @@ mod tests {
|
||||
request_id: None,
|
||||
body: String::new(),
|
||||
retryable: true,
|
||||
suggested_action: None,
|
||||
};
|
||||
|
||||
// when
|
||||
@@ -1623,6 +1632,7 @@ mod tests {
|
||||
request_id: None,
|
||||
body: String::new(),
|
||||
retryable: false,
|
||||
suggested_action: None,
|
||||
};
|
||||
|
||||
// when
|
||||
@@ -1650,6 +1660,7 @@ mod tests {
|
||||
request_id: None,
|
||||
body: String::new(),
|
||||
retryable: false,
|
||||
suggested_action: None,
|
||||
};
|
||||
|
||||
// when
|
||||
@@ -1674,6 +1685,7 @@ mod tests {
|
||||
request_id: None,
|
||||
body: String::new(),
|
||||
retryable: false,
|
||||
suggested_action: None,
|
||||
};
|
||||
|
||||
// when
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -267,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,
|
||||
}
|
||||
}
|
||||
@@ -554,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);
|
||||
@@ -694,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
|
||||
|
||||
@@ -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,6 +66,7 @@ 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -752,7 +772,12 @@ struct ErrorBody {
|
||||
/// 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.
|
||||
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() {
|
||||
@@ -994,15 +1080,36 @@ 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
|
||||
@@ -1186,6 +1293,7 @@ fn parse_sse_frame(
|
||||
request_id: None,
|
||||
body: payload.clone(),
|
||||
retryable: false,
|
||||
suggested_action: suggested_action_for_status(status),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1243,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
|
||||
@@ -1254,6 +1364,7 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
|
||||
request_id,
|
||||
body,
|
||||
retryable,
|
||||
suggested_action,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1261,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",
|
||||
@@ -1794,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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4121,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,
|
||||
};
|
||||
@@ -4149,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),
|
||||
@@ -4884,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");
|
||||
@@ -5181,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");
|
||||
|
||||
@@ -2294,6 +2294,12 @@ fn env_lock() -> &'static std::sync::Mutex<()> {
|
||||
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)
|
||||
@@ -2302,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");
|
||||
@@ -2485,7 +2503,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn load_plugin_from_directory_validates_required_fields() {
|
||||
let _guard = env_lock().lock().expect("env lock");
|
||||
let _guard = env_guard();
|
||||
let root = temp_dir("manifest-required");
|
||||
write_file(
|
||||
root.join(MANIFEST_FILE_NAME).as_path(),
|
||||
@@ -2500,7 +2518,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn load_plugin_from_directory_reads_root_manifest_and_validates_entries() {
|
||||
let _guard = env_lock().lock().expect("env lock");
|
||||
let _guard = env_guard();
|
||||
let root = temp_dir("manifest-root");
|
||||
write_loader_plugin(&root);
|
||||
|
||||
@@ -2530,7 +2548,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn load_plugin_from_directory_supports_packaged_manifest_path() {
|
||||
let _guard = env_lock().lock().expect("env lock");
|
||||
let _guard = env_guard();
|
||||
let root = temp_dir("manifest-packaged");
|
||||
write_external_plugin(&root, "packaged-demo", "1.0.0");
|
||||
|
||||
@@ -2544,7 +2562,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn load_plugin_from_directory_defaults_optional_fields() {
|
||||
let _guard = env_lock().lock().expect("env lock");
|
||||
let _guard = env_guard();
|
||||
let root = temp_dir("manifest-defaults");
|
||||
write_file(
|
||||
root.join(MANIFEST_FILE_NAME).as_path(),
|
||||
@@ -2566,7 +2584,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn load_plugin_from_directory_rejects_duplicate_permissions_and_commands() {
|
||||
let _guard = env_lock().lock().expect("env lock");
|
||||
let _guard = env_guard();
|
||||
let root = temp_dir("manifest-duplicates");
|
||||
write_file(
|
||||
root.join("commands").join("sync.sh").as_path(),
|
||||
@@ -2862,7 +2880,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn discovers_builtin_and_bundled_plugins() {
|
||||
let _guard = env_lock().lock().expect("env lock");
|
||||
let _guard = env_guard();
|
||||
let manager = PluginManager::new(PluginManagerConfig::new(temp_dir("discover")));
|
||||
let plugins = manager.list_plugins().expect("plugins should list");
|
||||
assert!(plugins
|
||||
@@ -2875,7 +2893,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn installs_enables_updates_and_uninstalls_external_plugins() {
|
||||
let _guard = env_lock().lock().expect("env lock");
|
||||
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");
|
||||
@@ -2924,7 +2942,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn auto_installs_bundled_plugins_into_the_registry() {
|
||||
let _guard = env_lock().lock().expect("env lock");
|
||||
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);
|
||||
@@ -2956,7 +2974,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn default_bundled_root_loads_repo_bundles_as_installed_plugins() {
|
||||
let _guard = env_lock().lock().expect("env lock");
|
||||
let _guard = env_guard();
|
||||
let config_home = temp_dir("default-bundled-home");
|
||||
let manager = PluginManager::new(PluginManagerConfig::new(&config_home));
|
||||
|
||||
@@ -2975,7 +2993,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn bundled_sync_prunes_removed_bundled_registry_entries() {
|
||||
let _guard = env_lock().lock().expect("env lock");
|
||||
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
|
||||
@@ -3039,7 +3057,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn installed_plugin_discovery_keeps_registry_entries_outside_install_root() {
|
||||
let _guard = env_lock().lock().expect("env lock");
|
||||
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");
|
||||
@@ -3094,7 +3112,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn installed_plugin_discovery_prunes_stale_registry_entries() {
|
||||
let _guard = env_lock().lock().expect("env lock");
|
||||
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");
|
||||
@@ -3140,7 +3158,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn persists_bundled_plugin_enable_state_across_reloads() {
|
||||
let _guard = env_lock().lock().expect("env lock");
|
||||
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);
|
||||
@@ -3174,7 +3192,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn persists_bundled_plugin_disable_state_across_reloads() {
|
||||
let _guard = env_lock().lock().expect("env lock");
|
||||
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);
|
||||
@@ -3208,7 +3226,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn validates_plugin_source_before_install() {
|
||||
let _guard = env_lock().lock().expect("env lock");
|
||||
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");
|
||||
@@ -3223,7 +3241,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn plugin_registry_tracks_enabled_state_and_lookup() {
|
||||
let _guard = env_lock().lock().expect("env lock");
|
||||
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");
|
||||
@@ -3251,7 +3269,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn plugin_registry_report_collects_load_failures_without_dropping_valid_plugins() {
|
||||
let _guard = env_lock().lock().expect("env lock");
|
||||
let _guard = env_guard();
|
||||
// given
|
||||
let config_home = temp_dir("report-home");
|
||||
let external_root = temp_dir("report-external");
|
||||
@@ -3296,7 +3314,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn installed_plugin_registry_report_collects_load_failures_from_install_root() {
|
||||
let _guard = env_lock().lock().expect("env lock");
|
||||
let _guard = env_guard();
|
||||
// given
|
||||
let config_home = temp_dir("installed-report-home");
|
||||
let bundled_root = temp_dir("installed-report-bundled");
|
||||
@@ -3327,7 +3345,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn rejects_plugin_sources_with_missing_hook_paths() {
|
||||
let _guard = env_lock().lock().expect("env lock");
|
||||
let _guard = env_guard();
|
||||
// given
|
||||
let config_home = temp_dir("broken-home");
|
||||
let source_root = temp_dir("broken-source");
|
||||
@@ -3355,7 +3373,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn rejects_plugin_sources_with_missing_failure_hook_paths() {
|
||||
let _guard = env_lock().lock().expect("env lock");
|
||||
let _guard = env_guard();
|
||||
// given
|
||||
let config_home = temp_dir("broken-failure-home");
|
||||
let source_root = temp_dir("broken-failure-source");
|
||||
@@ -3383,7 +3401,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn plugin_registry_runs_initialize_and_shutdown_for_enabled_plugins() {
|
||||
let _guard = env_lock().lock().expect("env lock");
|
||||
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");
|
||||
@@ -3407,7 +3425,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn aggregates_and_executes_plugin_tools() {
|
||||
let _guard = env_lock().lock().expect("env lock");
|
||||
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");
|
||||
@@ -3436,7 +3454,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn list_installed_plugins_scans_install_root_without_registry_entries() {
|
||||
let _guard = env_lock().lock().expect("env lock");
|
||||
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");
|
||||
@@ -3468,7 +3486,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn list_installed_plugins_scans_packaged_manifests_in_install_root() {
|
||||
let _guard = env_lock().lock().expect("env lock");
|
||||
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");
|
||||
@@ -3502,7 +3520,7 @@ mod tests {
|
||||
/// host `~/.claw/plugins/` from bleeding into test runs.
|
||||
#[test]
|
||||
fn claw_config_home_isolation_prevents_host_plugin_leakage() {
|
||||
let _guard = env_lock().lock().expect("env lock");
|
||||
let _guard = env_guard();
|
||||
|
||||
// Create a temp directory to act as our isolated CLAW_CONFIG_HOME
|
||||
let config_home = temp_dir("isolated-home");
|
||||
@@ -3556,7 +3574,7 @@ mod tests {
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
|
||||
let _guard = env_lock().lock().expect("env lock");
|
||||
let _guard = env_guard();
|
||||
|
||||
// Shared base directory for all threads
|
||||
let base_dir = temp_dir("parallel-base");
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#![allow(clippy::similar_names)]
|
||||
#![allow(clippy::similar_names, clippy::cast_possible_truncation)]
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -73,6 +73,316 @@ pub enum LaneFailureClass {
|
||||
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 struct LaneEventBlocker {
|
||||
#[serde(rename = "failureClass")]
|
||||
@@ -106,9 +416,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,
|
||||
@@ -122,6 +436,7 @@ impl LaneEvent {
|
||||
failure_class: None,
|
||||
detail: None,
|
||||
data: None,
|
||||
metadata: LaneEventMetadata::new(0, EventProvenance::LiveLane),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,8 +569,10 @@ 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, EventProvenance, LaneCommitProvenance, LaneEvent, LaneEventBlocker,
|
||||
LaneEventBuilder, LaneEventMetadata, LaneEventName, LaneEventStatus, LaneFailureClass,
|
||||
LaneOwnership, SessionIdentity, WatcherAction,
|
||||
};
|
||||
|
||||
#[test]
|
||||
@@ -420,4 +737,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()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,8 +83,10 @@ 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, EventProvenance, LaneCommitProvenance, LaneEvent, LaneEventBlocker,
|
||||
LaneEventBuilder, LaneEventMetadata, LaneEventName, LaneEventStatus, LaneFailureClass,
|
||||
LaneOwnership, SessionIdentity, WatcherAction,
|
||||
};
|
||||
pub use mcp::{
|
||||
mcp_server_signature, mcp_tool_name, mcp_tool_prefix, normalize_name_for_mcp,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
@@ -1030,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 {
|
||||
@@ -1125,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;
|
||||
@@ -1134,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();
|
||||
|
||||
@@ -144,12 +144,7 @@ impl SessionStore {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -260,6 +255,7 @@ impl SessionStore {
|
||||
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
|
||||
@@ -279,6 +275,7 @@ impl SessionStore {
|
||||
.unwrap_or("unknown")
|
||||
.to_string(),
|
||||
path,
|
||||
updated_at_ms: 0,
|
||||
modified_epoch_millis,
|
||||
message_count: 0,
|
||||
parent_session_id: None,
|
||||
@@ -322,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,
|
||||
@@ -598,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
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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()],
|
||||
|
||||
@@ -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(
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -78,6 +78,9 @@ const INTERNAL_PROGRESS_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(3);
|
||||
const POST_TOOL_STALL_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const PRIMARY_SESSION_EXTENSION: &str = "jsonl";
|
||||
const LEGACY_SESSION_EXTENSION: &str = "json";
|
||||
const OFFICIAL_REPO_URL: &str = "https://github.com/ultraworkers/claw-code";
|
||||
const OFFICIAL_REPO_SLUG: &str = "ultraworkers/claw-code";
|
||||
const DEPRECATED_INSTALL_COMMAND: &str = "cargo install claw-code";
|
||||
const LATEST_SESSION_REFERENCE: &str = "latest";
|
||||
const SESSION_REFERENCE_ALIASES: &[&str] = &[LATEST_SESSION_REFERENCE, "last", "recent"];
|
||||
const CLI_OPTION_SUGGESTIONS: &[&str] = &[
|
||||
@@ -92,6 +95,8 @@ const CLI_OPTION_SUGGESTIONS: &[&str] = &[
|
||||
"--allowedTools",
|
||||
"--allowed-tools",
|
||||
"--resume",
|
||||
"--acp",
|
||||
"-acp",
|
||||
"--print",
|
||||
"--compact",
|
||||
"--base-commit",
|
||||
@@ -177,7 +182,10 @@ fn merge_prompt_with_stdin(prompt: &str, stdin_content: Option<&str>) -> String
|
||||
fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args: Vec<String> = env::args().skip(1).collect();
|
||||
match parse_args(&args)? {
|
||||
CliAction::DumpManifests { output_format } => dump_manifests(output_format)?,
|
||||
CliAction::DumpManifests {
|
||||
output_format,
|
||||
manifests_dir,
|
||||
} => dump_manifests(manifests_dir.as_deref(), output_format)?,
|
||||
CliAction::BootstrapPlan { output_format } => print_bootstrap_plan(output_format)?,
|
||||
CliAction::Agents {
|
||||
args,
|
||||
@@ -242,6 +250,7 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
cli.run_turn_with_output(&effective_prompt, output_format, compact)?;
|
||||
}
|
||||
CliAction::Doctor { output_format } => run_doctor(output_format)?,
|
||||
CliAction::Acp { output_format } => print_acp_status(output_format)?,
|
||||
CliAction::State { output_format } => run_worker_state(output_format)?,
|
||||
CliAction::Init { output_format } => run_init(output_format)?,
|
||||
CliAction::Export {
|
||||
@@ -274,6 +283,7 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
enum CliAction {
|
||||
DumpManifests {
|
||||
output_format: CliOutputFormat,
|
||||
manifests_dir: Option<PathBuf>,
|
||||
},
|
||||
BootstrapPlan {
|
||||
output_format: CliOutputFormat,
|
||||
@@ -330,6 +340,9 @@ enum CliAction {
|
||||
Doctor {
|
||||
output_format: CliOutputFormat,
|
||||
},
|
||||
Acp {
|
||||
output_format: CliOutputFormat,
|
||||
},
|
||||
State {
|
||||
output_format: CliOutputFormat,
|
||||
},
|
||||
@@ -361,6 +374,7 @@ enum LocalHelpTopic {
|
||||
Status,
|
||||
Sandbox,
|
||||
Doctor,
|
||||
Acp,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -540,6 +554,10 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||
rest.push(flag[9..].to_string());
|
||||
index += 1;
|
||||
}
|
||||
"--acp" | "-acp" => {
|
||||
rest.push("acp".to_string());
|
||||
index += 1;
|
||||
}
|
||||
"--allowedTools" | "--allowed-tools" => {
|
||||
let value = args
|
||||
.get(index + 1)
|
||||
@@ -623,7 +641,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||
let permission_mode = permission_mode_override.unwrap_or_else(default_permission_mode);
|
||||
|
||||
match rest[0].as_str() {
|
||||
"dump-manifests" => Ok(CliAction::DumpManifests { output_format }),
|
||||
"dump-manifests" => parse_dump_manifests_args(&rest[1..], output_format),
|
||||
"bootstrap-plan" => Ok(CliAction::BootstrapPlan { output_format }),
|
||||
"agents" => Ok(CliAction::Agents {
|
||||
args: join_optional_args(&rest[1..]),
|
||||
@@ -654,6 +672,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||
}
|
||||
}
|
||||
"system-prompt" => parse_system_prompt_args(&rest[1..], output_format),
|
||||
"acp" => parse_acp_args(&rest[1..], output_format),
|
||||
"login" | "logout" => Err(removed_auth_surface_error(rest[0].as_str())),
|
||||
"init" => Ok(CliAction::Init { output_format }),
|
||||
"export" => parse_export_args(&rest[1..], output_format),
|
||||
@@ -708,6 +727,7 @@ fn parse_local_help_action(rest: &[String]) -> Option<Result<CliAction, String>>
|
||||
"status" => LocalHelpTopic::Status,
|
||||
"sandbox" => LocalHelpTopic::Sandbox,
|
||||
"doctor" => LocalHelpTopic::Doctor,
|
||||
"acp" => LocalHelpTopic::Acp,
|
||||
_ => return None,
|
||||
};
|
||||
Some(Ok(CliAction::HelpTopic(topic)))
|
||||
@@ -778,6 +798,32 @@ fn removed_auth_surface_error(command_name: &str) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
fn parse_acp_args(args: &[String], output_format: CliOutputFormat) -> Result<CliAction, String> {
|
||||
match args {
|
||||
[] => Ok(CliAction::Acp { output_format }),
|
||||
[subcommand] if subcommand == "serve" => Ok(CliAction::Acp { output_format }),
|
||||
_ => Err(String::from(
|
||||
"unsupported ACP invocation. Use `claw acp`, `claw acp serve`, `claw --acp`, or `claw -acp`.",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn try_resolve_bare_skill_prompt(cwd: &Path, trimmed: &str) -> Option<String> {
|
||||
let bare_first_token = trimmed.split_whitespace().next().unwrap_or_default();
|
||||
let looks_like_skill_name = !bare_first_token.is_empty()
|
||||
&& !bare_first_token.starts_with('/')
|
||||
&& bare_first_token
|
||||
.chars()
|
||||
.all(|c| c.is_alphanumeric() || c == '-' || c == '_');
|
||||
if !looks_like_skill_name {
|
||||
return None;
|
||||
}
|
||||
match resolve_skill_invocation(cwd, Some(trimmed)) {
|
||||
Ok(SkillSlashDispatch::Invoke(prompt)) => Some(prompt),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn join_optional_args(args: &[String]) -> Option<String> {
|
||||
let joined = args.join(" ");
|
||||
let trimmed = joined.trim();
|
||||
@@ -1197,6 +1243,39 @@ fn parse_export_args(args: &[String], output_format: CliOutputFormat) -> Result<
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_dump_manifests_args(
|
||||
args: &[String],
|
||||
output_format: CliOutputFormat,
|
||||
) -> Result<CliAction, String> {
|
||||
let mut manifests_dir: Option<PathBuf> = None;
|
||||
let mut index = 0;
|
||||
while index < args.len() {
|
||||
let arg = &args[index];
|
||||
if arg == "--manifests-dir" {
|
||||
let value = args
|
||||
.get(index + 1)
|
||||
.ok_or_else(|| String::from("--manifests-dir requires a path"))?;
|
||||
manifests_dir = Some(PathBuf::from(value));
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if let Some(value) = arg.strip_prefix("--manifests-dir=") {
|
||||
if value.is_empty() {
|
||||
return Err(String::from("--manifests-dir requires a path"));
|
||||
}
|
||||
manifests_dir = Some(PathBuf::from(value));
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
return Err(format!("unknown dump-manifests option: {arg}"));
|
||||
}
|
||||
|
||||
Ok(CliAction::DumpManifests {
|
||||
output_format,
|
||||
manifests_dir,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_resume_args(args: &[String], output_format: CliOutputFormat) -> Result<CliAction, String> {
|
||||
let (session_path, command_tokens): (PathBuf, &[String]) = match args.first() {
|
||||
None => (PathBuf::from(LATEST_SESSION_REFERENCE), &[]),
|
||||
@@ -1424,6 +1503,7 @@ fn render_doctor_report() -> Result<DoctorReport, Box<dyn std::error::Error>> {
|
||||
checks: vec![
|
||||
check_auth_health(),
|
||||
check_config_health(&config_loader, config.as_ref()),
|
||||
check_install_source_health(),
|
||||
check_workspace_health(&context),
|
||||
check_sandbox_health(&context.sandbox_status),
|
||||
check_system_health(&cwd, config.as_ref().ok()),
|
||||
@@ -1711,6 +1791,36 @@ fn check_config_health(
|
||||
}
|
||||
}
|
||||
|
||||
fn check_install_source_health() -> DiagnosticCheck {
|
||||
DiagnosticCheck::new(
|
||||
"Install source",
|
||||
DiagnosticLevel::Ok,
|
||||
format!(
|
||||
"official source of truth is {OFFICIAL_REPO_SLUG}; avoid `{DEPRECATED_INSTALL_COMMAND}`"
|
||||
),
|
||||
)
|
||||
.with_details(vec![
|
||||
format!("Official repo {OFFICIAL_REPO_URL}"),
|
||||
"Recommended path build from this repo or use the upstream binary documented in README.md"
|
||||
.to_string(),
|
||||
format!(
|
||||
"Deprecated crate `{DEPRECATED_INSTALL_COMMAND}` installs a deprecated stub and does not provide the `claw` binary"
|
||||
)
|
||||
.to_string(),
|
||||
])
|
||||
.with_data(Map::from_iter([
|
||||
("official_repo".to_string(), json!(OFFICIAL_REPO_URL)),
|
||||
(
|
||||
"deprecated_install".to_string(),
|
||||
json!(DEPRECATED_INSTALL_COMMAND),
|
||||
),
|
||||
(
|
||||
"recommended_install".to_string(),
|
||||
json!("build from source or follow the upstream binary instructions in README.md"),
|
||||
),
|
||||
]))
|
||||
}
|
||||
|
||||
fn check_workspace_health(context: &StatusContext) -> DiagnosticCheck {
|
||||
let in_repo = context.project_root.is_some();
|
||||
DiagnosticCheck::new(
|
||||
@@ -1899,32 +2009,58 @@ fn looks_like_slash_command_token(token: &str) -> bool {
|
||||
.any(|spec| spec.name == name || spec.aliases.contains(&name))
|
||||
}
|
||||
|
||||
fn dump_manifests(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Error>> {
|
||||
fn dump_manifests(
|
||||
manifests_dir: Option<&Path>,
|
||||
output_format: CliOutputFormat,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let workspace_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
dump_manifests_at_path(&workspace_dir, output_format)
|
||||
dump_manifests_at_path(&workspace_dir, manifests_dir, output_format)
|
||||
}
|
||||
|
||||
const DUMP_MANIFESTS_OVERRIDE_HINT: &str =
|
||||
"Hint: set CLAUDE_CODE_UPSTREAM=/path/to/upstream or pass `claw dump-manifests --manifests-dir /path/to/upstream`.";
|
||||
|
||||
// Internal function for testing that accepts a workspace directory path.
|
||||
fn dump_manifests_at_path(
|
||||
workspace_dir: &std::path::Path,
|
||||
manifests_dir: Option<&Path>,
|
||||
output_format: CliOutputFormat,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Surface the resolved path in the error so users can diagnose missing
|
||||
// manifest files without guessing what path the binary expected.
|
||||
// ROADMAP #45: this path is only correct when running from the build tree;
|
||||
// a proper fix would ship manifests alongside the binary.
|
||||
let resolved = workspace_dir
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| workspace_dir.to_path_buf());
|
||||
let paths = if let Some(dir) = manifests_dir {
|
||||
let resolved = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf());
|
||||
UpstreamPaths::from_repo_root(resolved)
|
||||
} else {
|
||||
// Surface the resolved path in the error so users can diagnose missing
|
||||
// manifest files without guessing what path the binary expected.
|
||||
let resolved = workspace_dir
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| workspace_dir.to_path_buf());
|
||||
UpstreamPaths::from_workspace_dir(&resolved)
|
||||
};
|
||||
|
||||
let paths = UpstreamPaths::from_workspace_dir(&resolved);
|
||||
|
||||
// Pre-check: verify manifest directory exists
|
||||
let manifest_dir = paths.repo_root();
|
||||
if !manifest_dir.exists() {
|
||||
let source_root = paths.repo_root();
|
||||
if !source_root.exists() {
|
||||
return Err(format!(
|
||||
"Manifest files (commands.ts, tools.ts) define CLI commands and tools.\n Expected at: {}\n Run `claw init` to create them or specify --manifests-dir.",
|
||||
manifest_dir.display()
|
||||
"Manifest source directory does not exist.\n looked in: {}\n {DUMP_MANIFESTS_OVERRIDE_HINT}",
|
||||
source_root.display(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
let required_paths = [
|
||||
("src/commands.ts", paths.commands_path()),
|
||||
("src/tools.ts", paths.tools_path()),
|
||||
("src/entrypoints/cli.tsx", paths.cli_path()),
|
||||
];
|
||||
let missing = required_paths
|
||||
.iter()
|
||||
.filter_map(|(label, path)| (!path.is_file()).then_some(*label))
|
||||
.collect::<Vec<_>>();
|
||||
if !missing.is_empty() {
|
||||
return Err(format!(
|
||||
"Manifest source files are missing.\n repo root: {}\n missing: {}\n {DUMP_MANIFESTS_OVERRIDE_HINT}",
|
||||
source_root.display(),
|
||||
missing.join(", "),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
@@ -1950,7 +2086,7 @@ fn dump_manifests_at_path(
|
||||
Ok(())
|
||||
}
|
||||
Err(error) => Err(format!(
|
||||
"failed to extract manifests: {error}\n looked in: {path}",
|
||||
"failed to extract manifests: {error}\n looked in: {path}\n {DUMP_MANIFESTS_OVERRIDE_HINT}",
|
||||
path = paths.repo_root().display()
|
||||
)
|
||||
.into()),
|
||||
@@ -2977,22 +3113,12 @@ fn run_repl(
|
||||
// Bare-word skill dispatch: if the first token of the input
|
||||
// matches a known skill name, invoke it as `/skills <input>`
|
||||
// rather than forwarding raw text to the LLM (ROADMAP #36).
|
||||
let bare_first_token = trimmed.split_whitespace().next().unwrap_or_default();
|
||||
let looks_like_skill_name = !bare_first_token.is_empty()
|
||||
&& !bare_first_token.starts_with('/')
|
||||
&& bare_first_token
|
||||
.chars()
|
||||
.all(|c| c.is_alphanumeric() || c == '-' || c == '_');
|
||||
if looks_like_skill_name {
|
||||
let cwd = std::env::current_dir().unwrap_or_default();
|
||||
if let Ok(SkillSlashDispatch::Invoke(prompt)) =
|
||||
resolve_skill_invocation(&cwd, Some(&trimmed))
|
||||
{
|
||||
editor.push_history(input);
|
||||
cli.record_prompt_history(&trimmed);
|
||||
cli.run_turn(&prompt)?;
|
||||
continue;
|
||||
}
|
||||
let cwd = std::env::current_dir().unwrap_or_default();
|
||||
if let Some(prompt) = try_resolve_bare_skill_prompt(&cwd, &trimmed) {
|
||||
editor.push_history(input);
|
||||
cli.record_prompt_history(&trimmed);
|
||||
cli.run_turn(&prompt)?;
|
||||
continue;
|
||||
}
|
||||
editor.push_history(input);
|
||||
cli.record_prompt_history(&trimmed);
|
||||
@@ -3019,6 +3145,7 @@ struct SessionHandle {
|
||||
struct ManagedSessionSummary {
|
||||
id: String,
|
||||
path: PathBuf,
|
||||
updated_at_ms: u64,
|
||||
modified_epoch_millis: u128,
|
||||
message_count: usize,
|
||||
parent_session_id: Option<String>,
|
||||
@@ -4608,6 +4735,7 @@ fn list_managed_sessions() -> Result<Vec<ManagedSessionSummary>, Box<dyn std::er
|
||||
.map(|session| ManagedSessionSummary {
|
||||
id: session.id,
|
||||
path: session.path,
|
||||
updated_at_ms: session.updated_at_ms,
|
||||
modified_epoch_millis: session.modified_epoch_millis,
|
||||
message_count: session.message_count,
|
||||
parent_session_id: session.parent_session_id,
|
||||
@@ -4623,6 +4751,7 @@ fn latest_managed_session() -> Result<ManagedSessionSummary, Box<dyn std::error:
|
||||
Ok(ManagedSessionSummary {
|
||||
id: session.id,
|
||||
path: session.path,
|
||||
updated_at_ms: session.updated_at_ms,
|
||||
modified_epoch_millis: session.modified_epoch_millis,
|
||||
message_count: session.message_count,
|
||||
parent_session_id: session.parent_session_id,
|
||||
@@ -5069,6 +5198,13 @@ fn render_help_topic(topic: LocalHelpTopic) -> String {
|
||||
Output local-only health report; no provider request or session resume required
|
||||
Related /doctor · claw --resume latest /doctor"
|
||||
.to_string(),
|
||||
LocalHelpTopic::Acp => "ACP / Zed
|
||||
Usage claw acp [serve]
|
||||
Aliases claw --acp · claw -acp
|
||||
Purpose explain the current editor-facing ACP/Zed launch contract without starting the runtime
|
||||
Status discoverability only; `serve` is a status alias and does not launch a daemon yet
|
||||
Related ROADMAP #64a (discoverability) · ROADMAP #76 (real ACP support) · claw --help"
|
||||
.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5076,6 +5212,39 @@ fn print_help_topic(topic: LocalHelpTopic) {
|
||||
println!("{}", render_help_topic(topic));
|
||||
}
|
||||
|
||||
fn print_acp_status(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let message = "ACP/Zed editor integration is not implemented in claw-code yet. `claw acp serve` is only a discoverability alias today; it does not launch a daemon or Zed-specific protocol endpoint. Use the normal terminal surfaces for now and track ROADMAP #76 for real ACP support.";
|
||||
match output_format {
|
||||
CliOutputFormat::Text => {
|
||||
println!(
|
||||
"ACP / Zed\n Status discoverability only\n Launch `claw acp serve` / `claw --acp` / `claw -acp` report status only; no editor daemon is available yet\n Today use `claw prompt`, the REPL, or `claw doctor` for local verification\n Tracking ROADMAP #76\n Message {message}"
|
||||
);
|
||||
}
|
||||
CliOutputFormat::Json => {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"kind": "acp",
|
||||
"status": "discoverability_only",
|
||||
"supported": false,
|
||||
"serve_alias_only": true,
|
||||
"message": message,
|
||||
"launch_command": serde_json::Value::Null,
|
||||
"aliases": ["acp", "--acp", "-acp"],
|
||||
"discoverability_tracking": "ROADMAP #64a",
|
||||
"tracking": "ROADMAP #76",
|
||||
"recommended_workflows": [
|
||||
"claw prompt TEXT",
|
||||
"claw",
|
||||
"claw doctor"
|
||||
],
|
||||
}))?
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_config_report(section: Option<&str>) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let cwd = env::current_dir()?;
|
||||
let loader = ConfigLoader::default_for(&cwd);
|
||||
@@ -5849,6 +6018,93 @@ fn summarize_tool_payload_for_markdown(payload: &str) -> String {
|
||||
truncate_for_summary(&compact, SESSION_MARKDOWN_TOOL_SUMMARY_LIMIT)
|
||||
}
|
||||
|
||||
/// Structured export error envelope (#130).
|
||||
/// Conforms to Phase 2 §4.44 typed-error envelope contract.
|
||||
/// Includes kind/operation/target/errno/hint/retryable for actionable diagnostics.
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
struct ExportError {
|
||||
kind: String,
|
||||
operation: String,
|
||||
target: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
errno: Option<String>,
|
||||
hint: String,
|
||||
retryable: bool,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ExportError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"export failed: {} ({})\n target: {}\n errno: {}\n hint: {}",
|
||||
self.kind,
|
||||
self.operation,
|
||||
self.target,
|
||||
self.errno.as_deref().unwrap_or("unknown"),
|
||||
self.hint
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ExportError {}
|
||||
|
||||
/// Wrap std::io::Error into a structured ExportError per §4.44.
|
||||
fn wrap_export_io_error(path: &Path, op: &str, e: std::io::Error) -> ExportError {
|
||||
use std::io::ErrorKind;
|
||||
let target_display = path.display().to_string();
|
||||
let parent = path
|
||||
.parent()
|
||||
.filter(|p| !p.as_os_str().is_empty())
|
||||
.map(|p| p.display().to_string());
|
||||
let (kind, hint) = match e.kind() {
|
||||
ErrorKind::NotFound => (
|
||||
"filesystem",
|
||||
parent
|
||||
.as_ref()
|
||||
.map(|p| format!("intermediate directory does not exist; try `mkdir -p {p}` first"))
|
||||
.unwrap_or_else(|| {
|
||||
"path is empty or invalid; provide a non-empty file path".to_string()
|
||||
}),
|
||||
),
|
||||
ErrorKind::PermissionDenied => (
|
||||
"permission",
|
||||
format!(
|
||||
"permission denied; check file permissions with `ls -la {}`",
|
||||
parent.as_deref().unwrap_or(".")
|
||||
),
|
||||
),
|
||||
ErrorKind::IsADirectory => (
|
||||
"filesystem",
|
||||
format!(
|
||||
"path `{}` is a directory, not a file; use a file path like `{}/session.md`",
|
||||
target_display, target_display
|
||||
),
|
||||
),
|
||||
ErrorKind::AlreadyExists => (
|
||||
"filesystem",
|
||||
format!("path `{target_display}` already exists; remove it or pick a different name"),
|
||||
),
|
||||
ErrorKind::InvalidInput | ErrorKind::InvalidData => (
|
||||
"invalid_path",
|
||||
format!("path `{target_display}` is invalid; check for empty or malformed input"),
|
||||
),
|
||||
_ => (
|
||||
"filesystem",
|
||||
format!(
|
||||
"unexpected error writing to `{target_display}`; check disk space and path validity"
|
||||
),
|
||||
),
|
||||
};
|
||||
ExportError {
|
||||
kind: kind.to_string(),
|
||||
operation: op.to_string(),
|
||||
target: target_display,
|
||||
errno: Some(format!("{:?}", e.kind())),
|
||||
hint,
|
||||
retryable: matches!(e.kind(), ErrorKind::TimedOut | ErrorKind::Interrupted),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_export(
|
||||
session_reference: &str,
|
||||
output_path: Option<&Path>,
|
||||
@@ -5858,7 +6114,9 @@ fn run_export(
|
||||
let markdown = render_session_markdown(&session, &handle.id, &handle.path);
|
||||
|
||||
if let Some(path) = output_path {
|
||||
fs::write(path, &markdown)?;
|
||||
fs::write(path, &markdown).map_err(|e| {
|
||||
Box::new(wrap_export_io_error(path, "write", e)) as Box<dyn std::error::Error>
|
||||
})?;
|
||||
let report = format!(
|
||||
"Export\n Result wrote markdown transcript\n File {}\n Session {}\n Messages {}",
|
||||
path.display(),
|
||||
@@ -8042,7 +8300,17 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> {
|
||||
out,
|
||||
" Diagnose local auth, config, workspace, and sandbox health"
|
||||
)?;
|
||||
writeln!(out, " claw dump-manifests")?;
|
||||
writeln!(out, " claw acp [serve]")?;
|
||||
writeln!(
|
||||
out,
|
||||
" Show ACP/Zed editor integration status (currently unsupported; aliases: --acp, -acp)"
|
||||
)?;
|
||||
writeln!(out, " Source of truth: {OFFICIAL_REPO_SLUG}")?;
|
||||
writeln!(
|
||||
out,
|
||||
" Warning: do not `{DEPRECATED_INSTALL_COMMAND}` (deprecated stub)"
|
||||
)?;
|
||||
writeln!(out, " claw dump-manifests [--manifests-dir PATH]")?;
|
||||
writeln!(out, " claw bootstrap-plan")?;
|
||||
writeln!(out, " claw agents")?;
|
||||
writeln!(out, " claw mcp")?;
|
||||
@@ -8131,6 +8399,11 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> {
|
||||
writeln!(out, " claw mcp show my-server")?;
|
||||
writeln!(out, " claw /skills")?;
|
||||
writeln!(out, " claw doctor")?;
|
||||
writeln!(out, " source of truth: {OFFICIAL_REPO_URL}")?;
|
||||
writeln!(
|
||||
out,
|
||||
" do not run `{DEPRECATED_INSTALL_COMMAND}` — it installs a deprecated stub"
|
||||
)?;
|
||||
writeln!(out, " claw init")?;
|
||||
writeln!(out, " claw export")?;
|
||||
writeln!(out, " claw export conversation.md")?;
|
||||
@@ -8176,10 +8449,11 @@ mod tests {
|
||||
resolve_repl_model, resolve_session_reference, response_to_events,
|
||||
resume_supported_slash_commands, run_resume_command, short_tool_id,
|
||||
slash_command_completion_candidates_with_sessions, status_context,
|
||||
summarize_tool_payload_for_markdown, validate_no_args, write_mcp_server_fixture, CliAction,
|
||||
CliOutputFormat, CliToolExecutor, GitWorkspaceSummary, InternalPromptProgressEvent,
|
||||
InternalPromptProgressState, LiveCli, LocalHelpTopic, PromptHistoryEntry, SlashCommand,
|
||||
StatusUsage, DEFAULT_MODEL, LATEST_SESSION_REFERENCE, STUB_COMMANDS,
|
||||
summarize_tool_payload_for_markdown, try_resolve_bare_skill_prompt, validate_no_args,
|
||||
write_mcp_server_fixture, CliAction, CliOutputFormat, CliToolExecutor, GitWorkspaceSummary,
|
||||
InternalPromptProgressEvent, InternalPromptProgressState, LiveCli, LocalHelpTopic,
|
||||
PromptHistoryEntry, SlashCommand, StatusUsage, DEFAULT_MODEL, LATEST_SESSION_REFERENCE,
|
||||
STUB_COMMANDS,
|
||||
};
|
||||
use api::{ApiError, MessageResponse, OutputContentBlock, Usage};
|
||||
use plugins::{
|
||||
@@ -8420,6 +8694,16 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn write_skill_fixture(root: &Path, name: &str, description: &str) {
|
||||
let skill_dir = root.join(name);
|
||||
fs::create_dir_all(&skill_dir).expect("skill dir should exist");
|
||||
fs::write(
|
||||
skill_dir.join("SKILL.md"),
|
||||
format!("---\nname: {name}\ndescription: {description}\n---\n\n# {name}\n"),
|
||||
)
|
||||
.expect("skill file should write");
|
||||
}
|
||||
|
||||
fn write_plugin_fixture(root: &Path, name: &str, include_hooks: bool, include_lifecycle: bool) {
|
||||
fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir");
|
||||
if include_hooks {
|
||||
@@ -9046,6 +9330,61 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dump_manifests_subcommand_accepts_explicit_manifest_dir() {
|
||||
assert_eq!(
|
||||
parse_args(&[
|
||||
"dump-manifests".to_string(),
|
||||
"--manifests-dir".to_string(),
|
||||
"/tmp/upstream".to_string(),
|
||||
])
|
||||
.expect("dump-manifests should parse"),
|
||||
CliAction::DumpManifests {
|
||||
output_format: CliOutputFormat::Text,
|
||||
manifests_dir: Some(PathBuf::from("/tmp/upstream")),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
parse_args(&[
|
||||
"dump-manifests".to_string(),
|
||||
"--manifests-dir=/tmp/upstream".to_string()
|
||||
])
|
||||
.expect("inline dump-manifests flag should parse"),
|
||||
CliAction::DumpManifests {
|
||||
output_format: CliOutputFormat::Text,
|
||||
manifests_dir: Some(PathBuf::from("/tmp/upstream")),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_acp_command_surfaces() {
|
||||
assert_eq!(
|
||||
parse_args(&["acp".to_string()]).expect("acp should parse"),
|
||||
CliAction::Acp {
|
||||
output_format: CliOutputFormat::Text,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
parse_args(&["acp".to_string(), "serve".to_string()]).expect("acp serve should parse"),
|
||||
CliAction::Acp {
|
||||
output_format: CliOutputFormat::Text,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
parse_args(&["--acp".to_string()]).expect("--acp should parse"),
|
||||
CliAction::Acp {
|
||||
output_format: CliOutputFormat::Text,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
parse_args(&["-acp".to_string()]).expect("-acp should parse"),
|
||||
CliAction::Acp {
|
||||
output_format: CliOutputFormat::Text,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_command_help_flags_stay_on_the_local_parser_path() {
|
||||
assert_eq!(
|
||||
@@ -9063,6 +9402,10 @@ mod tests {
|
||||
.expect("doctor help should parse"),
|
||||
CliAction::HelpTopic(LocalHelpTopic::Doctor)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_args(&["acp".to_string(), "--help".to_string()]).expect("acp help should parse"),
|
||||
CliAction::HelpTopic(LocalHelpTopic::Acp)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -9713,6 +10056,38 @@ mod tests {
|
||||
assert!(help.contains("works with --resume SESSION.jsonl"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_skill_dispatch_resolves_known_project_skill_to_prompt() {
|
||||
let _guard = env_lock();
|
||||
let workspace = temp_dir();
|
||||
write_skill_fixture(
|
||||
&workspace.join(".codex").join("skills"),
|
||||
"caveman",
|
||||
"Project skill fixture",
|
||||
);
|
||||
|
||||
let prompt = try_resolve_bare_skill_prompt(&workspace, "caveman sharpen club")
|
||||
.expect("known bare skill should dispatch");
|
||||
assert_eq!(prompt, "$caveman sharpen club");
|
||||
|
||||
fs::remove_dir_all(workspace).expect("workspace should clean up");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_skill_dispatch_ignores_unknown_or_non_skill_input() {
|
||||
let _guard = env_lock();
|
||||
let workspace = temp_dir();
|
||||
fs::create_dir_all(&workspace).expect("workspace should exist");
|
||||
|
||||
assert_eq!(
|
||||
try_resolve_bare_skill_prompt(&workspace, "not-a-known-skill do thing"),
|
||||
None
|
||||
);
|
||||
assert_eq!(try_resolve_bare_skill_prompt(&workspace, "/status"), None);
|
||||
|
||||
fs::remove_dir_all(workspace).expect("workspace should clean up");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repl_help_includes_shared_commands_and_exit() {
|
||||
let help = render_repl_help();
|
||||
@@ -9939,10 +10314,13 @@ mod tests {
|
||||
assert!(help.contains("claw status"));
|
||||
assert!(help.contains("claw sandbox"));
|
||||
assert!(help.contains("claw init"));
|
||||
assert!(help.contains("claw acp [serve]"));
|
||||
assert!(help.contains("claw agents"));
|
||||
assert!(help.contains("claw mcp"));
|
||||
assert!(help.contains("claw skills"));
|
||||
assert!(help.contains("claw /skills"));
|
||||
assert!(help.contains("ultraworkers/claw-code"));
|
||||
assert!(help.contains("cargo install claw-code"));
|
||||
assert!(!help.contains("claw login"));
|
||||
assert!(!help.contains("claw logout"));
|
||||
}
|
||||
@@ -10346,7 +10724,7 @@ UU conflicted.rs",
|
||||
|
||||
#[test]
|
||||
fn managed_sessions_default_to_jsonl_and_resolve_legacy_json() {
|
||||
let _guard = cwd_lock().lock().expect("cwd lock");
|
||||
let _guard = cwd_guard();
|
||||
let workspace = temp_workspace("session-resolution");
|
||||
std::fs::create_dir_all(&workspace).expect("workspace should create");
|
||||
let previous = std::env::current_dir().expect("cwd");
|
||||
@@ -10385,7 +10763,7 @@ UU conflicted.rs",
|
||||
|
||||
#[test]
|
||||
fn latest_session_alias_resolves_most_recent_managed_session() {
|
||||
let _guard = cwd_lock().lock().expect("cwd lock");
|
||||
let _guard = cwd_guard();
|
||||
let workspace = temp_workspace("latest-session-alias");
|
||||
std::fs::create_dir_all(&workspace).expect("workspace should create");
|
||||
let previous = std::env::current_dir().expect("cwd");
|
||||
@@ -10418,7 +10796,7 @@ UU conflicted.rs",
|
||||
|
||||
#[test]
|
||||
fn load_session_reference_rejects_workspace_mismatch() {
|
||||
let _guard = cwd_lock().lock().expect("cwd lock");
|
||||
let _guard = cwd_guard();
|
||||
let workspace_a = temp_workspace("session-mismatch-a");
|
||||
let workspace_b = temp_workspace("session-mismatch-b");
|
||||
std::fs::create_dir_all(&workspace_a).expect("workspace a should create");
|
||||
@@ -10492,6 +10870,24 @@ UU conflicted.rs",
|
||||
LOCK.get_or_init(|| Mutex::new(()))
|
||||
}
|
||||
|
||||
fn cwd_guard() -> MutexGuard<'static, ()> {
|
||||
cwd_lock()
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cwd_guard_recovers_after_poisoning() {
|
||||
let poisoned = std::thread::spawn(|| {
|
||||
let _guard = cwd_guard();
|
||||
panic!("poison cwd lock");
|
||||
})
|
||||
.join();
|
||||
assert!(poisoned.is_err(), "poisoning thread should panic");
|
||||
|
||||
let _guard = cwd_guard();
|
||||
}
|
||||
|
||||
fn temp_workspace(label: &str) -> PathBuf {
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
@@ -11505,43 +11901,78 @@ mod sandbox_report_tests {
|
||||
#[cfg(test)]
|
||||
mod dump_manifests_tests {
|
||||
use super::{dump_manifests_at_path, CliOutputFormat};
|
||||
use std::fs;
|
||||
|
||||
#[test]
|
||||
fn dump_manifests_shows_helpful_error_when_manifests_missing() {
|
||||
// Create a temp directory without manifest files
|
||||
let temp_dir = std::env::temp_dir().join(format!(
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"claw_test_missing_manifests_{}",
|
||||
std::process::id()
|
||||
));
|
||||
std::fs::create_dir_all(&temp_dir).expect("failed to create temp dir");
|
||||
let workspace = root.join("workspace");
|
||||
std::fs::create_dir_all(&workspace).expect("failed to create temp workspace");
|
||||
|
||||
// Clean up at the end of the test
|
||||
let _cleanup = std::panic::catch_unwind(|| {
|
||||
// Call dump_manifests_at_path with the temp directory
|
||||
let result = dump_manifests_at_path(&temp_dir, CliOutputFormat::Text);
|
||||
let result = dump_manifests_at_path(&workspace, None, CliOutputFormat::Text);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"expected an error when manifests are missing"
|
||||
);
|
||||
|
||||
// Assert that the call fails
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"expected an error when manifests are missing"
|
||||
);
|
||||
let error_msg = result.unwrap_err().to_string();
|
||||
|
||||
let error_msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
error_msg.contains("Manifest source files are missing"),
|
||||
"error message should mention missing manifest sources: {error_msg}"
|
||||
);
|
||||
assert!(
|
||||
error_msg.contains(&root.display().to_string()),
|
||||
"error message should contain the resolved repo root path: {error_msg}"
|
||||
);
|
||||
assert!(
|
||||
error_msg.contains("src/commands.ts"),
|
||||
"error message should mention missing commands.ts: {error_msg}"
|
||||
);
|
||||
assert!(
|
||||
error_msg.contains("CLAUDE_CODE_UPSTREAM"),
|
||||
"error message should explain how to supply the upstream path: {error_msg}"
|
||||
);
|
||||
|
||||
// Assert the error message contains "Manifest files (commands.ts, tools.ts)"
|
||||
assert!(
|
||||
error_msg.contains("Manifest files (commands.ts, tools.ts)"),
|
||||
"error message should mention manifest files: {error_msg}"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
// Assert the error message contains the expected path
|
||||
assert!(
|
||||
error_msg.contains(&temp_dir.display().to_string()),
|
||||
"error message should contain the expected path: {error_msg}"
|
||||
);
|
||||
});
|
||||
#[test]
|
||||
fn dump_manifests_uses_explicit_manifest_dir() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"claw_test_explicit_manifest_dir_{}",
|
||||
std::process::id()
|
||||
));
|
||||
let workspace = root.join("workspace");
|
||||
let upstream = root.join("upstream");
|
||||
fs::create_dir_all(workspace.join("nested")).expect("workspace should exist");
|
||||
fs::create_dir_all(upstream.join("src/entrypoints"))
|
||||
.expect("upstream fixture should exist");
|
||||
fs::write(
|
||||
upstream.join("src/commands.ts"),
|
||||
"import FooCommand from './commands/foo'\n",
|
||||
)
|
||||
.expect("commands fixture should write");
|
||||
fs::write(
|
||||
upstream.join("src/tools.ts"),
|
||||
"import ReadTool from './tools/read'\n",
|
||||
)
|
||||
.expect("tools fixture should write");
|
||||
fs::write(
|
||||
upstream.join("src/entrypoints/cli.tsx"),
|
||||
"startupProfiler()\n",
|
||||
)
|
||||
.expect("cli fixture should write");
|
||||
|
||||
// Clean up temp directory
|
||||
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||
let result = dump_manifests_at_path(&workspace, Some(&upstream), CliOutputFormat::Text);
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"explicit manifest dir should succeed: {result:?}"
|
||||
);
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,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");
|
||||
@@ -174,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);
|
||||
@@ -207,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| {
|
||||
@@ -219,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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user