mirror of
https://github.com/instructkr/claw-code.git
synced 2026-10-15 00:00:14 +08:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28fb5c171b | ||
|
|
4c2a09c868 |
No files matched your search
@@ -57,7 +57,7 @@ jobs:
|
||||
mkdir -p dist
|
||||
cp "target/release/${{ matrix.bin }}" "dist/${{ matrix.artifact_name }}"
|
||||
chmod +x "dist/${{ matrix.artifact_name }}"
|
||||
(cd dist && (sha256sum "${{ matrix.artifact_name }}" 2>/dev/null || shasum -a 256 "${{ matrix.artifact_name }}") > "${{ matrix.artifact_name }}.sha256")
|
||||
(cd dist && sha256sum "${{ matrix.artifact_name }}" > "${{ matrix.artifact_name }}.sha256")
|
||||
|
||||
- name: Upload workflow artifact
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
|
||||
|
||||
@@ -25,8 +25,6 @@ on:
|
||||
- tests/test_roadmap_helpers.py
|
||||
- docs/**
|
||||
- rust/**
|
||||
- flake.nix
|
||||
- .envrc
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
@@ -49,8 +47,6 @@ on:
|
||||
- tests/test_roadmap_helpers.py
|
||||
- docs/**
|
||||
- rust/**
|
||||
- flake.nix
|
||||
- .envrc
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
|
||||
@@ -13,9 +13,3 @@ archive/
|
||||
status-help.txt
|
||||
# Legacy Python port session scratch artifacts
|
||||
.port_sessions/
|
||||
# Local agent session state
|
||||
.gjc/
|
||||
# Nix build outputs and direnv
|
||||
.envrc.local
|
||||
result
|
||||
result-*
|
||||
@@ -0,0 +1 @@
|
||||
{"schema_version":1,"session_id":"5e2dd1e8-ea4b-4af1-b56b-89ab7cca4299","state":"running","ready_for_input":false,"updated_at":"2026-10-12T22:09:30.659Z","current_turn_id":null,"last_turn_id":null,"live":true,"reason":null,"source":"agent_session_event","event":"turn_start","cwd":"/home/bellman/Data/Workspace/gc-task-cc-issue-3257-lane","workdir":"/home/bellman/Data/Workspace/gc-task-cc-issue-3257-lane","branch":null,"session_file":"/home/bellman/.gajaeway/broker/agent/sessions/v2-6fji37qf7ld523ivvoiohlcpmz3nu45m2apxyrkusn5nxczjmouq/2026-10-12T22-04-33-362Z_5e2dd1e8-ea4b-4af1-b56b-89ab7cca4299.jsonl","activity":{"seq":115,"last_activity_at":"2026-10-12T22:09:34.144Z","tool":"bash","phase":"started","outcome":null,"elapsed_ms":null,"active_tool_count":1,"active_tools":[{"tool":"bash","started_at":"2026-10-12T22:09:34.144Z"}],"in_flight":[{"digest":"e603a70efb03b6b2b78c94b704197d26fc8a35113543291a21f68b3b08ca11a9","tool":"bash","started_at":"2026-10-12T22:09:34.144Z"}]}}
|
||||
@@ -0,0 +1 @@
|
||||
{"pid":1,"start_time":"unknown","token":"1a2e8a33-76f2-4175-ac23-1521458f12a2","owner_host_id":"7af2fb0e8e37bd543e3d0bda6f205b56f30077b5a9b77e83208e94ae441315ac","released":true}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"pid":1,"start_time":"unknown","token":"a1342032-af80-4a49-a783-291bf73bf027","owner_host_id":"7af2fb0e8e37bd543e3d0bda6f205b56f30077b5a9b77e83208e94ae441315ac","released":true}
|
||||
Whitespace-only changes.
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"version": 1,
|
||||
"sessionId": "5e2dd1e8-ea4b-4af1-b56b-89ab7cca4299",
|
||||
"pid": 487360,
|
||||
"host": "127.0.0.1",
|
||||
"port": 37541,
|
||||
"url": "ws://127.0.0.1:37541",
|
||||
"token": "LlIfu_aHH57jfys2Ii0ORMrnu56NLAa1",
|
||||
"startedAt": 1791842673967,
|
||||
"updatedAt": 1791842673967,
|
||||
"stale": false
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"effectMarker":"f441857a-f591-474b-b34a-6d89b2bf9feb","incarnation":"linux:159099652","pid":487360}
|
||||
@@ -0,0 +1 @@
|
||||
{"effectMarker":"f441857a-f591-474b-b34a-6d89b2bf9feb","incarnation":"linux:159099652","pid":487360}
|
||||
@@ -1,44 +0,0 @@
|
||||
# Issue #3283 Triage Summary
|
||||
|
||||
**Issue:** docs: document OmniRoute OpenAI-compatible setup
|
||||
**Opened by:** @diegosouzapw
|
||||
**Status:** BLOCKED (waiting for clarification)
|
||||
**Date:** 2026-10-13
|
||||
|
||||
## Findings
|
||||
|
||||
### Investigation
|
||||
1. Searched codebase for "OmniRoute" — **no matches found**
|
||||
2. Searched documentation (docs/local-openai-compatible-providers.md, MODEL_COMPATIBILITY.md) — **no mentions**
|
||||
3. Searched git history (commits, branches, PRs) — **no references**
|
||||
4. Checked GitHub API (rate limited, but issue title confirmed)
|
||||
|
||||
### Current State
|
||||
- Issue #3283 is OPEN
|
||||
- No labels assigned
|
||||
- Title: "docs: document OmniRoute OpenAI-compatible setup"
|
||||
- This is a documentation request for setting up an OpenAI-compatible provider
|
||||
|
||||
### Existing Documentation
|
||||
The repository already has comprehensive documentation for OpenAI-compatible providers:
|
||||
- `docs/local-openai-compatible-providers.md` — documents Ollama, llama.cpp, vLLM, and OpenRouter
|
||||
- `docs/MODEL_COMPATIBILITY.md` — model-specific provider routing details
|
||||
- `rust/crates/api/src/providers/openai_compat.rs` — provider implementation
|
||||
|
||||
## Next Steps
|
||||
|
||||
**Action taken:** Comment added to issue asking for:
|
||||
1. OmniRoute documentation/website link
|
||||
2. OpenAI-compatible endpoint example (base URL, API key format)
|
||||
3. Model ID examples
|
||||
4. Any special configuration or known quirks
|
||||
|
||||
**Comment posted to:** https://github.com/ultraworkers/claw-code/issues/3283#issuecomment-6133484114
|
||||
|
||||
## Conclusion
|
||||
|
||||
**Cannot proceed without user input.** Once the user provides the necessary information about OmniRoute, the fix will involve:
|
||||
1. Adding a new section to `docs/local-openai-compatible-providers.md`
|
||||
2. Including setup example (environment variables, model names, basic smoke test)
|
||||
3. Following the existing format used for Ollama, llama.cpp, vLLM sections
|
||||
4. Creating a PR for review
|
||||
@@ -1,337 +0,0 @@
|
||||
# GJC Tool Semantics Alignment: Phase 1 (read/edit/search/find)
|
||||
|
||||
**Status**: Implementation plan
|
||||
**Issue**: #3323
|
||||
**Scope**: Slice 2.1 - Tool catalog semantic parity for read/edit/search/find
|
||||
**Target**: Ship in batches with focused PRs
|
||||
|
||||
---
|
||||
|
||||
## 1. EXECUTIVE SUMMARY
|
||||
|
||||
This document details the semantic gaps between claw-code's current tool implementations and GJC's (gajae-code) reference implementations for the highest-value tools: **read**, **edit**, **search**, and **find**.
|
||||
|
||||
### Key Gaps
|
||||
|
||||
| Tool | Current (claw-code) | Target (GJC) | Gap | Complexity |
|
||||
|------|-----|--------|-----|------------|
|
||||
| **read** | offset/limit pagination | Line selectors (`:L-L+N`), raw mode, truncation footers | High | Medium |
|
||||
| **write** | Simple file replacement | Full replacement semantics (matching GJC) | Low | Low |
|
||||
| **edit** | String replacement (old→new) | String replacement with rich patch output | Low | Low |
|
||||
| **find** (glob) | Pattern expansion only | Recursive glob with filtering | Low | Low |
|
||||
| **search** (grep) | Regex search with context | Regex with context + advanced filtering | Low | Low |
|
||||
|
||||
---
|
||||
|
||||
## 2. GJC TOOL SEMANTICS REFERENCE
|
||||
|
||||
### 2.1 READ Tool
|
||||
|
||||
**Source**: `gajae-code/packages/coding-agent/src/tools/read.ts` (3,500+ LOC)
|
||||
|
||||
#### Input Schema
|
||||
```typescript
|
||||
{
|
||||
path: string; // File path (relative or absolute)
|
||||
offset?: number; // Line offset (for pagination)
|
||||
limit?: number; // Lines to read (for pagination)
|
||||
// GJC extensions (missing in claw-code):
|
||||
selector?: string; // Line range syntax:
|
||||
// ":50" = line 50 onward
|
||||
// ":50-200" = lines 50-200 inclusive
|
||||
// ":50+150" = 150 lines starting at line 50
|
||||
// ":raw" = full file verbatim
|
||||
// ":conflicts" = git merge conflicts only
|
||||
truncationDirection?: "head" | "tail" | "both"; // Which end to keep
|
||||
maxSummaryBytes?: number; // Summary budget (default 50KB)
|
||||
maxSummaryLines?: number; // Summary budget (default 20K lines)
|
||||
}
|
||||
```
|
||||
|
||||
#### Output Structure
|
||||
```json
|
||||
{
|
||||
"file": {
|
||||
"path": "src/main.rs",
|
||||
"content": "...",
|
||||
"totalLines": 500,
|
||||
"totalBytes": 12345,
|
||||
"truncatedBy": "lines" | "bytes" | "middle" | null,
|
||||
"displayMode": "summarized" | "raw" | "receipt"
|
||||
},
|
||||
"truncationFooter": "[Showing lines 1-50 of 500; re-read ...]"
|
||||
}
|
||||
```
|
||||
|
||||
#### Key Behaviors
|
||||
|
||||
1. **Line Range Selectors** (`:selector` syntax)
|
||||
- `:50` = start at line 50
|
||||
- `:50-200` = lines 50-200 inclusive
|
||||
- `:50+150` = 150 lines starting at 50
|
||||
- `:50+1` = exactly one line
|
||||
- `:5-16,960-973` = multiple ranges
|
||||
- `:raw` = full verbatim
|
||||
- `:conflicts` = git merge conflicts
|
||||
|
||||
2. **Truncation Modes**
|
||||
- Default: 50KB bytes limit, 20K lines limit
|
||||
- Direction: `head` (keep start), `tail` (keep end), `both` (keep both ends)
|
||||
- Footer includes recovery hint (e.g., "`re-read path:1-9999` for verbatim")
|
||||
|
||||
3. **Structural Summarization**
|
||||
- Code: collapse function bodies to `..` or `…` (merged brace pairs)
|
||||
- Prose: preserve full lines, truncate at word boundaries
|
||||
- Special files: documents (PDF, DOCX, XLSX) converted via markit
|
||||
|
||||
4. **Archive Support**
|
||||
- `.tar`, `.tar.gz`, `.tgz`, `.zip`: member read with `archive.ext:path/inside`
|
||||
- SQLite: `.sqlite:table` or `.sqlite:table:key` queries
|
||||
- Line ranges apply to archive members
|
||||
|
||||
5. **Output Formatting**
|
||||
- Anchored output: Line prefixes with source line numbers and hash anchors
|
||||
- Optional hash lines: Marks structural boundaries (`# pragma`, function start, etc.)
|
||||
- Prose column cap: Long lines wrapped/truncated at column limit
|
||||
|
||||
### 2.2 EDIT Tool
|
||||
|
||||
**Source**: `gajae-code/packages/coding-agent/src/tools/edit.ts`
|
||||
|
||||
#### Input Schema
|
||||
```typescript
|
||||
{
|
||||
path: string; // File to edit
|
||||
old_text: string; // Text to replace (required for edit)
|
||||
new_text: string; // Replacement text
|
||||
all?: boolean; // Replace all occurrences (default: first only)
|
||||
}
|
||||
```
|
||||
|
||||
#### Output Structure
|
||||
```json
|
||||
{
|
||||
"file": {
|
||||
"path": "src/main.rs",
|
||||
"editCount": 1,
|
||||
"beforeSize": 12345,
|
||||
"afterSize": 12400
|
||||
},
|
||||
"patch": [
|
||||
{
|
||||
"type": "replace",
|
||||
"lineStart": 50,
|
||||
"lineEnd": 55,
|
||||
"before": "old content",
|
||||
"after": "new content",
|
||||
"context": {"before": 3, "after": 3}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Key Behaviors
|
||||
|
||||
1. **Exact Match Required**: `old_text` must match exactly (no fuzzy matching)
|
||||
2. **Smallest Replacement**: Use smallest unique old_text that identifies the change
|
||||
3. **All-or-Nothing**: Either all occurrences replaced or none (no partial)
|
||||
4. **Patch Metadata**: Line numbers, context, before/after preserved
|
||||
|
||||
### 2.3 SEARCH Tool
|
||||
|
||||
**Source**: `gajae-code/packages/coding-agent/src/tools/search.ts`
|
||||
|
||||
#### Input Schema
|
||||
```typescript
|
||||
{
|
||||
pattern: string; // Regex pattern
|
||||
paths?: string[]; // Files/dirs to search
|
||||
context?: number; // Context lines before/after (default: 2)
|
||||
i?: boolean; // Case-insensitive
|
||||
glob_exclude?: string[]; // Patterns to skip
|
||||
file_type?: string; // Filter by extension
|
||||
limit?: number; // Max results
|
||||
truncation?: "head" | "tail" | "both";
|
||||
}
|
||||
```
|
||||
|
||||
#### Output Structure
|
||||
```json
|
||||
{
|
||||
"matches": [
|
||||
{
|
||||
"file": "src/main.rs",
|
||||
"lineNumber": 42,
|
||||
"line": "fn process() {",
|
||||
"before": ["// Process data", ""],
|
||||
"after": [" // Implementation", ""]
|
||||
}
|
||||
],
|
||||
"summary": "Found 5 matches in 3 files"
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 FIND Tool
|
||||
|
||||
**Source**: `gajae-code/packages/coding-agent/src/tools/find.ts`
|
||||
|
||||
#### Input Schema
|
||||
```typescript
|
||||
{
|
||||
paths: string[]; // Glob patterns
|
||||
timeout?: number; // Timeout in seconds (default: 5)
|
||||
gitignore?: boolean; // Respect .gitignore (default: true)
|
||||
hidden?: boolean; // Include hidden files (default: true)
|
||||
limit?: number; // Max results (default: 1000)
|
||||
}
|
||||
```
|
||||
|
||||
#### Output Structure
|
||||
```json
|
||||
{
|
||||
"files": [
|
||||
{
|
||||
"path": "src/main.rs",
|
||||
"modified": "2026-10-13T12:34:56Z",
|
||||
"size": 12345
|
||||
}
|
||||
],
|
||||
"summary": "Found 42 files matching pattern",
|
||||
"truncated": false
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. CURRENT CLAW-CODE IMPLEMENTATION GAPS
|
||||
|
||||
### 3.1 READ Tool Gaps
|
||||
|
||||
**File**: `rust/crates/runtime/src/file_ops.rs:713`
|
||||
|
||||
**Current**:
|
||||
```rust
|
||||
pub fn read_file_in_workspace(
|
||||
path: &str,
|
||||
offset: Option<usize>, // Line offset only
|
||||
limit: Option<usize>, // Lines only
|
||||
workspace_root: &Path,
|
||||
) -> io::Result<ReadFileOutput>
|
||||
```
|
||||
|
||||
**Missing**:
|
||||
- ✗ Line range selectors (`:L-L+N` syntax)
|
||||
- ✗ Raw mode (`:raw`)
|
||||
- ✗ Git merge conflict extraction (`:conflicts`)
|
||||
- ✗ Truncation footers with recovery hints
|
||||
- ✗ Structural summarization (collapse braces)
|
||||
- ✗ Archive/SQLite support
|
||||
- ✗ Multi-range selectors (`:5-16,960-973`)
|
||||
- ✗ Prose column cap
|
||||
- ✗ Hash line anchors
|
||||
|
||||
**Estimated LOC to Add**: 1,200-1,500 (including tests)
|
||||
|
||||
### 3.2 EDIT Tool Gaps
|
||||
|
||||
**File**: `rust/crates/runtime/src/file_ops.rs:760`
|
||||
|
||||
**Current**: Basic string replacement works
|
||||
**Missing**:
|
||||
- ✗ Rich patch metadata (line numbers, context lines)
|
||||
|
||||
**Estimated LOC to Add**: 200-300
|
||||
|
||||
### 3.3 SEARCH Tool Gaps
|
||||
|
||||
**File**: `rust/crates/tools/src/lib.rs` (grep search)
|
||||
|
||||
**Current**: Basic regex with context
|
||||
**Missing**:
|
||||
- ✗ Advanced filtering (file type, glob exclude)
|
||||
- ✗ Structured output format
|
||||
|
||||
**Estimated LOC to Add**: 300-400
|
||||
|
||||
### 3.4 FIND Tool Gaps
|
||||
|
||||
**File**: `rust/crates/tools/src/lib.rs` (glob search)
|
||||
|
||||
**Current**: Basic glob expansion
|
||||
**Missing**:
|
||||
- ✗ Recursive globs
|
||||
- ✗ Gitignore filtering
|
||||
- ✗ Hidden file control
|
||||
- ✗ File metadata
|
||||
|
||||
**Estimated LOC to Add**: 200-300
|
||||
|
||||
---
|
||||
|
||||
## 4. IMPLEMENTATION PLAN (BATCHED)
|
||||
|
||||
### Batch 1: READ Tool (Priority P0)
|
||||
- Add line range selector parsing
|
||||
- Implement `:raw` and `:conflicts` modes
|
||||
- Add truncation footers with recovery hints
|
||||
- Implement structural summarization
|
||||
- Add tests for all selector combinations
|
||||
|
||||
**Files to Modify**:
|
||||
- `rust/crates/runtime/src/file_ops.rs` (read_file_in_workspace)
|
||||
- `rust/crates/tools/src/lib.rs` (ReadFileInput struct)
|
||||
- Tests in both
|
||||
|
||||
**Size Estimate**: 1,200 LOC
|
||||
**Complexity**: Medium (string parsing, output formatting)
|
||||
|
||||
### Batch 2: EDIT Tool (Priority P1)
|
||||
- Add patch metadata to output
|
||||
- Ensure exact match semantics
|
||||
- Add comprehensive tests
|
||||
|
||||
**Files to Modify**:
|
||||
- `rust/crates/runtime/src/file_ops.rs` (edit_file_in_workspace)
|
||||
- Tests
|
||||
|
||||
**Size Estimate**: 200 LOC
|
||||
**Complexity**: Low
|
||||
|
||||
### Batch 3: SEARCH/FIND Tools (Priority P1)
|
||||
- Enhance grep/find with GJC-compatible filtering
|
||||
- Add structured output format
|
||||
- Add metadata (file type, modified time)
|
||||
|
||||
**Files to Modify**:
|
||||
- `rust/crates/tools/src/lib.rs` (glob_search, grep_search)
|
||||
- `rust/crates/runtime/src/file_ops.rs`
|
||||
|
||||
**Size Estimate**: 500 LOC
|
||||
**Complexity**: Low-Medium
|
||||
|
||||
---
|
||||
|
||||
## 5. VERIFICATION STRATEGY
|
||||
|
||||
Each batch will include:
|
||||
1. **Semantic Tests**: Verify tool inputs/outputs match GJC reference
|
||||
2. **Edge Case Tests**: Line ranges, truncation, archives, etc.
|
||||
3. **Integration Tests**: End-to-end with claw CLI
|
||||
4. **Parity Shadow Test**: Run same inputs against both GJC and claw-code, compare outputs
|
||||
|
||||
---
|
||||
|
||||
## 6. ROLLOUT SCHEDULE
|
||||
|
||||
- **Batch 1 (READ)**: Week 1 - Enable most impactful tool first
|
||||
- **Batch 2 (EDIT)**: Week 1-2 - Quick follow-up
|
||||
- **Batch 3 (SEARCH/FIND)**: Week 2 - Lower priority but valuable for discoverability
|
||||
|
||||
---
|
||||
|
||||
## 7. REFERENCES
|
||||
|
||||
- GJC Read Tool: `/home/bellman/Workspace/gajae-code/packages/coding-agent/src/tools/read.ts`
|
||||
- claw-code file_ops: `rust/crates/runtime/src/file_ops.rs`
|
||||
- claw-code tools: `rust/crates/tools/src/lib.rs`
|
||||
- Gap Matrix: `docs/gjc-compat/gap-matrix.md`
|
||||
@@ -130,25 +130,6 @@ claw --model "mlx-community/Qwen2.5-Coder-7B-Instruct-4bit" prompt "Reply exactl
|
||||
|
||||
mlx-lm serves models under their full Hugging Face repo ID. Use the exact `id` field from `curl $OPENAI_BASE_URL/models` for `--model`. A bare name like `qwen2.5-coder-7b-instruct` will fail model resolution before the request ever reaches the server.
|
||||
|
||||
## OmniRoute and other remote OpenAI-compatible gateways
|
||||
|
||||
[OmniRoute](https://github.com/diegosouzapw/OmniRoute) is a third-party OpenAI-compatible AI gateway that can route requests to multiple providers. To use any remote OpenAI-compatible gateway with Claw, set `OPENAI_BASE_URL` to the gateway's `/v1` endpoint and provide your API key:
|
||||
|
||||
```bash
|
||||
export OPENAI_BASE_URL="https://api.omniroute.ai/v1"
|
||||
export OPENAI_API_KEY="your-omniroute-api-key"
|
||||
claw --model "gpt-4" prompt "Reply exactly HELLO_WORLD_123"
|
||||
```
|
||||
|
||||
To find the available model IDs on your gateway, use the `/models` endpoint:
|
||||
|
||||
```bash
|
||||
curl -sS "$OPENAI_BASE_URL/models" \
|
||||
-H "Authorization: Bearer $OPENAI_API_KEY" | jq '.data[].id'
|
||||
```
|
||||
|
||||
For local configuration, see your gateway's documentation for the correct base URL, API key format, and available model IDs. The same OPENAI_BASE_URL + OPENAI_API_KEY pattern applies to any OpenAI-compatible remote gateway.
|
||||
|
||||
## Local skills install from disk
|
||||
|
||||
Skills are discovered from Claw skill roots such as `.claw/skills/` in a workspace and `~/.claw/skills/` for user-level installs. Legacy `.codex/skills/` roots may also be scanned for compatibility, but new local Claw projects should prefer `.claw/skills/`.
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
# Nix flake development environment
|
||||
|
||||
This repository includes a **`flake.nix`** for reproducible development environments on NixOS and systems with Nix installed.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Nix** (version 2.4 or later with flakes support enabled)
|
||||
- **direnv** (optional, for automatic environment loading via `.envrc`)
|
||||
|
||||
To verify Nix is installed and flakes are enabled:
|
||||
|
||||
```bash
|
||||
nix --version
|
||||
nix flake --help
|
||||
```
|
||||
|
||||
If flakes are not enabled, add this to your `~/.config/nix/nix.conf`:
|
||||
|
||||
```
|
||||
experimental-features = nix-command flakes
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
### Enter the development environment
|
||||
|
||||
**Option 1: Using `nix flake`** (without direnv)
|
||||
|
||||
```bash
|
||||
nix flake update # Update inputs (optional, pins nixpkgs)
|
||||
nix develop # Enter default dev shell
|
||||
nix develop .#full # Enter full dev shell with extra tools
|
||||
exit # Leave the shell
|
||||
```
|
||||
|
||||
**Option 2: Using direnv** (automatic on directory entry)
|
||||
|
||||
```bash
|
||||
direnv allow # Trust the .envrc file (one-time setup)
|
||||
cd . # Direnv reloads the flake automatically
|
||||
```
|
||||
|
||||
When you leave the repository directory, the environment is automatically unloaded.
|
||||
|
||||
## Development shells
|
||||
|
||||
The flake provides two development shells:
|
||||
|
||||
### `default` (minimal)
|
||||
|
||||
```bash
|
||||
nix develop
|
||||
```
|
||||
|
||||
Includes:
|
||||
- Rust (stable, latest)
|
||||
- Rust source code (`rust-src`)
|
||||
- `cargo-edit` (dependency management helpers)
|
||||
- `cargo-expand` (macro expansion tool)
|
||||
- `cargo-watch` (file watcher for incremental builds)
|
||||
- Build tools: `pkg-config`, `openssl`, `git`, `ca-certificates`
|
||||
|
||||
### `full` (with extra tooling)
|
||||
|
||||
```bash
|
||||
nix develop .#full
|
||||
```
|
||||
|
||||
Includes everything from `default` plus:
|
||||
- `clippy` (linter)
|
||||
- `rustfmt` (code formatter)
|
||||
- `mold` (fast linker)
|
||||
- `lldb` (debugger)
|
||||
|
||||
## Building the claw binary
|
||||
|
||||
### With the dev environment
|
||||
|
||||
```bash
|
||||
nix develop
|
||||
cd rust
|
||||
cargo build -p rusty-claude-cli
|
||||
```
|
||||
|
||||
### Directly with `nix build`
|
||||
|
||||
```bash
|
||||
nix build .#claw # Build the claw binary
|
||||
nix build # Same as .#claw (default package)
|
||||
```
|
||||
|
||||
The compiled binary is symlinked to `./result/bin/claw`.
|
||||
|
||||
## Running common tasks
|
||||
|
||||
Once in the dev shell:
|
||||
|
||||
```bash
|
||||
# Format code
|
||||
cd rust
|
||||
cargo fmt
|
||||
|
||||
# Run clippy linter
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
|
||||
# Run all tests
|
||||
cargo test --workspace
|
||||
|
||||
# Run tests for a specific crate
|
||||
cargo test -p rusty-claude-cli
|
||||
|
||||
# Build with optimizations
|
||||
cargo build --release -p rusty-claude-cli
|
||||
```
|
||||
|
||||
## Environment variables
|
||||
|
||||
The flake automatically sets:
|
||||
|
||||
- `RUSTUP_TOOLCHAIN=stable` — uses stable Rust from nixpkgs
|
||||
- Standard Rust build flags are inherited from the environment
|
||||
|
||||
For development, the minimal dev shell includes a helpful message on entry:
|
||||
|
||||
```
|
||||
Claw Code development environment
|
||||
Run 'cargo build -p rusty-claude-cli' to build the claw binary
|
||||
Run 'cargo test --workspace' to run tests
|
||||
Run 'scripts/fmt.sh' to format code
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### `command not found: nix`
|
||||
|
||||
Nix is not installed. Install it from [nixos.org/download.html](https://nixos.org/download.html).
|
||||
|
||||
### `error: experimental Nix feature 'flakes' is disabled`
|
||||
|
||||
Enable flakes in your Nix configuration (`~/.config/nix/nix.conf` or `/etc/nix/nix.conf`):
|
||||
|
||||
```
|
||||
experimental-features = nix-command flakes
|
||||
```
|
||||
|
||||
### `error: ... does not provide attribute 'claw'`
|
||||
|
||||
The flake structure may have changed. Try updating:
|
||||
|
||||
```bash
|
||||
nix flake update
|
||||
nix flake check
|
||||
```
|
||||
|
||||
### Rebuilds are slow after updating dependencies
|
||||
|
||||
Cargo will recompile dependencies if `Cargo.lock` changes. This is expected:
|
||||
|
||||
```bash
|
||||
nix build --rebuild
|
||||
```
|
||||
|
||||
### `cache lock file needs to be updated`
|
||||
|
||||
The Cargo.lock in `rust/` needs to be committed. Run:
|
||||
|
||||
```bash
|
||||
cd rust
|
||||
cargo generate-lockfile
|
||||
git add Cargo.lock
|
||||
git commit -m "update: Cargo.lock"
|
||||
```
|
||||
|
||||
## Integration with other tools
|
||||
|
||||
### IDE integration (VS Code, IntelliJ, etc.)
|
||||
|
||||
Many IDEs can detect and use Nix environments. Direnv integration is recommended:
|
||||
|
||||
1. Install the direnv extension
|
||||
2. Enable `direnv` shell integration in your IDE
|
||||
3. Run `direnv allow` in the repository
|
||||
|
||||
Your IDE will automatically use the correct Rust toolchain and dependencies from the flake.
|
||||
|
||||
### CI/CD
|
||||
|
||||
For GitHub Actions, consider using [cachix/install-nix-action](https://github.com/cachix/install-nix-action) to set up Nix on the runner:
|
||||
|
||||
```yaml
|
||||
- uses: cachix/install-nix-action@v18
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
extra_nix_config: "experimental-features = nix-command flakes"
|
||||
|
||||
- run: nix develop -c cargo test --workspace
|
||||
```
|
||||
|
||||
## More information
|
||||
|
||||
- [NixOS flakes documentation](https://nixos.wiki/wiki/Flakes)
|
||||
- [Direnv documentation](https://direnv.net/)
|
||||
- [Rust on Nix](https://github.com/oxalica/rust-overlay)
|
||||
@@ -1,115 +0,0 @@
|
||||
{
|
||||
description = "claw-code: Claude Code CLI agent harness in Rust";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
rust-overlay = {
|
||||
url = "github:oxalica/rust-overlay";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, flake-utils, rust-overlay }:
|
||||
flake-utils.lib.eachDefaultSystem (system:
|
||||
let
|
||||
overlays = [ (import rust-overlay) ];
|
||||
pkgs = import nixpkgs {
|
||||
inherit system overlays;
|
||||
};
|
||||
|
||||
# Build inputs needed for Rust and the project
|
||||
buildInputs = with pkgs; [
|
||||
pkg-config
|
||||
openssl
|
||||
ca-certificates
|
||||
git
|
||||
];
|
||||
|
||||
# Runtime inputs for the dev environment
|
||||
nativeBuildInputs = with pkgs; [
|
||||
(rust-bin.stable.latest.default.override {
|
||||
extensions = [ "rust-src" ];
|
||||
targets = [ ];
|
||||
})
|
||||
cargo-edit
|
||||
cargo-expand
|
||||
cargo-watch
|
||||
];
|
||||
|
||||
# Derivation to build the claw binary
|
||||
clawBinary = pkgs.rustPlatform.buildRustPackage {
|
||||
pname = "claw";
|
||||
version = (builtins.fromTOML (builtins.readFile ./rust/Cargo.toml)).workspace.package.version;
|
||||
|
||||
src = ./rust;
|
||||
|
||||
# Hash of all the dependencies in Cargo.lock
|
||||
# This needs to be updated when dependencies change
|
||||
# Run: nix flake update
|
||||
cargoLock = {
|
||||
lockFile = ./rust/Cargo.lock;
|
||||
};
|
||||
|
||||
cargoBuildFlags = [ "-p" "rusty-claude-cli" ];
|
||||
|
||||
buildInputs = buildInputs ++ (with pkgs; [
|
||||
openssl
|
||||
]);
|
||||
|
||||
nativeBuildInputs = with pkgs; [
|
||||
pkg-config
|
||||
];
|
||||
|
||||
meta = with pkgs.lib; {
|
||||
description = "Claude Code CLI: agent harness with Rust implementation";
|
||||
homepage = "https://github.com/ultraworkers/claw-code";
|
||||
license = licenses.mit;
|
||||
platforms = platforms.unix;
|
||||
};
|
||||
};
|
||||
|
||||
in
|
||||
{
|
||||
# Provide the claw binary as a package
|
||||
packages.claw = clawBinary;
|
||||
packages.default = clawBinary;
|
||||
|
||||
# Development shell for contributors
|
||||
devShells.default = pkgs.mkShell {
|
||||
name = "claw-dev";
|
||||
inherit buildInputs nativeBuildInputs;
|
||||
|
||||
shellHook = ''
|
||||
echo "Claw Code development environment"
|
||||
echo "Run 'cargo build -p rusty-claude-cli' to build the claw binary"
|
||||
echo "Run 'cargo test --workspace' to run tests"
|
||||
echo "Run 'scripts/fmt.sh' to format code"
|
||||
'';
|
||||
|
||||
# Make sure we have the right Rust toolchain
|
||||
RUSTUP_TOOLCHAIN = "stable";
|
||||
};
|
||||
|
||||
# Alternative shell with additional development tools
|
||||
devShells.full = pkgs.mkShell {
|
||||
name = "claw-dev-full";
|
||||
inherit buildInputs nativeBuildInputs;
|
||||
|
||||
nativeBuildInputs = nativeBuildInputs ++ (with pkgs; [
|
||||
clippy
|
||||
rustfmt
|
||||
mold
|
||||
lldb
|
||||
]);
|
||||
|
||||
shellHook = ''
|
||||
echo "Claw Code full development environment (with extra tools)"
|
||||
echo "Available tools: clippy, rustfmt, mold (linker), lldb (debugger)"
|
||||
'';
|
||||
|
||||
RUSTUP_TOOLCHAIN = "stable";
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
+43
-136
@@ -349,12 +349,6 @@ version = "3.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.11.1"
|
||||
@@ -389,17 +383,6 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.0",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ciborium"
|
||||
version = "0.2.2"
|
||||
@@ -733,12 +716,6 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.8"
|
||||
@@ -1022,15 +999,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.3"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"rand_core 0.10.1",
|
||||
"wasip2",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
@@ -1904,15 +1881,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.19"
|
||||
version = "0.11.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe"
|
||||
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.4.3",
|
||||
"getrandom 0.3.4",
|
||||
"lru-slab",
|
||||
"rand 0.10.3",
|
||||
"rand_pcg",
|
||||
"rand 0.9.2",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
@@ -1949,9 +1925,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "6.0.0"
|
||||
version = "5.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||
|
||||
[[package]]
|
||||
name = "radix_trie"
|
||||
@@ -1970,19 +1946,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha",
|
||||
"rand_chacha 0.3.1",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.10.3"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
|
||||
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
||||
dependencies = [
|
||||
"chacha20",
|
||||
"getrandom 0.4.3",
|
||||
"rand_core 0.10.1",
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1995,6 +1970,16 @@ dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
@@ -2006,17 +1991,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.10.1"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "rand_pcg"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
|
||||
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||
dependencies = [
|
||||
"rand_core 0.10.1",
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2268,7 +2247,6 @@ dependencies = [
|
||||
"crossterm",
|
||||
"log",
|
||||
"mock-anthropic-service",
|
||||
"nix",
|
||||
"plugins",
|
||||
"pulldown-cmark",
|
||||
"runtime",
|
||||
@@ -2332,21 +2310,6 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "sdk"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tokio-test",
|
||||
"tokio-tungstenite",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
@@ -2451,17 +2414,6 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
@@ -2633,7 +2585,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.3",
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"rustix 1.1.4",
|
||||
"windows-sys 0.61.2",
|
||||
@@ -2754,7 +2706,6 @@ dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
"mio",
|
||||
"parking_lot",
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
"socket2 0.6.3",
|
||||
@@ -2794,29 +2745,6 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-test"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12bc5db7778a8e08ee255090861e0e7aac55e784300077e7ed293c7be36168b2"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-tungstenite"
|
||||
version = "0.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6989540ced10490aaf14e6bad2e3d33728a2813310a0c71d1574304c49631cd"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
"tokio",
|
||||
"tungstenite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.18"
|
||||
@@ -3028,24 +2956,6 @@ version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e2e2ce1e47ed2994fd43b04c8f618008d4cabdd5ee34027cf14f9d918edd9c8"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"bytes",
|
||||
"data-encoding",
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
"rand 0.8.6",
|
||||
"sha1",
|
||||
"thiserror 1.0.69",
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.19.0"
|
||||
@@ -3094,12 +3004,6 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utf-8"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
@@ -3112,18 +3016,6 @@ version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606"
|
||||
dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"js-sys",
|
||||
"serde_core",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
@@ -3161,6 +3053,15 @@ version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "wasip2"
|
||||
version = "1.0.2+wasi-0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.116"
|
||||
@@ -3469,6 +3370,12 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.6.2"
|
||||
|
||||
@@ -13,130 +13,7 @@ pub enum ProviderClient {
|
||||
OpenAi(OpenAiCompatClient),
|
||||
}
|
||||
|
||||
/// Provider selected explicitly by persisted/runtime configuration.
|
||||
///
|
||||
/// This is intentionally separate from `ProviderKind`: DashScope speaks the
|
||||
/// OpenAI-compatible wire protocol but remains a distinct configured provider.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ProviderConfigKind {
|
||||
Anthropic,
|
||||
Xai,
|
||||
OpenAi,
|
||||
DashScope,
|
||||
}
|
||||
|
||||
/// Explicit provider configuration supplied by the runtime layer.
|
||||
///
|
||||
/// This keeps the API crate independent of runtime configuration types.
|
||||
/// Credentials are redacted from Debug output.
|
||||
#[derive(Clone)]
|
||||
pub struct ProviderConfig {
|
||||
pub kind: ProviderConfigKind,
|
||||
pub model: String,
|
||||
pub api_key: Option<String>,
|
||||
pub base_url: Option<String>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ProviderConfig {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("ProviderConfig")
|
||||
.field("kind", &self.kind)
|
||||
.field("model", &self.model)
|
||||
.field("api_key", &self.api_key.as_ref().map(|_| "<redacted>"))
|
||||
.field("base_url", &self.base_url)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
fn read_env_non_empty(key: &str) -> Option<String> {
|
||||
std::env::var(key)
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
}
|
||||
|
||||
impl ProviderClient {
|
||||
/// Construct a client from an explicit provider configuration.
|
||||
///
|
||||
/// The persisted provider kind is authoritative for provider selection,
|
||||
/// while environment credentials and base URLs retain precedence over
|
||||
/// persisted values.
|
||||
pub fn from_config(config: &ProviderConfig) -> Result<Self, ApiError> {
|
||||
match config.kind {
|
||||
ProviderConfigKind::Anthropic => {
|
||||
let api_key = read_env_non_empty("ANTHROPIC_API_KEY")
|
||||
.or_else(|| config.api_key.clone())
|
||||
.ok_or_else(|| {
|
||||
ApiError::missing_credentials(
|
||||
"Anthropic",
|
||||
&["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"],
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut client = AnthropicClient::new(api_key);
|
||||
let base_url = read_env_non_empty("ANTHROPIC_BASE_URL")
|
||||
.or_else(|| config.base_url.clone())
|
||||
.unwrap_or_else(anthropic::read_base_url);
|
||||
client = client.with_base_url(base_url);
|
||||
Ok(Self::Anthropic(client))
|
||||
}
|
||||
ProviderConfigKind::Xai => {
|
||||
let compat = OpenAiCompatConfig::xai();
|
||||
let api_key =
|
||||
read_env_non_empty(compat.api_key_env).or_else(|| config.api_key.clone());
|
||||
|
||||
let mut client = match api_key {
|
||||
Some(api_key) => OpenAiCompatClient::new(api_key, compat),
|
||||
None => OpenAiCompatClient::from_env(compat)?,
|
||||
};
|
||||
|
||||
let base_url = read_env_non_empty(compat.base_url_env)
|
||||
.or_else(|| config.base_url.clone())
|
||||
.unwrap_or_else(|| compat.default_base_url.to_string());
|
||||
client = client.with_base_url(base_url);
|
||||
|
||||
Ok(Self::Xai(client))
|
||||
}
|
||||
ProviderConfigKind::OpenAi | ProviderConfigKind::DashScope => {
|
||||
let compat = match config.kind {
|
||||
ProviderConfigKind::OpenAi => OpenAiCompatConfig::openai(),
|
||||
ProviderConfigKind::DashScope => OpenAiCompatConfig::dashscope(),
|
||||
_ => unreachable!("non-OpenAI provider reached compatibility path"),
|
||||
};
|
||||
|
||||
if config.kind == ProviderConfigKind::OpenAi
|
||||
&& read_env_non_empty("OLLAMA_HOST").is_some()
|
||||
&& config.api_key.is_none()
|
||||
&& config.base_url.is_none()
|
||||
{
|
||||
return Ok(Self::OpenAi(
|
||||
openai_compat::OpenAiCompatClient::from_ollama_env()
|
||||
.expect("from_ollama_env always returns Some"),
|
||||
));
|
||||
}
|
||||
|
||||
let api_key =
|
||||
read_env_non_empty(compat.api_key_env).or_else(|| config.api_key.clone());
|
||||
let persisted_base_url = config.base_url.clone();
|
||||
let mut client = match api_key {
|
||||
Some(api_key) => OpenAiCompatClient::new(api_key, compat),
|
||||
None if config.kind == ProviderConfigKind::OpenAi
|
||||
&& persisted_base_url
|
||||
.as_deref()
|
||||
.is_some_and(openai_compat::is_local_openai_compatible_base_url) =>
|
||||
{
|
||||
OpenAiCompatClient::new("local-dev-token", compat)
|
||||
}
|
||||
None => OpenAiCompatClient::from_env(compat)?,
|
||||
};
|
||||
let base_url = read_env_non_empty(compat.base_url_env)
|
||||
.or(persisted_base_url)
|
||||
.unwrap_or_else(|| compat.default_base_url.to_string());
|
||||
client = client.with_base_url(base_url);
|
||||
|
||||
Ok(Self::OpenAi(client))
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn from_model(model: &str) -> Result<Self, ApiError> {
|
||||
Self::from_model_with_anthropic_auth(model, None)
|
||||
}
|
||||
@@ -157,7 +34,7 @@ impl ProviderClient {
|
||||
ProviderKind::OpenAi => {
|
||||
// OLLAMA_HOST takes priority: local Ollama needs no API key
|
||||
// and ignores DashScope/OpenAI env-based dispatch.
|
||||
if read_env_non_empty("OLLAMA_HOST").is_some() {
|
||||
if std::env::var_os("OLLAMA_HOST").is_some() {
|
||||
Ok(Self::OpenAi(
|
||||
openai_compat::OpenAiCompatClient::from_ollama_env()
|
||||
.expect("from_ollama_env always returns Some"),
|
||||
@@ -278,7 +155,7 @@ pub fn read_xai_base_url() -> String {
|
||||
mod tests {
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use super::{ProviderClient, ProviderConfig, ProviderConfigKind};
|
||||
use super::ProviderClient;
|
||||
use crate::providers::{detect_provider_kind, resolve_model_alias, ProviderKind};
|
||||
|
||||
/// Serializes every test in this module that mutates process-wide
|
||||
@@ -336,73 +213,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persisted_provider_kind_overrides_model_provider_detection() {
|
||||
let _lock = env_lock();
|
||||
let _anthropic = EnvVarGuard::set("ANTHROPIC_API_KEY", Some("test-anthropic-key"));
|
||||
let _xai = EnvVarGuard::set("XAI_API_KEY", Some("test-xai-key"));
|
||||
|
||||
let config = ProviderConfig {
|
||||
kind: ProviderConfigKind::Xai,
|
||||
model: "claude-sonnet-4-6".to_string(),
|
||||
api_key: None,
|
||||
base_url: None,
|
||||
};
|
||||
|
||||
match ProviderClient::from_config(&config).expect("explicit xAI config should succeed") {
|
||||
ProviderClient::Xai(client) => {
|
||||
assert!(client.base_url().contains("api.x.ai"));
|
||||
}
|
||||
other => panic!("Expected explicit xAI provider, got: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persisted_dashscope_kind_overrides_qwen_model_routing() {
|
||||
let _lock = env_lock();
|
||||
let _dashscope = EnvVarGuard::set("DASHSCOPE_API_KEY", Some("test-dashscope-key"));
|
||||
let _openai = EnvVarGuard::set("OPENAI_API_KEY", Some("test-openai-key"));
|
||||
|
||||
let config = ProviderConfig {
|
||||
kind: ProviderConfigKind::DashScope,
|
||||
model: "gpt-5".to_string(),
|
||||
api_key: None,
|
||||
base_url: None,
|
||||
};
|
||||
|
||||
match ProviderClient::from_config(&config)
|
||||
.expect("explicit DashScope config should succeed")
|
||||
{
|
||||
ProviderClient::OpenAi(client) => {
|
||||
assert!(client.base_url().contains("dashscope.aliyuncs.com"));
|
||||
}
|
||||
other => panic!("Expected explicit DashScope provider, got: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persisted_openai_kind_does_not_become_dashscope_from_model() {
|
||||
let _lock = env_lock();
|
||||
let _openai = EnvVarGuard::set("OPENAI_API_KEY", Some("test-openai-key"));
|
||||
let _dashscope = EnvVarGuard::set("DASHSCOPE_API_KEY", Some("test-dashscope-key"));
|
||||
let _openai_base_url = EnvVarGuard::set("OPENAI_BASE_URL", None);
|
||||
|
||||
let config = ProviderConfig {
|
||||
kind: ProviderConfigKind::OpenAi,
|
||||
model: "qwen-plus".to_string(),
|
||||
api_key: None,
|
||||
base_url: None,
|
||||
};
|
||||
|
||||
match ProviderClient::from_config(&config).expect("explicit OpenAI config should succeed") {
|
||||
ProviderClient::OpenAi(client) => {
|
||||
assert!(client.base_url().contains("api.openai.com"));
|
||||
assert!(!client.base_url().contains("dashscope.aliyuncs.com"));
|
||||
}
|
||||
other => panic!("Expected explicit OpenAI provider, got: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dashscope_model_uses_dashscope_config_not_openai() {
|
||||
// Regression: qwen-plus was being routed to OpenAiCompatConfig::openai()
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
mod client;
|
||||
|
||||
mod error;
|
||||
mod http_client;
|
||||
mod prompt_cache;
|
||||
@@ -9,8 +8,7 @@ mod types;
|
||||
|
||||
pub use client::{
|
||||
oauth_token_is_expired, read_base_url, read_xai_base_url, resolve_saved_oauth_token,
|
||||
resolve_startup_auth_source, MessageStream, OAuthTokenSet, ProviderClient, ProviderConfig,
|
||||
ProviderConfigKind,
|
||||
resolve_startup_auth_source, MessageStream, OAuthTokenSet, ProviderClient,
|
||||
};
|
||||
pub use error::ApiError;
|
||||
pub use http_client::{
|
||||
|
||||
@@ -1131,9 +1131,6 @@ pub enum SlashCommand {
|
||||
SecurityReview,
|
||||
Keybindings,
|
||||
PrivacySettings,
|
||||
Workspace {
|
||||
path: Option<String>,
|
||||
},
|
||||
Plan {
|
||||
mode: Option<String>,
|
||||
},
|
||||
@@ -1275,7 +1272,6 @@ impl SlashCommand {
|
||||
Self::SecurityReview => "/security-review",
|
||||
Self::Keybindings => "/keybindings",
|
||||
Self::PrivacySettings => "/privacy-settings",
|
||||
Self::Workspace { .. } => "/workspace",
|
||||
Self::Plan { .. } => "/plan",
|
||||
Self::Review { .. } => "/review",
|
||||
Self::Tasks { .. } => "/tasks",
|
||||
@@ -1409,9 +1405,6 @@ pub fn validate_slash_command_input(
|
||||
validate_no_args(command, &args)?;
|
||||
SlashCommand::Setup
|
||||
}
|
||||
"workspace" | "cwd" => SlashCommand::Workspace {
|
||||
path: optional_single_arg(command, &args, "[path]")?,
|
||||
},
|
||||
"login" | "logout" => {
|
||||
return Err(command_error(
|
||||
"This auth flow was removed. Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN instead.",
|
||||
@@ -5297,60 +5290,6 @@ fn mcp_server_json(name: &str, server: &ScopedMcpServerConfig) -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
fn render_session_status(session: &Session) -> String {
|
||||
let message_count = session.messages.len();
|
||||
let mut total_input = 0u32;
|
||||
let mut total_output = 0u32;
|
||||
for msg in &session.messages {
|
||||
if let Some(usage) = msg.usage {
|
||||
total_input = total_input.saturating_add(usage.input_tokens);
|
||||
total_output = total_output.saturating_add(usage.output_tokens);
|
||||
}
|
||||
}
|
||||
format!(
|
||||
"Session {}: {} messages, {} input tokens, {} output tokens",
|
||||
&session.session_id[0..8],
|
||||
message_count,
|
||||
total_input,
|
||||
total_output
|
||||
)
|
||||
}
|
||||
|
||||
fn render_token_cost(session: &Session) -> String {
|
||||
let mut total_input = 0u32;
|
||||
let mut total_output = 0u32;
|
||||
let mut cache_creation = 0u32;
|
||||
let mut cache_read = 0u32;
|
||||
for msg in &session.messages {
|
||||
if let Some(usage) = msg.usage {
|
||||
total_input = total_input.saturating_add(usage.input_tokens);
|
||||
total_output = total_output.saturating_add(usage.output_tokens);
|
||||
cache_creation = cache_creation.saturating_add(usage.cache_creation_input_tokens);
|
||||
cache_read = cache_read.saturating_add(usage.cache_read_input_tokens);
|
||||
}
|
||||
}
|
||||
format!(
|
||||
"Cumulative token usage for this session:
|
||||
Input tokens: {}
|
||||
Output tokens: {}
|
||||
Cache creation tokens: {}
|
||||
Cache read tokens: {}
|
||||
Total input: {}",
|
||||
total_input,
|
||||
total_output,
|
||||
cache_creation,
|
||||
cache_read,
|
||||
total_input
|
||||
.saturating_add(cache_creation)
|
||||
.saturating_add(cache_read)
|
||||
)
|
||||
}
|
||||
|
||||
fn render_version_info() -> String {
|
||||
let version = env!("CARGO_PKG_VERSION");
|
||||
format!("claw-code version {}", version)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn handle_slash_command(
|
||||
input: &str,
|
||||
@@ -5388,19 +5327,8 @@ pub fn handle_slash_command(
|
||||
message: render_slash_command_help(),
|
||||
session: session.clone(),
|
||||
}),
|
||||
SlashCommand::Status => Some(SlashCommandResult {
|
||||
message: render_session_status(session),
|
||||
session: session.clone(),
|
||||
}),
|
||||
SlashCommand::Cost => Some(SlashCommandResult {
|
||||
message: render_token_cost(session),
|
||||
session: session.clone(),
|
||||
}),
|
||||
SlashCommand::Version => Some(SlashCommandResult {
|
||||
message: render_version_info(),
|
||||
session: session.clone(),
|
||||
}),
|
||||
SlashCommand::Bughunter { .. }
|
||||
SlashCommand::Status
|
||||
| SlashCommand::Bughunter { .. }
|
||||
| SlashCommand::Commit
|
||||
| SlashCommand::Pr { .. }
|
||||
| SlashCommand::Issue { .. }
|
||||
@@ -5411,13 +5339,14 @@ pub fn handle_slash_command(
|
||||
| SlashCommand::Model { .. }
|
||||
| SlashCommand::Permissions { .. }
|
||||
| SlashCommand::Clear { .. }
|
||||
| SlashCommand::Cost
|
||||
| SlashCommand::Resume { .. }
|
||||
| SlashCommand::Config { .. }
|
||||
| SlashCommand::Workspace { .. }
|
||||
| SlashCommand::Mcp { .. }
|
||||
| SlashCommand::Memory
|
||||
| SlashCommand::Init
|
||||
| SlashCommand::Diff
|
||||
| SlashCommand::Version
|
||||
| SlashCommand::Export { .. }
|
||||
| SlashCommand::Session { .. }
|
||||
| SlashCommand::Plugins { .. }
|
||||
@@ -6190,24 +6119,6 @@ mod tests {
|
||||
assert_eq!(suggest_slash_commands("zzz", 3), Vec::<String>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_workspace_slash_command_and_alias() {
|
||||
let workspace = validate_slash_command_input("/workspace")
|
||||
.expect("workspace should parse")
|
||||
.expect("workspace should be a slash command");
|
||||
assert_eq!(workspace, SlashCommand::Workspace { path: None });
|
||||
|
||||
let cwd = validate_slash_command_input("/cwd src")
|
||||
.expect("cwd alias should parse")
|
||||
.expect("cwd alias should be a slash command");
|
||||
assert_eq!(
|
||||
cwd,
|
||||
SlashCommand::Workspace {
|
||||
path: Some("src".to_string()),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compacts_sessions_via_slash_command() {
|
||||
let mut session = Session::new();
|
||||
@@ -6256,6 +6167,7 @@ mod tests {
|
||||
fn ignores_unknown_or_runtime_bound_slash_commands() {
|
||||
let session = Session::new();
|
||||
assert!(handle_slash_command("/unknown", &session, CompactionConfig::default()).is_none());
|
||||
assert!(handle_slash_command("/status", &session, CompactionConfig::default()).is_none());
|
||||
assert!(handle_slash_command("/sandbox", &session, CompactionConfig::default()).is_none());
|
||||
assert!(
|
||||
handle_slash_command("/bughunter", &session, CompactionConfig::default()).is_none()
|
||||
@@ -6287,6 +6199,7 @@ mod tests {
|
||||
handle_slash_command("/clear --confirm", &session, CompactionConfig::default())
|
||||
.is_none()
|
||||
);
|
||||
assert!(handle_slash_command("/cost", &session, CompactionConfig::default()).is_none());
|
||||
assert!(handle_slash_command(
|
||||
"/resume session.json",
|
||||
&session,
|
||||
@@ -6305,6 +6218,7 @@ mod tests {
|
||||
);
|
||||
assert!(handle_slash_command("/mcp list", &session, CompactionConfig::default()).is_none());
|
||||
assert!(handle_slash_command("/diff", &session, CompactionConfig::default()).is_none());
|
||||
assert!(handle_slash_command("/version", &session, CompactionConfig::default()).is_none());
|
||||
assert!(
|
||||
handle_slash_command("/export note.txt", &session, CompactionConfig::default())
|
||||
.is_none()
|
||||
@@ -7266,79 +7180,4 @@ mod tests {
|
||||
let _ = fs::remove_dir_all(config_home);
|
||||
let _ = fs::remove_dir_all(bundled_root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_command_shows_session_info() {
|
||||
use runtime::TokenUsage;
|
||||
|
||||
let mut session = Session::new();
|
||||
session.messages.push(ConversationMessage {
|
||||
role: MessageRole::User,
|
||||
blocks: vec![ContentBlock::Text {
|
||||
text: "hello".to_string(),
|
||||
}],
|
||||
usage: Some(TokenUsage {
|
||||
input_tokens: 10,
|
||||
output_tokens: 20,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
}),
|
||||
});
|
||||
session.messages.push(ConversationMessage {
|
||||
role: MessageRole::Assistant,
|
||||
blocks: vec![ContentBlock::Text {
|
||||
text: "response".to_string(),
|
||||
}],
|
||||
usage: Some(TokenUsage {
|
||||
input_tokens: 5,
|
||||
output_tokens: 15,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
}),
|
||||
});
|
||||
|
||||
let result = handle_slash_command("/status", &session, CompactionConfig::default())
|
||||
.expect("/status should be handled");
|
||||
assert!(result.message.contains("2 messages"));
|
||||
assert!(result.message.contains("15 input tokens"));
|
||||
assert!(result.message.contains("35 output tokens"));
|
||||
assert_eq!(result.session, session);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_command_shows_token_usage() {
|
||||
use runtime::TokenUsage;
|
||||
|
||||
let mut session = Session::new();
|
||||
session.messages.push(ConversationMessage {
|
||||
role: MessageRole::User,
|
||||
blocks: vec![ContentBlock::Text {
|
||||
text: "hello".to_string(),
|
||||
}],
|
||||
usage: Some(TokenUsage {
|
||||
input_tokens: 100,
|
||||
output_tokens: 200,
|
||||
cache_creation_input_tokens: 50,
|
||||
cache_read_input_tokens: 25,
|
||||
}),
|
||||
});
|
||||
|
||||
let result = handle_slash_command("/cost", &session, CompactionConfig::default())
|
||||
.expect("/cost should be handled");
|
||||
assert!(result.message.contains("100"));
|
||||
assert!(result.message.contains("200"));
|
||||
assert!(result.message.contains("50"));
|
||||
assert!(result.message.contains("25"));
|
||||
assert!(result.message.contains("Cumulative token usage"));
|
||||
assert_eq!(result.session, session);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_command_shows_version() {
|
||||
let session = Session::new();
|
||||
let result = handle_slash_command("/version", &session, CompactionConfig::default())
|
||||
.expect("/version should be handled");
|
||||
assert!(result.message.contains("claw-code version"));
|
||||
assert_eq!(result.session, session);
|
||||
}
|
||||
}
|
||||
@@ -13,16 +13,6 @@ use tokio::task::JoinHandle;
|
||||
pub const SCENARIO_PREFIX: &str = "PARITY_SCENARIO:";
|
||||
pub const DEFAULT_MODEL: &str = "claude-sonnet-4-6";
|
||||
|
||||
/// The assistant text the `context_window_retry` scenario finally streams, after
|
||||
/// its first request was refused with a context-overflow error. Exposed so a
|
||||
/// test can assert on the exact body instead of restating the literal.
|
||||
pub const CONTEXT_WINDOW_RETRY_TEXT: &str = "auto compact retry parity complete.";
|
||||
|
||||
/// The partial assistant text the `stream_then_error` scenario streams before it
|
||||
/// fails the response mid-stream. Exposed so a test can assert on the exact body
|
||||
/// instead of restating the literal.
|
||||
pub const STREAM_THEN_ERROR_TEXT: &str = "partial response before the stream failed";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CapturedRequest {
|
||||
pub method: String,
|
||||
@@ -109,8 +99,6 @@ enum Scenario {
|
||||
BashPermissionPromptDenied,
|
||||
PluginToolRoundtrip,
|
||||
AutoCompactTriggered,
|
||||
ContextWindowRetry,
|
||||
StreamThenError,
|
||||
TokenCostReporting,
|
||||
}
|
||||
|
||||
@@ -128,8 +116,6 @@ impl Scenario {
|
||||
"bash_permission_prompt_denied" => Some(Self::BashPermissionPromptDenied),
|
||||
"plugin_tool_roundtrip" => Some(Self::PluginToolRoundtrip),
|
||||
"auto_compact_triggered" => Some(Self::AutoCompactTriggered),
|
||||
"context_window_retry" => Some(Self::ContextWindowRetry),
|
||||
"stream_then_error" => Some(Self::StreamThenError),
|
||||
"token_cost_reporting" => Some(Self::TokenCostReporting),
|
||||
_ => None,
|
||||
}
|
||||
@@ -148,8 +134,6 @@ impl Scenario {
|
||||
Self::BashPermissionPromptDenied => "bash_permission_prompt_denied",
|
||||
Self::PluginToolRoundtrip => "plugin_tool_roundtrip",
|
||||
Self::AutoCompactTriggered => "auto_compact_triggered",
|
||||
Self::ContextWindowRetry => "context_window_retry",
|
||||
Self::StreamThenError => "stream_then_error",
|
||||
Self::TokenCostReporting => "token_cost_reporting",
|
||||
}
|
||||
}
|
||||
@@ -165,18 +149,6 @@ async fn handle_connection(
|
||||
let scenario = detect_scenario(&request)
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "missing parity scenario"))?;
|
||||
|
||||
// How many times this scenario has already been served *on this endpoint*.
|
||||
// Scenarios that need to behave differently on a retry (reject once, then
|
||||
// succeed) key off this; counting per endpoint keeps the token-counting
|
||||
// preflight on `/v1/messages/count_tokens` from consuming the first attempt.
|
||||
let attempt = {
|
||||
let served = requests.lock().await;
|
||||
served
|
||||
.iter()
|
||||
.filter(|captured| captured.scenario == scenario.name() && captured.path == path)
|
||||
.count()
|
||||
};
|
||||
|
||||
requests.lock().await.push(CapturedRequest {
|
||||
method,
|
||||
path,
|
||||
@@ -186,7 +158,7 @@ async fn handle_connection(
|
||||
raw_body,
|
||||
});
|
||||
|
||||
let response = build_http_response(&request, scenario, attempt);
|
||||
let response = build_http_response(&request, scenario);
|
||||
socket.write_all(response.as_bytes()).await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -336,11 +308,7 @@ fn flatten_tool_result_content(content: &[api::ToolResultContentBlock]) -> Strin
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn build_http_response(request: &MessageRequest, scenario: Scenario, attempt: usize) -> String {
|
||||
if let Some(rejection) = context_window_rejection(scenario, attempt) {
|
||||
return rejection;
|
||||
}
|
||||
|
||||
fn build_http_response(request: &MessageRequest, scenario: Scenario) -> String {
|
||||
let response = if request.stream {
|
||||
let body = build_stream_body(request, scenario);
|
||||
return http_response(
|
||||
@@ -361,37 +329,6 @@ fn build_http_response(request: &MessageRequest, scenario: Scenario, attempt: us
|
||||
)
|
||||
}
|
||||
|
||||
/// The first request of [`Scenario::ContextWindowRetry`] is refused with a
|
||||
/// context-overflow error instead of a streamed answer, so the client's
|
||||
/// auto-compact recovery path runs and the *retry* is what produces the
|
||||
/// response text. Every later request against the same endpoint succeeds.
|
||||
///
|
||||
/// HTTP 400 is deliberately chosen: it is not in the client's retryable status
|
||||
/// set, so the client surfaces the error rather than silently re-sending it —
|
||||
/// which is exactly the situation a real context-overflow rejection creates.
|
||||
/// The body carries the `context_window` marker the client greps for.
|
||||
fn context_window_rejection(scenario: Scenario, attempt: usize) -> Option<String> {
|
||||
if scenario != Scenario::ContextWindowRetry || attempt > 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let body = json!({
|
||||
"type": "error",
|
||||
"error": {
|
||||
"type": "invalid_request_error",
|
||||
"message": "prompt is too long: context_window exceeded for this model",
|
||||
},
|
||||
})
|
||||
.to_string();
|
||||
|
||||
Some(http_response(
|
||||
"400 Bad Request",
|
||||
"application/json",
|
||||
&body,
|
||||
&[("request-id", request_id_for(scenario))],
|
||||
))
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn build_stream_body(request: &MessageRequest, scenario: Scenario) -> String {
|
||||
match scenario {
|
||||
@@ -524,8 +461,6 @@ fn build_stream_body(request: &MessageRequest, scenario: Scenario) -> String {
|
||||
Scenario::AutoCompactTriggered => {
|
||||
final_text_sse_with_usage("auto compact parity complete.", 50_000, 200)
|
||||
}
|
||||
Scenario::ContextWindowRetry => final_text_sse(CONTEXT_WINDOW_RETRY_TEXT),
|
||||
Scenario::StreamThenError => stream_then_error_sse(STREAM_THEN_ERROR_TEXT),
|
||||
Scenario::TokenCostReporting => {
|
||||
final_text_sse_with_usage("token cost reporting parity complete.", 1_000, 500)
|
||||
}
|
||||
@@ -693,12 +628,6 @@ fn build_message_response(request: &MessageRequest, scenario: Scenario) -> Messa
|
||||
50_000,
|
||||
200,
|
||||
),
|
||||
Scenario::ContextWindowRetry => {
|
||||
text_message_response("msg_context_window_retry", CONTEXT_WINDOW_RETRY_TEXT)
|
||||
}
|
||||
Scenario::StreamThenError => {
|
||||
text_message_response("msg_stream_then_error", STREAM_THEN_ERROR_TEXT)
|
||||
}
|
||||
Scenario::TokenCostReporting => text_message_response_with_usage(
|
||||
"msg_token_cost_reporting",
|
||||
"token cost reporting parity complete.",
|
||||
@@ -721,8 +650,6 @@ fn request_id_for(scenario: Scenario) -> &'static str {
|
||||
Scenario::BashPermissionPromptDenied => "req_bash_permission_prompt_denied",
|
||||
Scenario::PluginToolRoundtrip => "req_plugin_tool_roundtrip",
|
||||
Scenario::AutoCompactTriggered => "req_auto_compact_triggered",
|
||||
Scenario::ContextWindowRetry => "req_context_window_retry",
|
||||
Scenario::StreamThenError => "req_stream_then_error",
|
||||
Scenario::TokenCostReporting => "req_token_cost_reporting",
|
||||
}
|
||||
}
|
||||
@@ -1044,84 +971,6 @@ fn final_text_sse(text: &str) -> String {
|
||||
body
|
||||
}
|
||||
|
||||
/// Streams a text block to completion and then fails the response, which is
|
||||
/// what a provider does when it dies partway through an answer.
|
||||
///
|
||||
/// The block is closed before the failure on purpose: the client only flushes
|
||||
/// its buffered markdown to the terminal on `content_block_stop`, so without
|
||||
/// that frame the partial text would still be sitting in the renderer and the
|
||||
/// scenario would not reproduce a turn that streamed text and *then* errored.
|
||||
///
|
||||
/// The response is cut off in the middle of the failing frame rather than
|
||||
/// carrying a complete one. Two things fall out of that, both deliberate.
|
||||
///
|
||||
/// It is what a provider that dies on the socket actually looks like, and it
|
||||
/// keeps the failure in a *later* parser call than the answer text. The whole
|
||||
/// response is one `write_all` on the mock's side, so a complete trailing frame
|
||||
/// would land in the same read chunk as the deltas — and `SseParser::push`
|
||||
/// returns `Err` from inside its frame loop, which discards the events it had
|
||||
/// already parsed from that same chunk. The client would then never render the
|
||||
/// text at all, and the scenario would be exercising that parser behaviour
|
||||
/// instead of the terminal state it is meant to reproduce. A truncated tail
|
||||
/// stays in the parser's buffer, so `push` returns the deltas normally and the
|
||||
/// failure only surfaces from `finish()`, once the client has consumed every
|
||||
/// event ahead of it.
|
||||
///
|
||||
/// The failure is a frame that does not deserialize — `api::types::StreamEvent`
|
||||
/// is `#[serde(tag = "type")]` with no fallback arm, so an unknown frame type or
|
||||
/// a truncated payload is an `ApiError::Json`. That is non-retryable, and it is
|
||||
/// raised while reading the body rather than while acquiring the response, so the
|
||||
/// client reports it on the first attempt rather than retrying.
|
||||
fn stream_then_error_sse(text: &str) -> String {
|
||||
let mut body = String::new();
|
||||
append_sse(
|
||||
&mut body,
|
||||
"message_start",
|
||||
json!({
|
||||
"type": "message_start",
|
||||
"message": {
|
||||
"id": unique_message_id(),
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [],
|
||||
"model": DEFAULT_MODEL,
|
||||
"stop_reason": null,
|
||||
"stop_sequence": null,
|
||||
"usage": usage_json(14, 0)
|
||||
}
|
||||
}),
|
||||
);
|
||||
append_sse(
|
||||
&mut body,
|
||||
"content_block_start",
|
||||
json!({
|
||||
"type": "content_block_start",
|
||||
"index": 0,
|
||||
"content_block": {"type": "text", "text": ""}
|
||||
}),
|
||||
);
|
||||
append_sse(
|
||||
&mut body,
|
||||
"content_block_delta",
|
||||
json!({
|
||||
"type": "content_block_delta",
|
||||
"index": 0,
|
||||
"delta": {"type": "text_delta", "text": text}
|
||||
}),
|
||||
);
|
||||
append_sse(
|
||||
&mut body,
|
||||
"content_block_stop",
|
||||
json!({"type": "content_block_stop", "index": 0}),
|
||||
);
|
||||
// No terminating blank line: the frame is still arriving when the provider
|
||||
// drops the connection, so it stays in the client's parser buffer until the
|
||||
// body ends. See the note above for why the failure has to trail the deltas
|
||||
// by a parser call rather than sharing their chunk.
|
||||
body.push_str("event: error\ndata: {\"type\":\"error\",\"error\":{\"type\":\"overloaded_");
|
||||
body
|
||||
}
|
||||
|
||||
fn final_text_sse_with_usage(text: &str, input_tokens: u32, output_tokens: u32) -> String {
|
||||
let mut body = String::new();
|
||||
append_sse(
|
||||
|
||||
@@ -205,11 +205,6 @@ pub struct RuntimeProviderConfig {
|
||||
}
|
||||
|
||||
impl RuntimeProviderConfig {
|
||||
#[must_use]
|
||||
pub fn is_configured(&self) -> bool {
|
||||
self.kind.is_some()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn kind(&self) -> Option<&str> {
|
||||
self.kind.as_deref()
|
||||
@@ -2663,42 +2658,6 @@ mod tests {
|
||||
std::env::temp_dir().join(format!("runtime-config-{pid}-{nanos}-{seq}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_persisted_provider_configuration_from_settings() {
|
||||
let root = temp_dir();
|
||||
let cwd = root.join("project");
|
||||
let home = root.join("home").join(".claw");
|
||||
fs::create_dir_all(&home).expect("home config dir");
|
||||
fs::create_dir_all(&cwd).expect("project dir");
|
||||
|
||||
fs::write(
|
||||
home.join("settings.json"),
|
||||
r#"{
|
||||
"provider": {
|
||||
"kind": "dashscope",
|
||||
"apiKey": "persisted-test-key",
|
||||
"baseUrl": "https://dashscope.example/v1",
|
||||
"model": "qwen-plus"
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.expect("write settings");
|
||||
|
||||
let loaded = ConfigLoader::new(&cwd, &home)
|
||||
.load()
|
||||
.expect("provider configuration should load");
|
||||
|
||||
let provider = loaded.provider();
|
||||
|
||||
assert_eq!(provider.kind(), Some("dashscope"));
|
||||
assert_eq!(provider.api_key(), Some("persisted-test-key"));
|
||||
assert_eq!(provider.base_url(), Some("https://dashscope.example/v1"));
|
||||
assert_eq!(provider.model(), Some("qwen-plus"));
|
||||
assert!(provider.is_configured());
|
||||
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_object_settings_files() {
|
||||
let root = temp_dir();
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt::{Display, Formatter};
|
||||
use std::sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc,
|
||||
};
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
use telemetry::SessionTracer;
|
||||
@@ -57,41 +53,6 @@ pub struct PromptCacheEvent {
|
||||
pub token_drop: u32,
|
||||
}
|
||||
|
||||
/// Shared flag used to request graceful interruption of a running turn.
|
||||
///
|
||||
/// Cloning shares the underlying flag, mirroring
|
||||
/// [`HookAbortSignal`](crate::hooks::HookAbortSignal). An input listener
|
||||
/// (e.g. Esc or Ctrl+C handling in the CLI) sets the flag while the
|
||||
/// conversation loop and the streaming API client poll it at safe points.
|
||||
/// When the flag is observed, the turn winds down without treating the
|
||||
/// stop as a failure: pending tool calls receive synthesized error
|
||||
/// results so the session stays consistent, and [`TurnSummary`] reports
|
||||
/// `interrupted: true`.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct TurnInterruptSignal {
|
||||
interrupted: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl TurnInterruptSignal {
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn interrupt(&self) {
|
||||
self.interrupted.store(true, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_interrupted(&self) -> bool {
|
||||
self.interrupted.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
pub fn reset(&self) {
|
||||
self.interrupted.store(false, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal streaming API contract required by [`ConversationRuntime`].
|
||||
pub trait ApiClient {
|
||||
fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError>;
|
||||
@@ -157,7 +118,6 @@ pub struct TurnSummary {
|
||||
pub iterations: usize,
|
||||
pub usage: TokenUsage,
|
||||
pub auto_compaction: Option<AutoCompactionEvent>,
|
||||
pub interrupted: bool,
|
||||
}
|
||||
|
||||
/// Details about automatic session compaction applied during a turn.
|
||||
@@ -178,7 +138,6 @@ pub struct ConversationRuntime<C, T> {
|
||||
hook_runner: HookRunner,
|
||||
auto_compaction_input_tokens_threshold: u32,
|
||||
hook_abort_signal: HookAbortSignal,
|
||||
turn_interrupt_signal: TurnInterruptSignal,
|
||||
hook_progress_reporter: Option<Box<dyn HookProgressReporter>>,
|
||||
session_tracer: Option<SessionTracer>,
|
||||
}
|
||||
@@ -233,7 +192,6 @@ where
|
||||
hook_runner,
|
||||
auto_compaction_input_tokens_threshold: auto_compaction_threshold_from_env(),
|
||||
hook_abort_signal: HookAbortSignal::default(),
|
||||
turn_interrupt_signal: TurnInterruptSignal::default(),
|
||||
hook_progress_reporter: None,
|
||||
session_tracer: None,
|
||||
}
|
||||
@@ -264,15 +222,6 @@ where
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_turn_interrupt_signal(
|
||||
mut self,
|
||||
turn_interrupt_signal: TurnInterruptSignal,
|
||||
) -> Self {
|
||||
self.turn_interrupt_signal = turn_interrupt_signal;
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_hook_progress_reporter(
|
||||
mut self,
|
||||
@@ -406,14 +355,8 @@ where
|
||||
let mut prompt_cache_events = Vec::new();
|
||||
let mut iterations = 0;
|
||||
let mut auto_compaction = None;
|
||||
let mut interrupted = false;
|
||||
|
||||
loop {
|
||||
if self.turn_interrupt_signal.is_interrupted() {
|
||||
self.record_turn_interrupted(iterations, "before_request");
|
||||
interrupted = true;
|
||||
break;
|
||||
}
|
||||
iterations += 1;
|
||||
if iterations > self.max_iterations {
|
||||
let error = RuntimeError::new(
|
||||
@@ -430,14 +373,6 @@ where
|
||||
let events = match self.api_client.stream(request) {
|
||||
Ok(events) => events,
|
||||
Err(error) => {
|
||||
if self.turn_interrupt_signal.is_interrupted() {
|
||||
// The client aborted because the user interrupted the
|
||||
// turn; any partial response is discarded and the stop
|
||||
// is reported as an interruption rather than a failure.
|
||||
self.record_turn_interrupted(iterations, "during_request");
|
||||
interrupted = true;
|
||||
break;
|
||||
}
|
||||
self.record_turn_failed(iterations, &error);
|
||||
return Err(error);
|
||||
}
|
||||
@@ -486,25 +421,6 @@ where
|
||||
}
|
||||
|
||||
for (tool_use_id, tool_name, input) in pending_tool_uses {
|
||||
if interrupted || self.turn_interrupt_signal.is_interrupted() {
|
||||
// Every pending tool_use must still receive a tool_result
|
||||
// so the session stays valid for the next request.
|
||||
if !interrupted {
|
||||
self.record_turn_interrupted(iterations, "before_tool");
|
||||
interrupted = true;
|
||||
}
|
||||
let result_message = ConversationMessage::tool_result(
|
||||
tool_use_id,
|
||||
tool_name,
|
||||
"Interrupted by user before this tool could run.",
|
||||
true,
|
||||
);
|
||||
self.session
|
||||
.push_message(result_message.clone())
|
||||
.map_err(|error| RuntimeError::new(error.to_string()))?;
|
||||
tool_results.push(result_message);
|
||||
continue;
|
||||
}
|
||||
let pre_hook_result = self.run_pre_tool_use_hook(&tool_name, &input);
|
||||
let effective_input = pre_hook_result
|
||||
.updated_input()
|
||||
@@ -604,10 +520,6 @@ where
|
||||
self.record_tool_finished(iterations, &result_message);
|
||||
tool_results.push(result_message);
|
||||
}
|
||||
|
||||
if interrupted {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let summary = TurnSummary {
|
||||
@@ -617,11 +529,8 @@ where
|
||||
iterations,
|
||||
usage: self.usage_tracker.cumulative_usage(),
|
||||
auto_compaction,
|
||||
interrupted,
|
||||
};
|
||||
if !interrupted {
|
||||
self.record_turn_completed(&summary);
|
||||
}
|
||||
self.record_turn_completed(&summary);
|
||||
|
||||
Ok(summary)
|
||||
}
|
||||
@@ -785,17 +694,6 @@ where
|
||||
session_tracer.record("turn_completed", attributes);
|
||||
}
|
||||
|
||||
fn record_turn_interrupted(&self, iteration: usize, phase: &str) {
|
||||
let Some(session_tracer) = &self.session_tracer else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut attributes = Map::new();
|
||||
attributes.insert("iteration".to_string(), Value::from(iteration as u64));
|
||||
attributes.insert("phase".to_string(), Value::String(phase.to_string()));
|
||||
session_tracer.record("turn_interrupted", attributes);
|
||||
}
|
||||
|
||||
fn record_turn_failed(&self, iteration: usize, error: &RuntimeError) {
|
||||
let Some(session_tracer) = &self.session_tracer else {
|
||||
return;
|
||||
@@ -957,8 +855,7 @@ mod tests {
|
||||
use super::{
|
||||
build_assistant_message, parse_auto_compaction_threshold, ApiClient, ApiRequest,
|
||||
AssistantEvent, AutoCompactionEvent, ConversationRuntime, PromptCacheEvent, RuntimeError,
|
||||
StaticToolExecutor, ToolExecutor, TurnInterruptSignal,
|
||||
DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD,
|
||||
StaticToolExecutor, ToolExecutor, DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD,
|
||||
};
|
||||
use crate::compact::CompactionConfig;
|
||||
use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig};
|
||||
@@ -2059,165 +1956,4 @@ mod tests {
|
||||
// then
|
||||
assert_eq!(error.to_string(), "upstream failed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interrupt_before_first_request_skips_the_api_call() {
|
||||
struct UnreachableApi;
|
||||
|
||||
impl ApiClient for UnreachableApi {
|
||||
fn stream(
|
||||
&mut self,
|
||||
_request: ApiRequest,
|
||||
) -> Result<Vec<AssistantEvent>, RuntimeError> {
|
||||
unreachable!("interrupted turn must not reach the API")
|
||||
}
|
||||
}
|
||||
|
||||
// given
|
||||
let interrupt = TurnInterruptSignal::new();
|
||||
interrupt.interrupt();
|
||||
let mut runtime = ConversationRuntime::new(
|
||||
Session::new(),
|
||||
UnreachableApi,
|
||||
StaticToolExecutor::new(),
|
||||
PermissionPolicy::new(PermissionMode::DangerFullAccess),
|
||||
vec!["system".to_string()],
|
||||
)
|
||||
.with_turn_interrupt_signal(interrupt);
|
||||
|
||||
// when
|
||||
let summary = runtime
|
||||
.run_turn("hello", None)
|
||||
.expect("interruption should not be reported as a failure");
|
||||
|
||||
// then
|
||||
assert!(summary.interrupted);
|
||||
assert_eq!(summary.iterations, 0);
|
||||
assert!(summary.assistant_messages.is_empty());
|
||||
assert!(summary.tool_results.is_empty());
|
||||
assert_eq!(summary.auto_compaction, None);
|
||||
assert_eq!(runtime.session().messages.len(), 1);
|
||||
assert_eq!(runtime.session().messages[0].role, MessageRole::User);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interrupt_after_stream_synthesizes_results_for_pending_tools() {
|
||||
struct ToolUseApi {
|
||||
interrupt: TurnInterruptSignal,
|
||||
}
|
||||
|
||||
impl ApiClient for ToolUseApi {
|
||||
fn stream(
|
||||
&mut self,
|
||||
_request: ApiRequest,
|
||||
) -> Result<Vec<AssistantEvent>, RuntimeError> {
|
||||
// Simulate the user pressing Esc while the response streams in.
|
||||
self.interrupt.interrupt();
|
||||
Ok(vec![
|
||||
AssistantEvent::TextDelta("Running the tool.".to_string()),
|
||||
AssistantEvent::ToolUse {
|
||||
id: "tool-1".to_string(),
|
||||
name: "add".to_string(),
|
||||
input: "2,2".to_string(),
|
||||
},
|
||||
AssistantEvent::MessageStop,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
// given
|
||||
let interrupt = TurnInterruptSignal::new();
|
||||
let mut runtime = ConversationRuntime::new(
|
||||
Session::new(),
|
||||
ToolUseApi {
|
||||
interrupt: interrupt.clone(),
|
||||
},
|
||||
StaticToolExecutor::new()
|
||||
.register("add", |_input| panic!("interrupted tool must not run")),
|
||||
PermissionPolicy::new(PermissionMode::DangerFullAccess),
|
||||
vec!["system".to_string()],
|
||||
)
|
||||
.with_turn_interrupt_signal(interrupt);
|
||||
|
||||
// when
|
||||
let summary = runtime
|
||||
.run_turn("what is 2 + 2?", None)
|
||||
.expect("interruption should not be reported as a failure");
|
||||
|
||||
// then
|
||||
assert!(summary.interrupted);
|
||||
assert_eq!(summary.iterations, 1);
|
||||
assert_eq!(summary.assistant_messages.len(), 1);
|
||||
assert_eq!(summary.tool_results.len(), 1);
|
||||
assert!(matches!(
|
||||
&summary.tool_results[0].blocks[0],
|
||||
ContentBlock::ToolResult {
|
||||
tool_use_id,
|
||||
is_error: true,
|
||||
output,
|
||||
..
|
||||
} if tool_use_id == "tool-1" && output.contains("Interrupted by user")
|
||||
));
|
||||
// user text, assistant tool_use, synthesized tool_result
|
||||
assert_eq!(runtime.session().messages.len(), 3);
|
||||
assert!(matches!(
|
||||
runtime.session().messages[2].blocks[0],
|
||||
ContentBlock::ToolResult { is_error: true, .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_error_during_interrupt_is_reported_as_interruption() {
|
||||
struct AbortedApi {
|
||||
interrupt: TurnInterruptSignal,
|
||||
}
|
||||
|
||||
impl ApiClient for AbortedApi {
|
||||
fn stream(
|
||||
&mut self,
|
||||
_request: ApiRequest,
|
||||
) -> Result<Vec<AssistantEvent>, RuntimeError> {
|
||||
// Simulate the streaming client aborting the connection after
|
||||
// observing the interrupt flag mid-stream.
|
||||
self.interrupt.interrupt();
|
||||
Err(RuntimeError::new("request aborted"))
|
||||
}
|
||||
}
|
||||
|
||||
// given
|
||||
let sink = Arc::new(MemoryTelemetrySink::default());
|
||||
let tracer = SessionTracer::new("session-interrupt", sink.clone());
|
||||
let interrupt = TurnInterruptSignal::new();
|
||||
let mut runtime = ConversationRuntime::new(
|
||||
Session::new(),
|
||||
AbortedApi {
|
||||
interrupt: interrupt.clone(),
|
||||
},
|
||||
StaticToolExecutor::new(),
|
||||
PermissionPolicy::new(PermissionMode::DangerFullAccess),
|
||||
vec!["system".to_string()],
|
||||
)
|
||||
.with_turn_interrupt_signal(interrupt)
|
||||
.with_session_tracer(tracer);
|
||||
|
||||
// when
|
||||
let summary = runtime
|
||||
.run_turn("hello", None)
|
||||
.expect("interrupt-driven aborts should not surface as errors");
|
||||
|
||||
// then
|
||||
assert!(summary.interrupted);
|
||||
assert!(summary.assistant_messages.is_empty());
|
||||
let trace_names = sink
|
||||
.events()
|
||||
.iter()
|
||||
.filter_map(|event| match event {
|
||||
TelemetryEvent::SessionTrace(trace) => Some(trace.name.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert!(trace_names.iter().any(|name| name == "turn_interrupted"));
|
||||
assert!(!trace_names.iter().any(|name| name == "turn_failed"));
|
||||
assert!(!trace_names.iter().any(|name| name == "turn_completed"));
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,6 @@ use regex::RegexBuilder;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use walkdir::{DirEntry, WalkDir};
|
||||
|
||||
use crate::line_range_selector::{self, LineRangeSelector};
|
||||
|
||||
/// Maximum file size that can be read (10 MB).
|
||||
const MAX_READ_SIZE: u64 = 10 * 1024 * 1024;
|
||||
|
||||
@@ -90,9 +88,6 @@ pub struct ReadFileOutput {
|
||||
#[serde(rename = "type")]
|
||||
pub kind: String,
|
||||
pub file: TextFilePayload,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "truncationFooter")]
|
||||
pub truncation_footer: Option<String>,
|
||||
}
|
||||
|
||||
/// Structured patch hunk emitted by write and edit operations.
|
||||
@@ -206,19 +201,6 @@ pub fn read_file(
|
||||
path: &str,
|
||||
offset: Option<usize>,
|
||||
limit: Option<usize>,
|
||||
) -> io::Result<ReadFileOutput> {
|
||||
read_file_with_selector(path, offset, limit, None)
|
||||
}
|
||||
|
||||
/// Reads a text file with optional GJC-style line range selector.
|
||||
///
|
||||
/// The selector takes precedence over offset/limit if provided.
|
||||
/// Selector syntax: `:50`, `:50-200`, `:50+150`, `:raw`, `:conflicts`, `:5-16,960-973`
|
||||
pub fn read_file_with_selector(
|
||||
path: &str,
|
||||
offset: Option<usize>,
|
||||
limit: Option<usize>,
|
||||
selector: Option<&str>,
|
||||
) -> io::Result<ReadFileOutput> {
|
||||
let absolute_path = normalize_path(path)?;
|
||||
|
||||
@@ -245,28 +227,11 @@ pub fn read_file_with_selector(
|
||||
|
||||
let content = fs::read_to_string(&absolute_path)?;
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
let total_lines = lines.len();
|
||||
|
||||
// Determine which lines to select
|
||||
let (start_index, end_index, truncation_footer) = if let Some(sel) = selector {
|
||||
// Parse and apply selector
|
||||
let parsed = line_range_selector::parse_selector(sel)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e.to_string()))?;
|
||||
apply_line_selector(&lines, parsed, path)
|
||||
} else {
|
||||
// Use offset/limit for backward compatibility
|
||||
let start = offset.unwrap_or(0).min(lines.len());
|
||||
let end = limit.map_or(lines.len(), |lim| {
|
||||
start.saturating_add(lim).min(lines.len())
|
||||
});
|
||||
(start, end, None)
|
||||
};
|
||||
|
||||
let selected = if start_index < end_index {
|
||||
lines[start_index..end_index].join("\n")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let start_index = offset.unwrap_or(0).min(lines.len());
|
||||
let end_index = limit.map_or(lines.len(), |limit| {
|
||||
start_index.saturating_add(limit).min(lines.len())
|
||||
});
|
||||
let selected = lines[start_index..end_index].join("\n");
|
||||
|
||||
Ok(ReadFileOutput {
|
||||
kind: String::from("text"),
|
||||
@@ -275,66 +240,11 @@ pub fn read_file_with_selector(
|
||||
content: selected,
|
||||
num_lines: end_index.saturating_sub(start_index),
|
||||
start_line: start_index.saturating_add(1),
|
||||
total_lines,
|
||||
total_lines: lines.len(),
|
||||
},
|
||||
truncation_footer,
|
||||
})
|
||||
}
|
||||
|
||||
/// Apply a parsed line selector to file lines and return the selected range.
|
||||
///
|
||||
/// Returns (start_index, end_index, truncation_footer).
|
||||
fn apply_line_selector(
|
||||
lines: &[&str],
|
||||
selector: LineRangeSelector,
|
||||
_file_path: &str,
|
||||
) -> (usize, usize, Option<String>) {
|
||||
match selector {
|
||||
LineRangeSelector::Raw => {
|
||||
// Return entire file without truncation footer for raw mode
|
||||
(0, lines.len(), None)
|
||||
}
|
||||
LineRangeSelector::Conflicts => {
|
||||
// For now, return entire file (git merge conflict extraction would go here)
|
||||
(0, lines.len(), None)
|
||||
}
|
||||
LineRangeSelector::Lines(ranges) => {
|
||||
// Collect all lines specified by ranges
|
||||
let mut selected_indices = std::collections::BTreeSet::new();
|
||||
for range in ranges {
|
||||
let (start_zero, end_zero) = range.zero_indexed();
|
||||
for i in start_zero..=end_zero.min(lines.len().saturating_sub(1)) {
|
||||
selected_indices.insert(i);
|
||||
}
|
||||
}
|
||||
|
||||
if selected_indices.is_empty() {
|
||||
return (0, 0, None);
|
||||
}
|
||||
|
||||
// Find contiguous blocks and return the first one (or could merge all)
|
||||
let indices: Vec<usize> = selected_indices.iter().copied().collect();
|
||||
let first = indices[0];
|
||||
let last = indices[indices.len() - 1];
|
||||
|
||||
// Check if it's a contiguous range
|
||||
let is_contiguous = (last - first + 1) == indices.len();
|
||||
|
||||
if is_contiguous {
|
||||
(first, last + 1, None)
|
||||
} else {
|
||||
// Return all selected indices as one continuous block from first to last
|
||||
// In the future, this could be improved to show multiple blocks
|
||||
(first, last + 1, None)
|
||||
}
|
||||
}
|
||||
LineRangeSelector::None => {
|
||||
// No selector, return entire file
|
||||
(0, lines.len(), None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Replaces a file's contents and returns patch metadata.
|
||||
pub fn write_file(path: &str, content: &str) -> io::Result<WriteFileOutput> {
|
||||
if content.len() > MAX_WRITE_SIZE {
|
||||
@@ -805,23 +715,11 @@ pub fn read_file_in_workspace(
|
||||
offset: Option<usize>,
|
||||
limit: Option<usize>,
|
||||
workspace_root: &Path,
|
||||
) -> io::Result<ReadFileOutput> {
|
||||
read_file_in_workspace_with_selector(path, offset, limit, None, workspace_root)
|
||||
}
|
||||
|
||||
/// Read a file with workspace boundary enforcement and optional line range selector.
|
||||
#[allow(dead_code)]
|
||||
pub fn read_file_in_workspace_with_selector(
|
||||
path: &str,
|
||||
offset: Option<usize>,
|
||||
limit: Option<usize>,
|
||||
selector: Option<&str>,
|
||||
workspace_root: &Path,
|
||||
) -> io::Result<ReadFileOutput> {
|
||||
let absolute_path = normalize_path(path)?;
|
||||
let canonical_root = canonicalize_workspace_root(workspace_root);
|
||||
validate_workspace_boundary(&absolute_path, &canonical_root)?;
|
||||
read_file_with_selector(path, offset, limit, selector)
|
||||
read_file(path, offset, limit)
|
||||
}
|
||||
|
||||
/// Write a file with workspace boundary enforcement.
|
||||
@@ -1285,119 +1183,4 @@ mod tests {
|
||||
assert!(component_contains_glob("*.rs"));
|
||||
assert!(!component_contains_glob("src"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_with_selector_single_line() {
|
||||
let path = temp_path("selector-single.txt");
|
||||
write_file(
|
||||
path.to_string_lossy().as_ref(),
|
||||
"line1\nline2\nline3\nline4\nline5",
|
||||
)
|
||||
.expect("write should succeed");
|
||||
|
||||
let output =
|
||||
super::read_file_with_selector(path.to_string_lossy().as_ref(), None, None, Some("2"))
|
||||
.expect("read with selector should succeed");
|
||||
assert_eq!(output.file.content, "line2");
|
||||
assert_eq!(output.file.start_line, 2);
|
||||
assert_eq!(output.file.num_lines, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_with_selector_range() {
|
||||
let path = temp_path("selector-range.txt");
|
||||
write_file(
|
||||
path.to_string_lossy().as_ref(),
|
||||
"line1\nline2\nline3\nline4\nline5",
|
||||
)
|
||||
.expect("write should succeed");
|
||||
|
||||
let output = super::read_file_with_selector(
|
||||
path.to_string_lossy().as_ref(),
|
||||
None,
|
||||
None,
|
||||
Some("2-4"),
|
||||
)
|
||||
.expect("read with selector should succeed");
|
||||
assert_eq!(output.file.content, "line2\nline3\nline4");
|
||||
assert_eq!(output.file.start_line, 2);
|
||||
assert_eq!(output.file.num_lines, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_with_selector_plus_count() {
|
||||
let path = temp_path("selector-plus.txt");
|
||||
write_file(
|
||||
path.to_string_lossy().as_ref(),
|
||||
"line1\nline2\nline3\nline4\nline5",
|
||||
)
|
||||
.expect("write should succeed");
|
||||
|
||||
let output = super::read_file_with_selector(
|
||||
path.to_string_lossy().as_ref(),
|
||||
None,
|
||||
None,
|
||||
Some("2+2"),
|
||||
)
|
||||
.expect("read with selector should succeed");
|
||||
assert_eq!(output.file.content, "line2\nline3");
|
||||
assert_eq!(output.file.start_line, 2);
|
||||
assert_eq!(output.file.num_lines, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_with_selector_raw() {
|
||||
let path = temp_path("selector-raw.txt");
|
||||
write_file(
|
||||
path.to_string_lossy().as_ref(),
|
||||
"line1\nline2\nline3\nline4\nline5",
|
||||
)
|
||||
.expect("write should succeed");
|
||||
|
||||
let output = super::read_file_with_selector(
|
||||
path.to_string_lossy().as_ref(),
|
||||
None,
|
||||
None,
|
||||
Some("raw"),
|
||||
)
|
||||
.expect("read with selector should succeed");
|
||||
assert_eq!(output.file.content, "line1\nline2\nline3\nline4\nline5");
|
||||
assert_eq!(output.file.start_line, 1);
|
||||
assert_eq!(output.file.num_lines, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_with_selector_none() {
|
||||
let path = temp_path("selector-none.txt");
|
||||
write_file(
|
||||
path.to_string_lossy().as_ref(),
|
||||
"line1\nline2\nline3\nline4\nline5",
|
||||
)
|
||||
.expect("write should succeed");
|
||||
|
||||
let output =
|
||||
super::read_file_with_selector(path.to_string_lossy().as_ref(), None, None, None)
|
||||
.expect("read with selector should succeed");
|
||||
assert_eq!(output.file.content, "line1\nline2\nline3\nline4\nline5");
|
||||
assert_eq!(output.file.start_line, 1);
|
||||
assert_eq!(output.file.num_lines, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_with_invalid_selector() {
|
||||
let path = temp_path("selector-invalid.txt");
|
||||
write_file(
|
||||
path.to_string_lossy().as_ref(),
|
||||
"line1\nline2\nline3\nline4\nline5",
|
||||
)
|
||||
.expect("write should succeed");
|
||||
|
||||
let result = super::read_file_with_selector(
|
||||
path.to_string_lossy().as_ref(),
|
||||
None,
|
||||
None,
|
||||
Some("invalid-selector"),
|
||||
);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,6 @@ pub mod green_contract;
|
||||
mod hooks;
|
||||
mod json;
|
||||
mod lane_events;
|
||||
pub mod line_range_selector;
|
||||
pub mod lsp_client;
|
||||
mod mcp;
|
||||
mod mcp_client;
|
||||
@@ -84,12 +83,11 @@ pub use config_validate::{
|
||||
pub use conversation::{
|
||||
auto_compaction_threshold_from_env, ApiClient, ApiRequest, AssistantEvent, AutoCompactionEvent,
|
||||
ConversationRuntime, PromptCacheEvent, RuntimeError, StaticToolExecutor, ToolError,
|
||||
ToolExecutor, TurnInterruptSignal, TurnSummary,
|
||||
ToolExecutor, TurnSummary,
|
||||
};
|
||||
pub use file_ops::{
|
||||
edit_file, edit_file_in_workspace, glob_search, glob_search_in_workspace, grep_search,
|
||||
grep_search_in_workspace, read_file, read_file_in_workspace,
|
||||
read_file_in_workspace_with_selector, read_file_with_selector, write_file,
|
||||
grep_search_in_workspace, read_file, read_file_in_workspace, write_file,
|
||||
write_file_in_workspace, EditFileOutput, GlobSearchOutput, GrepSearchInput, GrepSearchOutput,
|
||||
ReadFileOutput, StructuredPatchHunk, TextFilePayload, WriteFileOutput,
|
||||
};
|
||||
|
||||
@@ -1,347 +0,0 @@
|
||||
/// GJC-compatible line range selector parsing for the read tool.
|
||||
///
|
||||
/// Supports selector syntax:
|
||||
/// - `:50` = start at line 50
|
||||
/// - `:50-200` = lines 50-200 inclusive
|
||||
/// - `:50+150` = 150 lines starting at 50
|
||||
/// - `:50+1` = exactly one line
|
||||
/// - `:5-16,960-973` = multiple ranges
|
||||
/// - `:raw` = full verbatim
|
||||
/// - `:conflicts` = git merge conflicts only
|
||||
use std::num::ParseIntError;
|
||||
|
||||
/// Parsed line range specification.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum LineRangeSelector {
|
||||
/// Full file verbatim (`:raw`)
|
||||
Raw,
|
||||
/// Git merge conflict sections only (`:conflicts`)
|
||||
Conflicts,
|
||||
/// Line range(s) (`:L-L+N` syntax)
|
||||
Lines(Vec<LineRange>),
|
||||
/// No selector (full file, may be truncated by default budget)
|
||||
None,
|
||||
}
|
||||
|
||||
/// A single line range, zero-indexed internally but displayed as 1-indexed.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct LineRange {
|
||||
/// Start line (1-indexed, user-facing)
|
||||
pub start_line: usize,
|
||||
/// End line inclusive (1-indexed, user-facing)
|
||||
pub end_line: usize,
|
||||
}
|
||||
|
||||
impl LineRange {
|
||||
/// Create a new line range (1-indexed).
|
||||
pub fn new(start_line: usize, end_line: usize) -> Self {
|
||||
Self {
|
||||
start_line,
|
||||
end_line,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert to zero-indexed range for internal use.
|
||||
pub fn zero_indexed(&self) -> (usize, usize) {
|
||||
(
|
||||
self.start_line.saturating_sub(1),
|
||||
self.end_line.saturating_sub(1),
|
||||
)
|
||||
}
|
||||
|
||||
/// Get the number of lines in this range.
|
||||
pub fn len(&self) -> usize {
|
||||
if self.start_line <= self.end_line {
|
||||
self.end_line - self.start_line + 1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this range is valid.
|
||||
pub fn is_valid(&self) -> bool {
|
||||
self.start_line > 0 && self.end_line > 0 && self.start_line <= self.end_line
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse error types for line range selectors.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ParseError {
|
||||
InvalidFormat(String),
|
||||
InvalidNumber(ParseIntError),
|
||||
InvalidRange(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ParseError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
ParseError::InvalidFormat(msg) => write!(f, "Invalid format: {}", msg),
|
||||
ParseError::InvalidNumber(e) => write!(f, "Invalid number: {}", e),
|
||||
ParseError::InvalidRange(msg) => write!(f, "Invalid range: {}", msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ParseError {}
|
||||
|
||||
impl From<ParseIntError> for ParseError {
|
||||
fn from(e: ParseIntError) -> Self {
|
||||
ParseError::InvalidNumber(e)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a line range selector string.
|
||||
///
|
||||
/// Examples:
|
||||
/// - `"50"` → `LineRangeSelector::Lines(vec![LineRange::new(50, 50)])`
|
||||
/// - `"50-200"` → `LineRangeSelector::Lines(vec![LineRange::new(50, 200)])`
|
||||
/// - `"50+150"` → `LineRangeSelector::Lines(vec![LineRange::new(50, 199)])`
|
||||
/// - `"raw"` → `LineRangeSelector::Raw`
|
||||
/// - `"conflicts"` → `LineRangeSelector::Conflicts`
|
||||
pub fn parse_selector(selector: &str) -> Result<LineRangeSelector, ParseError> {
|
||||
if selector.is_empty() {
|
||||
return Ok(LineRangeSelector::None);
|
||||
}
|
||||
|
||||
match selector {
|
||||
"raw" => Ok(LineRangeSelector::Raw),
|
||||
"conflicts" => Ok(LineRangeSelector::Conflicts),
|
||||
_ => parse_line_ranges(selector),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse comma-separated line ranges.
|
||||
fn parse_line_ranges(selector: &str) -> Result<LineRangeSelector, ParseError> {
|
||||
let mut ranges = Vec::new();
|
||||
|
||||
for range_str in selector.split(',') {
|
||||
let range_str = range_str.trim();
|
||||
if range_str.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let range = parse_single_range(range_str)?;
|
||||
ranges.push(range);
|
||||
}
|
||||
|
||||
if ranges.is_empty() {
|
||||
Ok(LineRangeSelector::None)
|
||||
} else {
|
||||
// Merge overlapping/adjacent ranges
|
||||
ranges.sort_by_key(|r| r.start_line);
|
||||
let merged = merge_ranges(ranges);
|
||||
Ok(LineRangeSelector::Lines(merged))
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a single line range in formats: "50", "50-200", "50+150".
|
||||
fn parse_single_range(range_str: &str) -> Result<LineRange, ParseError> {
|
||||
// Try "50+150" format first (start+count)
|
||||
if let Some(plus_idx) = range_str.find('+') {
|
||||
let start_str = &range_str[..plus_idx];
|
||||
let count_str = &range_str[plus_idx + 1..];
|
||||
|
||||
if start_str.is_empty() || count_str.is_empty() {
|
||||
return Err(ParseError::InvalidFormat(range_str.to_string()));
|
||||
}
|
||||
|
||||
let start = start_str.parse::<usize>()?;
|
||||
let count = count_str.parse::<usize>()?;
|
||||
|
||||
if start == 0 || count == 0 {
|
||||
return Err(ParseError::InvalidRange(
|
||||
"Line numbers and counts must be >= 1".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
return Ok(LineRange::new(start, start + count - 1));
|
||||
}
|
||||
|
||||
// Try "50-200" format (start-end)
|
||||
if let Some(dash_idx) = range_str.find('-') {
|
||||
// Skip leading dash (negative numbers aren't valid here)
|
||||
if dash_idx == 0 {
|
||||
return Err(ParseError::InvalidFormat(range_str.to_string()));
|
||||
}
|
||||
|
||||
let start_str = &range_str[..dash_idx];
|
||||
let end_str = &range_str[dash_idx + 1..];
|
||||
|
||||
if start_str.is_empty() || end_str.is_empty() {
|
||||
return Err(ParseError::InvalidFormat(range_str.to_string()));
|
||||
}
|
||||
|
||||
let start = start_str.parse::<usize>()?;
|
||||
let end = end_str.parse::<usize>()?;
|
||||
|
||||
if start == 0 || end == 0 {
|
||||
return Err(ParseError::InvalidRange(
|
||||
"Line numbers must be >= 1".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if start > end {
|
||||
return Err(ParseError::InvalidRange(format!(
|
||||
"Start line {} cannot be greater than end line {}",
|
||||
start, end
|
||||
)));
|
||||
}
|
||||
|
||||
return Ok(LineRange::new(start, end));
|
||||
}
|
||||
|
||||
// Try plain "50" format (single line)
|
||||
let line_num = range_str.parse::<usize>()?;
|
||||
if line_num == 0 {
|
||||
return Err(ParseError::InvalidRange(
|
||||
"Line number must be >= 1".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(LineRange::new(line_num, line_num))
|
||||
}
|
||||
|
||||
/// Merge overlapping and adjacent ranges.
|
||||
fn merge_ranges(ranges: Vec<LineRange>) -> Vec<LineRange> {
|
||||
let mut merged: Vec<LineRange> = Vec::new();
|
||||
|
||||
for range in ranges {
|
||||
if let Some(last) = merged.last_mut() {
|
||||
// If ranges overlap or are adjacent, merge them
|
||||
if range.start_line <= last.end_line + 1 {
|
||||
last.end_line = last.end_line.max(range.end_line);
|
||||
} else {
|
||||
merged.push(range);
|
||||
}
|
||||
} else {
|
||||
merged.push(range);
|
||||
}
|
||||
}
|
||||
|
||||
merged
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_raw() {
|
||||
assert_eq!(parse_selector("raw").unwrap(), LineRangeSelector::Raw);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_conflicts() {
|
||||
assert_eq!(
|
||||
parse_selector("conflicts").unwrap(),
|
||||
LineRangeSelector::Conflicts
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_single_line() {
|
||||
match parse_selector("50").unwrap() {
|
||||
LineRangeSelector::Lines(ranges) => {
|
||||
assert_eq!(ranges.len(), 1);
|
||||
assert_eq!(ranges[0].start_line, 50);
|
||||
assert_eq!(ranges[0].end_line, 50);
|
||||
}
|
||||
_ => panic!("Expected Lines variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_range_start_dash_end() {
|
||||
match parse_selector("50-200").unwrap() {
|
||||
LineRangeSelector::Lines(ranges) => {
|
||||
assert_eq!(ranges.len(), 1);
|
||||
assert_eq!(ranges[0].start_line, 50);
|
||||
assert_eq!(ranges[0].end_line, 200);
|
||||
}
|
||||
_ => panic!("Expected Lines variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_range_start_plus_count() {
|
||||
match parse_selector("50+150").unwrap() {
|
||||
LineRangeSelector::Lines(ranges) => {
|
||||
assert_eq!(ranges.len(), 1);
|
||||
assert_eq!(ranges[0].start_line, 50);
|
||||
assert_eq!(ranges[0].end_line, 199);
|
||||
}
|
||||
_ => panic!("Expected Lines variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_multiple_ranges() {
|
||||
match parse_selector("5-16,960-973").unwrap() {
|
||||
LineRangeSelector::Lines(ranges) => {
|
||||
assert_eq!(ranges.len(), 2);
|
||||
assert_eq!(ranges[0].start_line, 5);
|
||||
assert_eq!(ranges[0].end_line, 16);
|
||||
assert_eq!(ranges[1].start_line, 960);
|
||||
assert_eq!(ranges[1].end_line, 973);
|
||||
}
|
||||
_ => panic!("Expected Lines variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merge_overlapping_ranges() {
|
||||
// "5-16,10-20" should merge into "5-20"
|
||||
match parse_selector("5-16,10-20").unwrap() {
|
||||
LineRangeSelector::Lines(ranges) => {
|
||||
assert_eq!(ranges.len(), 1);
|
||||
assert_eq!(ranges[0].start_line, 5);
|
||||
assert_eq!(ranges[0].end_line, 20);
|
||||
}
|
||||
_ => panic!("Expected Lines variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merge_adjacent_ranges() {
|
||||
// "5-16,17-20" should merge into "5-20"
|
||||
match parse_selector("5-16,17-20").unwrap() {
|
||||
LineRangeSelector::Lines(ranges) => {
|
||||
assert_eq!(ranges.len(), 1);
|
||||
assert_eq!(ranges[0].start_line, 5);
|
||||
assert_eq!(ranges[0].end_line, 20);
|
||||
}
|
||||
_ => panic!("Expected Lines variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_range_start_greater_than_end() {
|
||||
let result = parse_selector("200-50");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_zero_line() {
|
||||
let result = parse_selector("0");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_selector() {
|
||||
assert_eq!(parse_selector("").unwrap(), LineRangeSelector::None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_line_range_zero_indexed() {
|
||||
let range = LineRange::new(50, 200);
|
||||
let (start, end) = range.zero_indexed();
|
||||
assert_eq!(start, 49);
|
||||
assert_eq!(end, 199);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_line_range_len() {
|
||||
assert_eq!(LineRange::new(50, 200).len(), 151);
|
||||
assert_eq!(LineRange::new(50, 50).len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -34,9 +34,6 @@ pub enum WorkerStatus {
|
||||
ToolPermissionRequired,
|
||||
ReadyForPrompt,
|
||||
Running,
|
||||
/// The user stopped the turn mid-run (e.g. Esc or Ctrl+C); the worker
|
||||
/// is back at the prompt with the session intact.
|
||||
Interrupted,
|
||||
Finished,
|
||||
Failed,
|
||||
}
|
||||
@@ -49,7 +46,6 @@ impl std::fmt::Display for WorkerStatus {
|
||||
Self::ToolPermissionRequired => write!(f, "tool_permission_required"),
|
||||
Self::ReadyForPrompt => write!(f, "ready_for_prompt"),
|
||||
Self::Running => write!(f, "running"),
|
||||
Self::Interrupted => write!(f, "interrupted"),
|
||||
Self::Finished => write!(f, "finished"),
|
||||
Self::Failed => write!(f, "failed"),
|
||||
}
|
||||
@@ -86,7 +82,6 @@ pub enum WorkerEventKind {
|
||||
PromptMisdelivery,
|
||||
PromptReplayArmed,
|
||||
Running,
|
||||
Interrupted,
|
||||
Restarted,
|
||||
Finished,
|
||||
Failed,
|
||||
@@ -1466,23 +1461,6 @@ mod tests {
|
||||
use std::fs;
|
||||
use std::process::Command;
|
||||
|
||||
#[test]
|
||||
fn interrupted_status_serializes_and_displays_as_snake_case() {
|
||||
assert_eq!(WorkerStatus::Interrupted.to_string(), "interrupted");
|
||||
assert_eq!(
|
||||
serde_json::to_string(&WorkerStatus::Interrupted).expect("should serialize"),
|
||||
"\"interrupted\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<WorkerStatus>("\"interrupted\"").expect("should deserialize"),
|
||||
WorkerStatus::Interrupted
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&WorkerEventKind::Interrupted).expect("should serialize"),
|
||||
"\"interrupted\""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allowlisted_trust_prompt_auto_resolves_then_reaches_ready_state() {
|
||||
let registry = WorkerRegistry::new();
|
||||
|
||||
@@ -24,8 +24,6 @@ tokio = { version = "1", features = ["rt-multi-thread", "signal", "time"] }
|
||||
tools = { path = "../tools" }
|
||||
log = "0.4"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
nix = { version = "0.29", features = ["term", "poll"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -1,280 +0,0 @@
|
||||
//! Esc-key interruption for running turns.
|
||||
//!
|
||||
//! While a turn runs, the main thread is blocked inside the conversation
|
||||
//! loop, so rustyline is not reading the keyboard. This module spawns a
|
||||
//! listener thread that switches stdin to a minimal non-canonical mode
|
||||
//! (line buffering and echo off; signal handling and output processing
|
||||
//! stay enabled, unlike full raw mode) and watches for a lone Escape
|
||||
//! byte. When one arrives it trips the shared [`TurnInterruptSignal`],
|
||||
//! which the conversation loop and the streaming API client poll to wind
|
||||
//! the turn down gracefully.
|
||||
//!
|
||||
//! Permission prompts read whole lines from stdin mid-turn, so stdin
|
||||
//! ownership is coordinated through [`StdinPromptGate`]: while a prompt
|
||||
//! holds a lease, the listener restores canonical mode and stops
|
||||
//! consuming bytes.
|
||||
|
||||
use std::io::IsTerminal;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
use std::thread::JoinHandle;
|
||||
|
||||
use runtime::TurnInterruptSignal;
|
||||
|
||||
/// Terminal-mode bookkeeping guarded by the gate mutex. `saved` holds the
|
||||
/// canonical-mode termios while non-canonical mode is active.
|
||||
#[derive(Default)]
|
||||
struct TtyMode {
|
||||
#[cfg(unix)]
|
||||
saved: Option<nix::sys::termios::Termios>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct GateState {
|
||||
/// True while a permission prompt owns stdin.
|
||||
busy: AtomicBool,
|
||||
/// Tells the listener thread to exit at the end of the turn.
|
||||
stop: AtomicBool,
|
||||
/// Serializes stdin reads and terminal-mode flips.
|
||||
mode: Mutex<TtyMode>,
|
||||
}
|
||||
|
||||
/// Classification of one chunk of bytes read from the terminal.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum EscClassification {
|
||||
/// A bare ESC byte: the user pressed the Escape key.
|
||||
LoneEscape,
|
||||
/// ESC followed by more bytes: the prefix of a terminal escape
|
||||
/// sequence such as an arrow or function key.
|
||||
EscapeSequence,
|
||||
/// Anything else (regular type-ahead, control characters, ...).
|
||||
Other,
|
||||
}
|
||||
|
||||
#[cfg_attr(not(unix), allow(dead_code))]
|
||||
fn classify_escape_chunk(bytes: &[u8]) -> EscClassification {
|
||||
match bytes {
|
||||
[0x1b] => EscClassification::LoneEscape,
|
||||
[0x1b, ..] => EscClassification::EscapeSequence,
|
||||
_ => EscClassification::Other,
|
||||
}
|
||||
}
|
||||
|
||||
/// Hands exclusive stdin ownership to interactive prompts while the
|
||||
/// Esc listener is running.
|
||||
#[derive(Clone)]
|
||||
pub struct StdinPromptGate {
|
||||
shared: Arc<GateState>,
|
||||
}
|
||||
|
||||
impl StdinPromptGate {
|
||||
/// Takes stdin away from the Esc listener for the lifetime of the
|
||||
/// returned lease: the terminal returns to canonical mode so a
|
||||
/// line-based prompt (e.g. the permission approval prompt) behaves
|
||||
/// normally. Listening resumes when the lease is dropped.
|
||||
pub fn lease(&self) -> StdinPromptLease<'_> {
|
||||
self.shared.busy.store(true, Ordering::SeqCst);
|
||||
// Wait for the listener's current poll cycle to release the
|
||||
// terminal, then restore canonical mode for line input.
|
||||
#[allow(unused_mut)]
|
||||
let mut mode = self
|
||||
.shared
|
||||
.mode
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner);
|
||||
#[cfg(unix)]
|
||||
tty::restore(&mut mode);
|
||||
drop(mode);
|
||||
StdinPromptLease {
|
||||
shared: &self.shared,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// RAII lease returned by [`StdinPromptGate::lease`].
|
||||
pub struct StdinPromptLease<'a> {
|
||||
shared: &'a GateState,
|
||||
}
|
||||
|
||||
impl Drop for StdinPromptLease<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.shared.busy.store(false, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
/// Background listener that maps a lone Esc keypress to
|
||||
/// [`TurnInterruptSignal::interrupt`].
|
||||
pub struct EscapeInterruptMonitor {
|
||||
shared: Arc<GateState>,
|
||||
join_handle: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl EscapeInterruptMonitor {
|
||||
/// Spawns the listener for the duration of one turn. Returns `None`
|
||||
/// when stdin is not an interactive terminal or the platform does not
|
||||
/// support terminal-mode switching; Ctrl+C interruption still works
|
||||
/// in those cases.
|
||||
pub fn spawn(signal: TurnInterruptSignal) -> Option<(Self, StdinPromptGate)> {
|
||||
if !std::io::stdin().is_terminal() {
|
||||
return None;
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _ = signal;
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let shared = Arc::new(GateState::default());
|
||||
let thread_shared = Arc::clone(&shared);
|
||||
let join_handle = std::thread::spawn(move || listener_loop(&thread_shared, &signal));
|
||||
let gate = StdinPromptGate {
|
||||
shared: Arc::clone(&shared),
|
||||
};
|
||||
Some((
|
||||
Self {
|
||||
shared,
|
||||
join_handle: Some(join_handle),
|
||||
},
|
||||
gate,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops the listener and restores the terminal before control
|
||||
/// returns to the line editor.
|
||||
pub fn stop(mut self) {
|
||||
self.shared.stop.store(true, Ordering::SeqCst);
|
||||
if let Some(join_handle) = self.join_handle.take() {
|
||||
let _ = join_handle.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn listener_loop(shared: &GateState, signal: &TurnInterruptSignal) {
|
||||
let mut buffer = [0u8; 64];
|
||||
|
||||
while !shared.stop.load(Ordering::SeqCst) {
|
||||
if shared.busy.load(Ordering::SeqCst) {
|
||||
std::thread::sleep(std::time::Duration::from_millis(25));
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut mode = shared.mode.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if shared.busy.load(Ordering::SeqCst) || shared.stop.load(Ordering::SeqCst) {
|
||||
continue;
|
||||
}
|
||||
if !tty::enable_noncanonical(&mut mode) {
|
||||
// Terminal refused the mode switch; Esc support is
|
||||
// unavailable for this turn but Ctrl+C still works.
|
||||
return;
|
||||
}
|
||||
if !tty::wait_readable(100) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let count = tty::read_pending(&mut buffer);
|
||||
if classify_escape_chunk(&buffer[..count]) == EscClassification::LoneEscape {
|
||||
// A slow terminal may split an escape sequence across reads;
|
||||
// only treat ESC as the Esc key when nothing follows it.
|
||||
if tty::wait_readable(50) {
|
||||
let _ = tty::read_pending(&mut buffer);
|
||||
continue;
|
||||
}
|
||||
signal.interrupt();
|
||||
}
|
||||
// Other bytes are intentionally swallowed: type-ahead is not
|
||||
// preserved while a turn is running.
|
||||
}
|
||||
|
||||
let mut mode = shared.mode.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
tty::restore(&mut mode);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
mod tty {
|
||||
use std::os::fd::{AsFd, AsRawFd};
|
||||
|
||||
use nix::poll::{poll, PollFd, PollFlags, PollTimeout};
|
||||
use nix::sys::termios::{self, LocalFlags, SetArg, SpecialCharacterIndices};
|
||||
|
||||
use super::TtyMode;
|
||||
|
||||
/// Disables line buffering and echo while leaving signal generation
|
||||
/// (ISIG) and output post-processing (OPOST) untouched, so Ctrl+C and
|
||||
/// streamed output keep working. No-op when already active.
|
||||
pub(super) fn enable_noncanonical(mode: &mut TtyMode) -> bool {
|
||||
if mode.saved.is_some() {
|
||||
return true;
|
||||
}
|
||||
let stdin = std::io::stdin();
|
||||
let Ok(saved) = termios::tcgetattr(&stdin) else {
|
||||
return false;
|
||||
};
|
||||
let mut noncanonical = saved.clone();
|
||||
noncanonical.local_flags &= !(LocalFlags::ICANON | LocalFlags::ECHO);
|
||||
noncanonical.control_chars[SpecialCharacterIndices::VMIN as usize] = 0;
|
||||
noncanonical.control_chars[SpecialCharacterIndices::VTIME as usize] = 0;
|
||||
if termios::tcsetattr(&stdin, SetArg::TCSANOW, &noncanonical).is_err() {
|
||||
return false;
|
||||
}
|
||||
mode.saved = Some(saved);
|
||||
true
|
||||
}
|
||||
|
||||
pub(super) fn restore(mode: &mut TtyMode) {
|
||||
if let Some(saved) = mode.saved.take() {
|
||||
let _ = termios::tcsetattr(std::io::stdin(), SetArg::TCSANOW, &saved);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn wait_readable(timeout_ms: u16) -> bool {
|
||||
let stdin = std::io::stdin();
|
||||
let mut fds = [PollFd::new(stdin.as_fd(), PollFlags::POLLIN)];
|
||||
matches!(poll(&mut fds, PollTimeout::from(timeout_ms)), Ok(ready) if ready > 0)
|
||||
&& fds[0]
|
||||
.revents()
|
||||
.is_some_and(|revents| revents.contains(PollFlags::POLLIN))
|
||||
}
|
||||
|
||||
pub(super) fn read_pending(buffer: &mut [u8]) -> usize {
|
||||
nix::unistd::read(std::io::stdin().as_raw_fd(), buffer).unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{classify_escape_chunk, EscClassification};
|
||||
|
||||
#[test]
|
||||
fn lone_escape_byte_is_the_esc_key() {
|
||||
assert_eq!(
|
||||
classify_escape_chunk(&[0x1b]),
|
||||
EscClassification::LoneEscape
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_followed_by_more_bytes_is_a_sequence() {
|
||||
// Up arrow: ESC [ A
|
||||
assert_eq!(
|
||||
classify_escape_chunk(&[0x1b, 0x5b, 0x41]),
|
||||
EscClassification::EscapeSequence
|
||||
);
|
||||
// Alt+f style: ESC f
|
||||
assert_eq!(
|
||||
classify_escape_chunk(&[0x1b, b'f']),
|
||||
EscClassification::EscapeSequence
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regular_bytes_are_ignored() {
|
||||
assert_eq!(classify_escape_chunk(b"hello"), EscClassification::Other);
|
||||
assert_eq!(classify_escape_chunk(&[]), EscClassification::Other);
|
||||
assert_eq!(classify_escape_chunk(&[0x03]), EscClassification::Other);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -7,9 +7,7 @@ use std::process::{Command, Output, Stdio};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use mock_anthropic_service::{
|
||||
MockAnthropicService, CONTEXT_WINDOW_RETRY_TEXT, SCENARIO_PREFIX, STREAM_THEN_ERROR_TEXT,
|
||||
};
|
||||
use mock_anthropic_service::{MockAnthropicService, SCENARIO_PREFIX};
|
||||
use serde_json::Value;
|
||||
|
||||
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
@@ -191,199 +189,6 @@ fn text_prompt_mode_streams_assistant_text_once_before_spinner_completion() {
|
||||
fs::remove_dir_all(&workspace).expect("workspace cleanup should succeed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_prompt_mode_emits_the_response_body_exactly_once() {
|
||||
// given a workspace pointed at the mock Anthropic service running the
|
||||
// streaming_text scenario, which emits a single assistant text block
|
||||
let runtime = tokio::runtime::Runtime::new().expect("tokio runtime should build");
|
||||
let server = runtime
|
||||
.block_on(MockAnthropicService::spawn())
|
||||
.expect("mock service should start");
|
||||
let base_url = server.base_url();
|
||||
|
||||
let workspace = unique_temp_dir("text-prompt-once");
|
||||
let config_home = workspace.join("config-home");
|
||||
let home = workspace.join("home");
|
||||
fs::create_dir_all(&workspace).expect("workspace should exist");
|
||||
fs::create_dir_all(&config_home).expect("config home should exist");
|
||||
fs::create_dir_all(&home).expect("home should exist");
|
||||
|
||||
// when we invoke claw in normal text prompt mode
|
||||
let prompt = format!("{SCENARIO_PREFIX}streaming_text");
|
||||
let output = run_claw(
|
||||
&workspace,
|
||||
&config_home,
|
||||
&home,
|
||||
&base_url,
|
||||
&[
|
||||
"--model",
|
||||
"sonnet",
|
||||
"--permission-mode",
|
||||
"read-only",
|
||||
&prompt,
|
||||
],
|
||||
);
|
||||
|
||||
// then the response body reaches stdout exactly once: the emitter owns the
|
||||
// terminal while the turn runs, so the caller must not print the assistant
|
||||
// text a second time
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"text prompt run should succeed\nstdout:\n{}\n\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
);
|
||||
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
|
||||
let plain_stdout = strip_ansi_codes(&stdout);
|
||||
assert_eq!(
|
||||
plain_stdout
|
||||
.matches("Mock streaming says hello from the parity harness.")
|
||||
.count(),
|
||||
1,
|
||||
"text prompt stdout should carry the response body exactly once ({stdout:?})"
|
||||
);
|
||||
|
||||
fs::remove_dir_all(&workspace).expect("workspace cleanup should succeed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_prompt_mode_emits_retried_body_once_after_auto_compact() {
|
||||
// given a mock service whose first answer to this scenario is a context
|
||||
// window rejection and whose answer to the retry is a streamed response
|
||||
let runtime = tokio::runtime::Runtime::new().expect("tokio runtime should build");
|
||||
let server = runtime
|
||||
.block_on(MockAnthropicService::spawn())
|
||||
.expect("mock service should start");
|
||||
let base_url = server.base_url();
|
||||
|
||||
let workspace = unique_temp_dir("text-prompt-auto-compact-retry");
|
||||
let config_home = workspace.join("config-home");
|
||||
let home = workspace.join("home");
|
||||
fs::create_dir_all(&workspace).expect("workspace should exist");
|
||||
fs::create_dir_all(&config_home).expect("config home should exist");
|
||||
fs::create_dir_all(&home).expect("home should exist");
|
||||
|
||||
// when claw is rejected for context window overflow and recovers by
|
||||
// compacting the session and retrying the same prompt
|
||||
let prompt = format!("{SCENARIO_PREFIX}context_window_retry");
|
||||
let output = run_claw(
|
||||
&workspace,
|
||||
&config_home,
|
||||
&home,
|
||||
&base_url,
|
||||
&[
|
||||
"--model",
|
||||
"sonnet",
|
||||
"--permission-mode",
|
||||
"read-only",
|
||||
&prompt,
|
||||
],
|
||||
);
|
||||
|
||||
// then the retry succeeds and its response body is on stdout exactly once.
|
||||
// The recovery path builds a fresh runtime with its own terminal tail, so
|
||||
// this is where a tail that only tracked the first attempt would leave the
|
||||
// streamed line unterminated and let `spinner.finish` clear it.
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"auto-compact retry should succeed\nstdout:\n{}\n\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
);
|
||||
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
|
||||
let plain_stdout = strip_ansi_codes(&stdout);
|
||||
assert_eq!(
|
||||
plain_stdout.matches(CONTEXT_WINDOW_RETRY_TEXT).count(),
|
||||
1,
|
||||
"auto-compact retry stdout should carry the response body exactly once ({stdout:?})"
|
||||
);
|
||||
assert!(
|
||||
plain_stdout
|
||||
.lines()
|
||||
.any(|line| line == CONTEXT_WINDOW_RETRY_TEXT),
|
||||
"the retried response body should land on its own line ({stdout:?})"
|
||||
);
|
||||
assert!(
|
||||
plain_stdout.contains("✨ Done (after auto-compact)"),
|
||||
"the retry should report the auto-compact completion ({stdout:?})"
|
||||
);
|
||||
|
||||
// and the scenario really was requested twice, so the retry path ran
|
||||
let captured = runtime.block_on(server.captured_requests());
|
||||
let messages: Vec<_> = captured
|
||||
.iter()
|
||||
.filter(|request| request.path == "/v1/messages")
|
||||
.collect();
|
||||
assert!(
|
||||
messages.len() >= 2,
|
||||
"the scenario should be requested twice so the retry is exercised ({captured:?})"
|
||||
);
|
||||
|
||||
fs::remove_dir_all(&workspace).expect("workspace cleanup should succeed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_prompt_mode_keeps_streamed_text_when_the_turn_fails() {
|
||||
// given a mock service that streams part of an answer and then fails the
|
||||
// response, so the turn ends with text already rendered on the terminal
|
||||
let runtime = tokio::runtime::Runtime::new().expect("tokio runtime should build");
|
||||
let server = runtime
|
||||
.block_on(MockAnthropicService::spawn())
|
||||
.expect("mock service should start");
|
||||
let base_url = server.base_url();
|
||||
|
||||
let workspace = unique_temp_dir("text-prompt-stream-then-error");
|
||||
let config_home = workspace.join("config-home");
|
||||
let home = workspace.join("home");
|
||||
fs::create_dir_all(&workspace).expect("workspace should exist");
|
||||
fs::create_dir_all(&config_home).expect("config home should exist");
|
||||
fs::create_dir_all(&home).expect("home should exist");
|
||||
|
||||
// when the turn fails after the emitter already streamed a partial line
|
||||
let prompt = format!("{SCENARIO_PREFIX}stream_then_error");
|
||||
let output = run_claw(
|
||||
&workspace,
|
||||
&config_home,
|
||||
&home,
|
||||
&base_url,
|
||||
&[
|
||||
"--model",
|
||||
"sonnet",
|
||||
"--permission-mode",
|
||||
"read-only",
|
||||
&prompt,
|
||||
],
|
||||
);
|
||||
|
||||
// then the failed turn is reported...
|
||||
assert!(
|
||||
!output.status.success(),
|
||||
"a mid-stream failure should fail the run\nstdout:\n{}\n\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
);
|
||||
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
|
||||
let plain_stdout = strip_ansi_codes(&stdout);
|
||||
|
||||
// ...and the text the emitter already streamed survives the failure banner.
|
||||
// `spinner.fail` moves to column 0 and clears the current line, so a caller
|
||||
// that does not know a partial line is pending erases that text instead of
|
||||
// printing the banner underneath it. This is the failure arm of the same
|
||||
// ownership rule the success arm follows.
|
||||
assert!(
|
||||
plain_stdout
|
||||
.lines()
|
||||
.any(|line| line == STREAM_THEN_ERROR_TEXT),
|
||||
"the streamed text should keep its own line despite the failure ({stdout:?})"
|
||||
);
|
||||
assert!(
|
||||
plain_stdout.contains("Request failed"),
|
||||
"the failure banner should still be reported ({stdout:?})"
|
||||
);
|
||||
|
||||
fs::remove_dir_all(&workspace).expect("workspace cleanup should succeed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_flag_with_json_output_emits_structured_json() {
|
||||
let runtime = tokio::runtime::Runtime::new().expect("tokio runtime should build");
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
[package]
|
||||
name = "sdk"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tokio-tungstenite = "0.23"
|
||||
futures-util = "0.3"
|
||||
serde_json = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
thiserror = "1"
|
||||
tracing = "0.1"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = "0.4"
|
||||
@@ -1,130 +0,0 @@
|
||||
//! SDK error types and result type.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
/// SDK wire protocol error codes.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ErrorCode {
|
||||
/// Invalid JSON or malformed frame
|
||||
InvalidInput,
|
||||
/// Unknown broker operation
|
||||
UnknownOperation,
|
||||
/// Payload too large (>4 MiB)
|
||||
PayloadTooLarge,
|
||||
/// Authentication token mismatch
|
||||
Unauthorized,
|
||||
/// Broker or session unavailable
|
||||
Unavailable,
|
||||
/// WebSocket connection error
|
||||
ConnectionError,
|
||||
/// Request timeout
|
||||
Timeout,
|
||||
/// Frame parsing or validation error
|
||||
FrameError,
|
||||
/// Request/response correlation failure
|
||||
CorrelationError,
|
||||
/// Other unclassified error
|
||||
Internal,
|
||||
}
|
||||
|
||||
impl fmt::Display for ErrorCode {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::InvalidInput => write!(f, "invalid_input"),
|
||||
Self::UnknownOperation => write!(f, "unknown_operation"),
|
||||
Self::PayloadTooLarge => write!(f, "payload_too_large"),
|
||||
Self::Unauthorized => write!(f, "unauthorized"),
|
||||
Self::Unavailable => write!(f, "unavailable"),
|
||||
Self::ConnectionError => write!(f, "connection_error"),
|
||||
Self::Timeout => write!(f, "timeout"),
|
||||
Self::FrameError => write!(f, "frame_error"),
|
||||
Self::CorrelationError => write!(f, "correlation_error"),
|
||||
Self::Internal => write!(f, "internal_error"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// SDK wire protocol error.
|
||||
#[derive(Debug)]
|
||||
pub struct SdkError {
|
||||
pub code: ErrorCode,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl SdkError {
|
||||
pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
code,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn invalid_input(message: impl Into<String>) -> Self {
|
||||
Self::new(ErrorCode::InvalidInput, message)
|
||||
}
|
||||
|
||||
pub fn unknown_operation(message: impl Into<String>) -> Self {
|
||||
Self::new(ErrorCode::UnknownOperation, message)
|
||||
}
|
||||
|
||||
pub fn payload_too_large(message: impl Into<String>) -> Self {
|
||||
Self::new(ErrorCode::PayloadTooLarge, message)
|
||||
}
|
||||
|
||||
pub fn unauthorized(message: impl Into<String>) -> Self {
|
||||
Self::new(ErrorCode::Unauthorized, message)
|
||||
}
|
||||
|
||||
pub fn unavailable(message: impl Into<String>) -> Self {
|
||||
Self::new(ErrorCode::Unavailable, message)
|
||||
}
|
||||
|
||||
pub fn connection_error(message: impl Into<String>) -> Self {
|
||||
Self::new(ErrorCode::ConnectionError, message)
|
||||
}
|
||||
|
||||
pub fn timeout(message: impl Into<String>) -> Self {
|
||||
Self::new(ErrorCode::Timeout, message)
|
||||
}
|
||||
|
||||
pub fn frame_error(message: impl Into<String>) -> Self {
|
||||
Self::new(ErrorCode::FrameError, message)
|
||||
}
|
||||
|
||||
pub fn correlation_error(message: impl Into<String>) -> Self {
|
||||
Self::new(ErrorCode::CorrelationError, message)
|
||||
}
|
||||
|
||||
pub fn internal(message: impl Into<String>) -> Self {
|
||||
Self::new(ErrorCode::Internal, message)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for SdkError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}: {}", self.code, self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for SdkError {}
|
||||
|
||||
/// Result type for SDK operations.
|
||||
pub type SdkResult<T> = Result<T, SdkError>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn error_display_formats_code_and_message() {
|
||||
let err = SdkError::invalid_input("test message");
|
||||
assert_eq!(err.to_string(), "invalid_input: test message");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_code_display_works() {
|
||||
assert_eq!(ErrorCode::InvalidInput.to_string(), "invalid_input");
|
||||
assert_eq!(ErrorCode::PayloadTooLarge.to_string(), "payload_too_large");
|
||||
assert_eq!(ErrorCode::Unauthorized.to_string(), "unauthorized");
|
||||
}
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
//! Frame types and serialization for the SDK wire protocol.
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::error::{SdkError, SdkResult};
|
||||
|
||||
/// Protocol version implemented.
|
||||
pub const PROTOCOL_VERSION: u32 = 3;
|
||||
|
||||
/// Maximum UTF-8 byte length for a single broker JSON frame.
|
||||
pub const MAX_FRAME_BYTES: usize = 4 * 1024 * 1024;
|
||||
|
||||
/// Frame type identifier.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FrameType {
|
||||
/// Hello frame sent by broker on connection
|
||||
BrokerHello,
|
||||
/// Request frame from client to broker
|
||||
BrokerRequest,
|
||||
/// Response frame from broker to client
|
||||
BrokerResponse,
|
||||
/// Query request
|
||||
QueryRequest,
|
||||
/// Query response
|
||||
QueryResponse,
|
||||
/// Control request
|
||||
ControlRequest,
|
||||
/// Control response
|
||||
ControlResponse,
|
||||
/// Event replay request
|
||||
EventReplay,
|
||||
/// Event replay result
|
||||
EventReplayResult,
|
||||
/// Session activation
|
||||
SessionActivate,
|
||||
/// Session activation result
|
||||
SessionActivateResult,
|
||||
}
|
||||
|
||||
impl FrameType {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::BrokerHello => "broker_hello",
|
||||
Self::BrokerRequest => "broker_request",
|
||||
Self::BrokerResponse => "broker_response",
|
||||
Self::QueryRequest => "query_request",
|
||||
Self::QueryResponse => "query_response",
|
||||
Self::ControlRequest => "control_request",
|
||||
Self::ControlResponse => "control_response",
|
||||
Self::EventReplay => "event_replay",
|
||||
Self::EventReplayResult => "event_replay_result",
|
||||
Self::SessionActivate => "session_activate",
|
||||
Self::SessionActivateResult => "session_activate_result",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(s: &str) -> SdkResult<Self> {
|
||||
match s {
|
||||
"broker_hello" => Ok(Self::BrokerHello),
|
||||
"broker_request" => Ok(Self::BrokerRequest),
|
||||
"broker_response" => Ok(Self::BrokerResponse),
|
||||
"query_request" => Ok(Self::QueryRequest),
|
||||
"query_response" => Ok(Self::QueryResponse),
|
||||
"control_request" => Ok(Self::ControlRequest),
|
||||
"control_response" => Ok(Self::ControlResponse),
|
||||
"event_replay" => Ok(Self::EventReplay),
|
||||
"event_replay_result" => Ok(Self::EventReplayResult),
|
||||
"session_activate" => Ok(Self::SessionActivate),
|
||||
"session_activate_result" => Ok(Self::SessionActivateResult),
|
||||
_ => Err(SdkError::frame_error(format!("unknown frame type: {}", s))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A frame in the SDK wire protocol.
|
||||
///
|
||||
/// Frames are JSON objects with a `type` field and optional `id` field for correlation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Frame {
|
||||
/// Underlying JSON value
|
||||
pub data: Map<String, Value>,
|
||||
}
|
||||
|
||||
impl Frame {
|
||||
/// Create a new frame from a JSON object.
|
||||
pub fn new(data: Map<String, Value>) -> Self {
|
||||
Self { data }
|
||||
}
|
||||
|
||||
/// Create an empty frame.
|
||||
pub fn empty() -> Self {
|
||||
Self { data: Map::new() }
|
||||
}
|
||||
|
||||
/// Get the frame type.
|
||||
pub fn frame_type(&self) -> SdkResult<FrameType> {
|
||||
let frame_type_str = self
|
||||
.data
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| SdkError::frame_error("missing 'type' field"))?;
|
||||
FrameType::parse(frame_type_str)
|
||||
}
|
||||
|
||||
/// Get the optional request/response ID for correlation.
|
||||
pub fn id(&self) -> Option<String> {
|
||||
self.data
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.map(String::from)
|
||||
}
|
||||
|
||||
/// Get the `ok` boolean field from a response frame.
|
||||
pub fn ok(&self) -> SdkResult<bool> {
|
||||
self.data
|
||||
.get("ok")
|
||||
.and_then(Value::as_bool)
|
||||
.ok_or_else(|| SdkError::frame_error("missing or invalid 'ok' field"))
|
||||
}
|
||||
|
||||
/// Get the error object from a response frame.
|
||||
pub fn error(&self) -> Option<SdkResult<(String, String)>> {
|
||||
let error_obj = self.data.get("error")?.as_object()?;
|
||||
let code = error_obj.get("code")?.as_str()?.to_string();
|
||||
let message = error_obj.get("message")?.as_str()?.to_string();
|
||||
Some(Ok((code, message)))
|
||||
}
|
||||
|
||||
/// Get the result field from a response frame.
|
||||
pub fn result(&self) -> Option<Value> {
|
||||
self.data.get("result").cloned()
|
||||
}
|
||||
|
||||
/// Convert to JSON string.
|
||||
pub fn to_json(&self) -> SdkResult<String> {
|
||||
serde_json::to_string(&self.data)
|
||||
.map_err(|e| SdkError::frame_error(format!("JSON serialization error: {}", e)))
|
||||
}
|
||||
|
||||
/// Parse from JSON string.
|
||||
pub fn from_json(json_str: &str) -> SdkResult<Self> {
|
||||
let data: Map<String, Value> = serde_json::from_str(json_str)
|
||||
.map_err(|e| SdkError::frame_error(format!("JSON parsing error: {}", e)))?;
|
||||
Ok(Self::new(data))
|
||||
}
|
||||
|
||||
/// Create a broker hello frame.
|
||||
pub fn broker_hello() -> Self {
|
||||
let mut data = Map::new();
|
||||
data.insert(
|
||||
"type".to_string(),
|
||||
Value::String("broker_hello".to_string()),
|
||||
);
|
||||
data.insert(
|
||||
"protocolVersion".to_string(),
|
||||
Value::Number(PROTOCOL_VERSION.into()),
|
||||
);
|
||||
Self::new(data)
|
||||
}
|
||||
|
||||
/// Create a broker request frame.
|
||||
pub fn broker_request(id: String, operation: String, input: Value) -> Self {
|
||||
let mut data = Map::new();
|
||||
data.insert(
|
||||
"type".to_string(),
|
||||
Value::String("broker_request".to_string()),
|
||||
);
|
||||
data.insert("id".to_string(), Value::String(id));
|
||||
data.insert("operation".to_string(), Value::String(operation));
|
||||
data.insert("input".to_string(), input);
|
||||
Self::new(data)
|
||||
}
|
||||
|
||||
/// Create a broker response frame.
|
||||
pub fn broker_response_ok(id: String, result: Value) -> Self {
|
||||
let mut data = Map::new();
|
||||
data.insert(
|
||||
"type".to_string(),
|
||||
Value::String("broker_response".to_string()),
|
||||
);
|
||||
data.insert("id".to_string(), Value::String(id));
|
||||
data.insert("ok".to_string(), Value::Bool(true));
|
||||
data.insert("result".to_string(), result);
|
||||
Self::new(data)
|
||||
}
|
||||
|
||||
/// Create a broker error response frame.
|
||||
pub fn broker_response_error(id: String, code: String, message: String) -> Self {
|
||||
let mut error_data = Map::new();
|
||||
error_data.insert("code".to_string(), Value::String(code));
|
||||
error_data.insert("message".to_string(), Value::String(message));
|
||||
|
||||
let mut data = Map::new();
|
||||
data.insert(
|
||||
"type".to_string(),
|
||||
Value::String("broker_response".to_string()),
|
||||
);
|
||||
data.insert("id".to_string(), Value::String(id));
|
||||
data.insert("ok".to_string(), Value::Bool(false));
|
||||
data.insert("error".to_string(), Value::Object(error_data));
|
||||
Self::new(data)
|
||||
}
|
||||
|
||||
/// Get the payload size in bytes.
|
||||
pub fn byte_size(&self) -> usize {
|
||||
self.to_json().map(|s| s.len()).unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn frame_type_round_trip() {
|
||||
assert_eq!(FrameType::BrokerHello.as_str(), "broker_hello");
|
||||
assert_eq!(
|
||||
FrameType::parse("broker_hello").unwrap(),
|
||||
FrameType::BrokerHello
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_parsing_extracts_type() {
|
||||
let frame = Frame::broker_hello();
|
||||
assert_eq!(frame.frame_type().unwrap(), FrameType::BrokerHello);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_with_id_can_be_recovered() {
|
||||
let frame = Frame::broker_request(
|
||||
"test-id".to_string(),
|
||||
"test.operation".to_string(),
|
||||
Value::Object(Map::new()),
|
||||
);
|
||||
assert_eq!(frame.id(), Some("test-id".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_json_round_trip() {
|
||||
let frame = Frame::broker_hello();
|
||||
let json_str = frame.to_json().unwrap();
|
||||
let parsed = Frame::from_json(&json_str).unwrap();
|
||||
assert_eq!(parsed.frame_type().unwrap(), FrameType::BrokerHello);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broker_response_error_contains_code_and_message() {
|
||||
let frame = Frame::broker_response_error(
|
||||
"id1".to_string(),
|
||||
"invalid_input".to_string(),
|
||||
"test error".to_string(),
|
||||
);
|
||||
let (code, message) = frame.error().unwrap().unwrap();
|
||||
assert_eq!(code, "invalid_input");
|
||||
assert_eq!(message, "test error");
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
//! GJC SDK wire transport implementation.
|
||||
//!
|
||||
//! This crate provides a Rust implementation of the GJC SDK wire protocol,
|
||||
//! compatible with the gajae-code SDK broker framing, request/response correlation,
|
||||
//! and event stream semantics.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![allow(
|
||||
clippy::missing_errors_doc,
|
||||
clippy::missing_panics_doc,
|
||||
clippy::module_name_repetitions
|
||||
)]
|
||||
|
||||
pub mod error;
|
||||
pub mod frame;
|
||||
pub mod transport;
|
||||
|
||||
pub use error::{SdkError, SdkResult};
|
||||
pub use frame::{Frame, FrameType};
|
||||
pub use transport::{SdkTransport, SdkTransportConfig};
|
||||
@@ -1,251 +0,0 @@
|
||||
//! WebSocket transport implementation for the SDK wire protocol.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use futures_util::sink::SinkExt;
|
||||
use futures_util::stream::StreamExt;
|
||||
use serde_json::Value;
|
||||
use tokio::sync::{mpsc, oneshot, Mutex, RwLock};
|
||||
use tokio_tungstenite::{connect_async, tungstenite::Message};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{SdkError, SdkResult};
|
||||
use crate::frame::{Frame, FrameType, MAX_FRAME_BYTES};
|
||||
|
||||
/// Configuration for SDK transport.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SdkTransportConfig {
|
||||
/// Server URL (e.g., "ws://127.0.0.1:8080")
|
||||
pub url: String,
|
||||
/// Authentication token
|
||||
pub token: String,
|
||||
/// Maximum payload size in bytes (default: 4 MiB)
|
||||
pub max_payload_bytes: usize,
|
||||
/// Request timeout in seconds (default: 30)
|
||||
pub timeout_secs: u64,
|
||||
}
|
||||
|
||||
impl Default for SdkTransportConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
url: "ws://127.0.0.1:8080".to_string(),
|
||||
token: String::new(),
|
||||
max_payload_bytes: MAX_FRAME_BYTES,
|
||||
timeout_secs: 30,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pending request tracking for correlation.
|
||||
struct PendingRequest {
|
||||
response_tx: oneshot::Sender<SdkResult<Frame>>,
|
||||
}
|
||||
|
||||
/// SDK WebSocket transport.
|
||||
pub struct SdkTransport {
|
||||
/// Channel for sending messages to the writer task
|
||||
tx: mpsc::UnboundedSender<Message>,
|
||||
/// Pending requests by ID
|
||||
pending: Arc<RwLock<HashMap<String, PendingRequest>>>,
|
||||
/// Configuration
|
||||
config: SdkTransportConfig,
|
||||
/// Background task handle
|
||||
task_handle: Arc<Mutex<Option<tokio::task::JoinHandle<()>>>>,
|
||||
}
|
||||
|
||||
impl SdkTransport {
|
||||
/// Create a new SDK transport and connect to the broker.
|
||||
pub async fn connect(config: SdkTransportConfig) -> SdkResult<Self> {
|
||||
// Add token to query params
|
||||
let url_with_token = if config.url.contains('?') {
|
||||
format!("{}&token={}", config.url, config.token)
|
||||
} else {
|
||||
format!("{}?token={}", config.url, config.token)
|
||||
};
|
||||
|
||||
// Create WebSocket connection
|
||||
let (ws, _) = connect_async(&url_with_token).await.map_err(|e| {
|
||||
SdkError::connection_error(format!("WebSocket connection failed: {}", e))
|
||||
})?;
|
||||
|
||||
let (ws_tx, ws_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let pending = Arc::new(RwLock::new(HashMap::new()));
|
||||
let pending_clone = pending.clone();
|
||||
|
||||
// Spawn background task for handling the WebSocket
|
||||
let task_handle = tokio::spawn(async move {
|
||||
Self::background_task(ws, ws_rx, pending_clone).await;
|
||||
});
|
||||
|
||||
let transport = Self {
|
||||
tx: ws_tx,
|
||||
pending,
|
||||
config,
|
||||
task_handle: Arc::new(Mutex::new(Some(task_handle))),
|
||||
};
|
||||
|
||||
Ok(transport)
|
||||
}
|
||||
|
||||
/// Send a request and wait for response.
|
||||
pub async fn request(&self, operation: String, input: Value) -> SdkResult<Frame> {
|
||||
// Generate correlation ID
|
||||
let id = Uuid::new_v4().to_string();
|
||||
|
||||
// Create request frame
|
||||
let request = Frame::broker_request(id.clone(), operation, input);
|
||||
|
||||
// Check payload size
|
||||
if request.byte_size() > self.config.max_payload_bytes {
|
||||
return Err(SdkError::payload_too_large(format!(
|
||||
"frame size {} exceeds maximum {}",
|
||||
request.byte_size(),
|
||||
self.config.max_payload_bytes
|
||||
)));
|
||||
}
|
||||
|
||||
// Create response channel
|
||||
let (response_tx, response_rx) = oneshot::channel();
|
||||
self.pending
|
||||
.write()
|
||||
.await
|
||||
.insert(id.clone(), PendingRequest { response_tx });
|
||||
|
||||
// Send request
|
||||
let json = request.to_json()?;
|
||||
self.tx
|
||||
.send(Message::Text(json))
|
||||
.map_err(|e| SdkError::connection_error(format!("send failed: {}", e)))?;
|
||||
|
||||
// Wait for response with timeout
|
||||
let timeout_duration = std::time::Duration::from_secs(self.config.timeout_secs);
|
||||
match tokio::time::timeout(timeout_duration, response_rx).await {
|
||||
Ok(Ok(frame)) => frame,
|
||||
Ok(Err(_)) => Err(SdkError::correlation_error("response channel closed")),
|
||||
Err(_) => Err(SdkError::timeout("response not received within timeout")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Background task for handling WebSocket communication.
|
||||
#[allow(unused_mut)]
|
||||
async fn background_task(
|
||||
ws: tokio_tungstenite::WebSocketStream<
|
||||
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
|
||||
>,
|
||||
mut ws_rx: mpsc::UnboundedReceiver<Message>,
|
||||
pending: Arc<RwLock<HashMap<String, PendingRequest>>>,
|
||||
) {
|
||||
let (mut ws_sink, mut ws_stream) = ws.split();
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
// Handle incoming WebSocket messages
|
||||
msg = ws_stream.next() => {
|
||||
match msg {
|
||||
Some(Ok(Message::Text(text))) => {
|
||||
// Parse frame
|
||||
match Frame::from_json(&text) {
|
||||
Ok(frame) => {
|
||||
// Handle frame type
|
||||
match frame.frame_type() {
|
||||
Ok(FrameType::BrokerHello) => {
|
||||
// Hello frame received, continue
|
||||
}
|
||||
Ok(FrameType::BrokerResponse) => {
|
||||
// Response frame, correlate with request
|
||||
if let Some(id) = frame.id() {
|
||||
if let Some(PendingRequest { response_tx }) =
|
||||
pending.write().await.remove(&id)
|
||||
{
|
||||
let _ = response_tx.send(Ok(frame));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Other frame types
|
||||
tracing::debug!("Unhandled frame type: {:?}", frame.frame_type());
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to parse frame: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Ok(Message::Close(_))) => {
|
||||
break;
|
||||
}
|
||||
Some(Ok(_)) => {
|
||||
// Other message types (binary, etc.), ignore
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
tracing::warn!("WebSocket error: {}", e);
|
||||
break;
|
||||
}
|
||||
None => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Handle outgoing messages
|
||||
Some(msg) = ws_rx.recv() => {
|
||||
if let Err(e) = ws_sink.send(msg).await {
|
||||
tracing::warn!("Failed to send message: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
else => break,
|
||||
}
|
||||
}
|
||||
|
||||
// Fail all pending requests
|
||||
let mut pending = pending.write().await;
|
||||
for (_, PendingRequest { response_tx }) in pending.drain() {
|
||||
let _ = response_tx.send(Err(SdkError::connection_error("connection closed")));
|
||||
}
|
||||
}
|
||||
|
||||
/// Close the transport.
|
||||
pub async fn close(self) -> SdkResult<()> {
|
||||
// Send close message
|
||||
let _ = self.tx.send(Message::Close(None));
|
||||
|
||||
// Wait for background task to finish
|
||||
if let Some(handle) = self.task_handle.lock().await.take() {
|
||||
let _ = tokio::time::timeout(std::time::Duration::from_secs(5), handle).await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn config_default_values() {
|
||||
let config = SdkTransportConfig::default();
|
||||
assert_eq!(config.url, "ws://127.0.0.1:8080");
|
||||
assert_eq!(config.max_payload_bytes, MAX_FRAME_BYTES);
|
||||
assert_eq!(config.timeout_secs, 30);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn transport_can_be_created() {
|
||||
// This test doesn't actually connect, just checks that the struct can be created
|
||||
let config = SdkTransportConfig::default();
|
||||
assert_eq!(config.url, "ws://127.0.0.1:8080");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_frame_has_correlation_id() {
|
||||
let frame = Frame::broker_request(
|
||||
"test-id".to_string(),
|
||||
"test.operation".to_string(),
|
||||
Value::Object(serde_json::Map::new()),
|
||||
);
|
||||
assert_eq!(frame.id(), Some("test-id".to_string()));
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ use runtime::{
|
||||
lsp_client::LspRegistry,
|
||||
mcp_tool_bridge::McpToolRegistry,
|
||||
permission_enforcer::{EnforcementResult, PermissionEnforcer},
|
||||
read_file_in_workspace_with_selector,
|
||||
read_file_in_workspace,
|
||||
summary_compression::compress_summary_text,
|
||||
task_registry::TaskRegistry,
|
||||
team_cron_registry::{CronRegistry, TeamRegistry},
|
||||
@@ -2569,14 +2569,8 @@ fn branch_divergence_output(
|
||||
fn run_read_file(input: ReadFileInput) -> Result<String, String> {
|
||||
let workspace = std::env::current_dir().map_err(|error| error.to_string())?;
|
||||
to_pretty_json(
|
||||
read_file_in_workspace_with_selector(
|
||||
&input.path,
|
||||
input.offset,
|
||||
input.limit,
|
||||
input.selector.as_deref(),
|
||||
&workspace,
|
||||
)
|
||||
.map_err(io_to_string)?,
|
||||
read_file_in_workspace(&input.path, input.offset, input.limit, &workspace)
|
||||
.map_err(io_to_string)?,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2891,7 +2885,6 @@ struct ReadFileInput {
|
||||
path: String,
|
||||
offset: Option<usize>,
|
||||
limit: Option<usize>,
|
||||
selector: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -10697,43 +10690,6 @@ printf 'pwsh:%s' "$1"
|
||||
let _ = fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn given_read_only_enforcer_when_read_file_with_selector_then_succeeds() {
|
||||
let _guard = env_lock()
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let root = temp_path("perm-read-selector");
|
||||
fs::create_dir_all(&root).expect("create root");
|
||||
let file = root.join("multi-line.txt");
|
||||
fs::write(&file, "line1\nline2\nline3\nline4\nline5\n").expect("write test file");
|
||||
let original_dir = std::env::current_dir().expect("cwd");
|
||||
std::env::set_current_dir(&root).expect("set cwd");
|
||||
|
||||
let registry = read_only_registry();
|
||||
let result = registry.execute(
|
||||
"read_file",
|
||||
&json!({
|
||||
"path": file.display().to_string(),
|
||||
"selector": "2-4"
|
||||
}),
|
||||
);
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"read_file with selector should succeed: {result:?}"
|
||||
);
|
||||
|
||||
// Verify the output contains the expected lines
|
||||
let output: serde_json::Value =
|
||||
serde_json::from_str(&result.unwrap()).expect("json output");
|
||||
let content = output["file"]["content"].as_str().expect("content field");
|
||||
assert!(content.contains("line2"));
|
||||
assert!(content.contains("line3"));
|
||||
assert!(content.contains("line4"));
|
||||
|
||||
std::env::set_current_dir(&original_dir).expect("restore cwd");
|
||||
let _ = fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn given_read_only_enforcer_when_glob_search_then_not_permission_denied() {
|
||||
let registry = read_only_registry();
|
||||
|
||||
Reference in new issue
Block a user