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 |
@@ -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}
|
||||
@@ -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}
|
||||
@@ -5,44 +5,6 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.1.4] - 2026-10-13
|
||||
|
||||
### Added
|
||||
|
||||
- GJC-compatible line range selectors for read_file tool (`:50-200`, `:50+150` syntax)
|
||||
- GJC-compatible `:conflicts` selector for git merge conflict detection
|
||||
- SDK wire transport with WebSocket support
|
||||
- Nix flake development environment
|
||||
- `/status`, `/cost`, and `/version` slash commands
|
||||
- Persistent provider configuration loading
|
||||
- Pagination support for large read_file results
|
||||
- Comprehensive GJC compatibility documentation and analysis
|
||||
|
||||
### Fixed
|
||||
|
||||
- Windows path containment checks for security boundary enforcement
|
||||
- Git bash detection on Windows with proper probe caching
|
||||
- Unresolvable path denial to prevent escape attempts
|
||||
- Auto-compaction context window measurement
|
||||
- Tool result preservation on failed turns
|
||||
- Model stall recovery without tool calls
|
||||
- Streaming markdown formatting with preserved block separators
|
||||
- Local model validation bypass generalization beyond Ollama
|
||||
- Release artifact checksums on macOS and Linux
|
||||
- Duplicate assistant text rendering
|
||||
- Workspace slash command wire-up
|
||||
|
||||
### Changed
|
||||
|
||||
- Enhanced path scope validation for multiple Windows path spellings
|
||||
- Improved error handling and configuration defaults
|
||||
- Upgraded GitHub Actions to immutable commit SHAs for CI security
|
||||
|
||||
### Known Limitations
|
||||
|
||||
- ACP / Zed integration is not yet implemented (status stub available via `claw acp`)
|
||||
- Python porting workspace is for reference only, not used in production
|
||||
|
||||
## [0.1.3] - 2026-10-13
|
||||
|
||||
This is the first public binary release of Claw Code.
|
||||
|
||||
@@ -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,338 +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` mode
|
||||
- ✓ Implement `:conflicts` mode
|
||||
- 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";
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
+1
-1
@@ -41,7 +41,7 @@ TUI rule: formatting fns take `&mut impl Write`, never stdout directly. Never mi
|
||||
|
||||
Library crates don't carry the `claw-` prefix. Binary crates do (except legacy `rusty-claude-cli`).
|
||||
|
||||
Workspace version is `0.1.4`, `publish = false`, MIT license.
|
||||
Workspace version is `0.1.3`, `publish = false`, MIT license.
|
||||
|
||||
No rust-toolchain file, no MSRV. CI pins `dtolnay/rust-toolchain@stable`.
|
||||
|
||||
|
||||
+54
-146
@@ -93,7 +93,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "api"
|
||||
version = "0.1.4"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"reqwest",
|
||||
@@ -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"
|
||||
@@ -478,7 +461,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||
|
||||
[[package]]
|
||||
name = "claw-analog"
|
||||
version = "0.1.4"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"api",
|
||||
"clap",
|
||||
@@ -498,7 +481,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "claw-rag-service"
|
||||
version = "0.1.4"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"blake3",
|
||||
@@ -531,7 +514,7 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||
|
||||
[[package]]
|
||||
name = "commands"
|
||||
version = "0.1.4"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"plugins",
|
||||
"runtime",
|
||||
@@ -540,7 +523,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "compat-harness"
|
||||
version = "0.1.4"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"commands",
|
||||
"runtime",
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -1568,7 +1545,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mock-anthropic-service"
|
||||
version = "0.1.4"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"api",
|
||||
"serde_json",
|
||||
@@ -1761,7 +1738,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "plugins"
|
||||
version = "0.1.4"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -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]]
|
||||
@@ -2136,7 +2115,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "runtime"
|
||||
version = "0.1.4"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"glob",
|
||||
"plugins",
|
||||
@@ -2261,7 +2240,7 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||
|
||||
[[package]]
|
||||
name = "rusty-claude-cli"
|
||||
version = "0.1.4"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"api",
|
||||
"commands",
|
||||
@@ -2331,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.4"
|
||||
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"
|
||||
@@ -2450,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"
|
||||
@@ -2619,7 +2572,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "telemetry"
|
||||
version = "0.1.4"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -2632,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",
|
||||
@@ -2753,7 +2706,6 @@ dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
"mio",
|
||||
"parking_lot",
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
"socket2 0.6.3",
|
||||
@@ -2793,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"
|
||||
@@ -2906,7 +2835,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tools"
|
||||
version = "0.1.4"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"api",
|
||||
"aspect-core",
|
||||
@@ -3027,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"
|
||||
@@ -3093,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"
|
||||
@@ -3111,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"
|
||||
@@ -3160,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"
|
||||
@@ -3468,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"
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ members = ["crates/*"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.4"
|
||||
version = "0.1.3"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
publish = false
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -82,7 +82,7 @@ async fn send_message_posts_json_and_parses_response() {
|
||||
);
|
||||
assert_eq!(
|
||||
request.headers.get("user-agent").map(String::as_str),
|
||||
Some("claude-code/0.1.4")
|
||||
Some("claude-code/0.1.3")
|
||||
);
|
||||
assert_eq!(
|
||||
request.headers.get("anthropic-beta").map(String::as_str),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,56 +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();
|
||||
|
||||
// Special handling for conflicts selector
|
||||
if selector == Some("conflicts") {
|
||||
let conflicts = extract_merge_conflicts(&lines);
|
||||
let (selected, num_lines) = if conflicts.is_empty() {
|
||||
(format!("No unresolved git merge conflicts in {}.", path), 1)
|
||||
} else {
|
||||
let display_path = absolute_path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or(path);
|
||||
let summary = format_conflict_summary(&conflicts, display_path);
|
||||
let summary_lines = summary.lines().count();
|
||||
(summary, summary_lines)
|
||||
};
|
||||
|
||||
return Ok(ReadFileOutput {
|
||||
kind: String::from("text"),
|
||||
file: TextFilePayload {
|
||||
file_path: absolute_path.to_string_lossy().into_owned(),
|
||||
content: selected,
|
||||
num_lines,
|
||||
start_line: 1,
|
||||
total_lines,
|
||||
},
|
||||
truncation_footer: None,
|
||||
});
|
||||
}
|
||||
|
||||
// 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"),
|
||||
@@ -303,255 +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,
|
||||
})
|
||||
}
|
||||
|
||||
/// Represents a merge conflict block in a file.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct ConflictBlock {
|
||||
start_line: usize, // 1-based line number where <<<<<<< starts
|
||||
separator_line: usize, // 1-based line number where ======= is
|
||||
end_line: usize, // 1-based line number where >>>>>>> is
|
||||
base_line: Option<usize>, // 1-based line number where ||||||| is (for 3-way)
|
||||
ours_label: Option<String>,
|
||||
base_label: Option<String>,
|
||||
theirs_label: Option<String>,
|
||||
}
|
||||
|
||||
/// Scans lines for unresolved git merge conflict blocks.
|
||||
fn extract_merge_conflicts(lines: &[&str]) -> Vec<ConflictBlock> {
|
||||
let mut blocks = Vec::new();
|
||||
let mut i = 0;
|
||||
|
||||
const OURS_PREFIX: &str = "<<<<<<<";
|
||||
const BASE_PREFIX: &str = "|||||||";
|
||||
const SEPARATOR: &str = "=======";
|
||||
const THEIRS_PREFIX: &str = ">>>>>>>";
|
||||
|
||||
while i < lines.len() {
|
||||
let line = lines[i];
|
||||
|
||||
// Check for start of conflict block
|
||||
if let Some(label) = extract_marker_label(line, OURS_PREFIX) {
|
||||
let ours_label = if label.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(label.to_string())
|
||||
};
|
||||
let start_line = i + 1; // Convert to 1-based
|
||||
let mut separator_line = None;
|
||||
let mut base_line = None;
|
||||
let mut base_label = None;
|
||||
let mut end_line = None;
|
||||
let mut theirs_label = None;
|
||||
let mut j = i + 1;
|
||||
|
||||
// Look for separator, optional base marker, and end marker
|
||||
while j < lines.len() {
|
||||
let current = lines[j];
|
||||
|
||||
// Check for base marker (3-way)
|
||||
if let Some(label) = extract_marker_label(current, BASE_PREFIX) {
|
||||
if base_line.is_none() {
|
||||
base_line = Some(j + 1);
|
||||
base_label = if label.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(label.to_string())
|
||||
};
|
||||
j += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for separator
|
||||
if current.trim() == SEPARATOR {
|
||||
if separator_line.is_none() {
|
||||
separator_line = Some(j + 1);
|
||||
j += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for end marker
|
||||
if let Some(label) = extract_marker_label(current, THEIRS_PREFIX) {
|
||||
if separator_line.is_some() {
|
||||
end_line = Some(j + 1);
|
||||
theirs_label = if label.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(label.to_string())
|
||||
};
|
||||
j += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
j += 1;
|
||||
}
|
||||
|
||||
// Only add if we found both separator and end marker
|
||||
if let (Some(sep_line), Some(end)) = (separator_line, end_line) {
|
||||
blocks.push(ConflictBlock {
|
||||
start_line,
|
||||
separator_line: sep_line,
|
||||
end_line: end,
|
||||
base_line,
|
||||
ours_label,
|
||||
base_label,
|
||||
theirs_label,
|
||||
});
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
i += 1;
|
||||
}
|
||||
|
||||
blocks
|
||||
}
|
||||
|
||||
/// Extract the optional label from a marker line.
|
||||
/// Returns the label text (stripped of leading/trailing spaces) if the line matches the prefix,
|
||||
/// or None otherwise.
|
||||
fn extract_marker_label<'a>(line: &'a str, prefix: &str) -> Option<&'a str> {
|
||||
if line.starts_with(prefix) {
|
||||
let rest = &line[prefix.len()..];
|
||||
// Accept either just the prefix or prefix + single space + label
|
||||
if rest.is_empty() {
|
||||
Some("")
|
||||
} else if rest.starts_with(' ') {
|
||||
Some(rest[1..].trim())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Format conflict blocks as a summary index, like GJC's implementation.
|
||||
fn format_conflict_summary(conflicts: &[ConflictBlock], display_path: &str) -> String {
|
||||
let mut output = Vec::new();
|
||||
let total = conflicts.len();
|
||||
let word = if total == 1 { "conflict" } else { "conflicts" };
|
||||
|
||||
output.push(format!(
|
||||
"⚠ {} unresolved {} in {}",
|
||||
total, word, display_path
|
||||
));
|
||||
|
||||
// Extract labels for header
|
||||
let ours_label = conflicts.iter().find_map(|c| c.ours_label.as_ref());
|
||||
let theirs_label = conflicts.iter().find_map(|c| c.theirs_label.as_ref());
|
||||
let base_label = conflicts.iter().find_map(|c| c.base_label.as_ref());
|
||||
let any_base = conflicts.iter().any(|c| c.base_line.is_some());
|
||||
|
||||
if let Some(label) = ours_label {
|
||||
output.push(format!("- ours = {}", label));
|
||||
}
|
||||
if let Some(label) = theirs_label {
|
||||
output.push(format!("- theirs = {}", label));
|
||||
}
|
||||
if any_base {
|
||||
let base_name = base_label
|
||||
.map(|l| l.to_string())
|
||||
.unwrap_or_else(|| "(no label)".to_string());
|
||||
output.push(format!("- base = {}", base_name));
|
||||
}
|
||||
|
||||
output.push(
|
||||
"NOTICE: Bulk-resolve with `write({ path: \"conflict://*\", content })`, or address a single block with `write({ path: \"conflict://<N>\", content })`. Inspect a block by reading `conflict://<N>` (add `/ours` / `/theirs` / `/base` for a single side).".to_string()
|
||||
);
|
||||
output.push(
|
||||
"`content` shorthand: `@ours` / `@theirs` / `@base` / `@both` lines expand to the recorded sections; `@both` = ours-then-theirs. Non-token lines pass through verbatim.".to_string()
|
||||
);
|
||||
output.push(String::new());
|
||||
|
||||
// Format each conflict as a one-liner index
|
||||
let id_width = total.to_string().len();
|
||||
for (idx, conflict) in conflicts.iter().enumerate() {
|
||||
let id = idx + 1;
|
||||
let range = if conflict.start_line == conflict.end_line {
|
||||
format!("L{}", conflict.start_line)
|
||||
} else {
|
||||
format!("L{}-{}", conflict.start_line, conflict.end_line)
|
||||
};
|
||||
let kind = if conflict.base_line.is_some() {
|
||||
" (3-way)"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
output.push(format!(
|
||||
"#{:>width$} {}{}",
|
||||
id,
|
||||
range,
|
||||
kind,
|
||||
width = id_width
|
||||
));
|
||||
}
|
||||
|
||||
output.join("\n")
|
||||
}
|
||||
|
||||
/// 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 => {
|
||||
// Conflicts are handled specially in read_file_with_selector, so this shouldn't be reached
|
||||
(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 {
|
||||
@@ -1022,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.
|
||||
@@ -1502,189 +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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_with_selector_conflicts_no_conflicts() {
|
||||
let path = temp_path("conflicts-none.txt");
|
||||
write_file(
|
||||
path.to_string_lossy().as_ref(),
|
||||
"const a = 1;\nconst b = 2;\n",
|
||||
)
|
||||
.expect("write should succeed");
|
||||
|
||||
let output = super::read_file_with_selector(
|
||||
path.to_string_lossy().as_ref(),
|
||||
None,
|
||||
None,
|
||||
Some("conflicts"),
|
||||
)
|
||||
.expect("read with conflicts selector should succeed");
|
||||
assert!(output
|
||||
.file
|
||||
.content
|
||||
.contains("No unresolved git merge conflicts"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_with_selector_conflicts_two_way() {
|
||||
let path = temp_path("conflicts-two-way.txt");
|
||||
let content = "<<<<<<< A\na-ours\n=======\na-theirs\n>>>>>>> A\nmiddle\n<<<<<<< B\nb-ours\n=======\nb-theirs\n>>>>>>> B\ntail";
|
||||
write_file(path.to_string_lossy().as_ref(), content).expect("write should succeed");
|
||||
|
||||
let output = super::read_file_with_selector(
|
||||
path.to_string_lossy().as_ref(),
|
||||
None,
|
||||
None,
|
||||
Some("conflicts"),
|
||||
)
|
||||
.expect("read with conflicts selector should succeed");
|
||||
|
||||
let text = &output.file.content;
|
||||
// Should report 2 conflicts
|
||||
assert!(text.contains("2 unresolved conflicts"), "text: {}", text);
|
||||
// Should have indices for both blocks
|
||||
assert!(text.contains("#1"), "missing #1: {}", text);
|
||||
assert!(text.contains("#2"), "missing #2: {}", text);
|
||||
// Should have line ranges
|
||||
assert!(text.contains("L1-5"), "missing L1-5: {}", text);
|
||||
assert!(text.contains("L7-11"), "missing L7-11: {}", text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_with_selector_conflicts_three_way() {
|
||||
let path = temp_path("conflicts-three-way.txt");
|
||||
let content = "head\n<<<<<<< HEAD\nours body\n||||||| common ancestor\nbase body\n=======\ntheirs body\n>>>>>>> feat\ntail";
|
||||
write_file(path.to_string_lossy().as_ref(), content).expect("write should succeed");
|
||||
|
||||
let output = super::read_file_with_selector(
|
||||
path.to_string_lossy().as_ref(),
|
||||
None,
|
||||
None,
|
||||
Some("conflicts"),
|
||||
)
|
||||
.expect("read with conflicts selector should succeed");
|
||||
|
||||
let text = &output.file.content;
|
||||
// Should report 1 conflict
|
||||
assert!(text.contains("1 unresolved conflict"), "text: {}", text);
|
||||
// Should mark as 3-way
|
||||
assert!(text.contains("3-way"), "missing 3-way marker: {}", text);
|
||||
// Should show line range
|
||||
assert!(text.contains("L2-8"), "missing L2-8: {}", text);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -88,8 +87,7 @@ pub use conversation::{
|
||||
};
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,6 @@ use std::net::TcpListener;
|
||||
use std::ops::{Deref, DerefMut};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::thread::{self, JoinHandle};
|
||||
@@ -6715,49 +6714,6 @@ fn run_resume_command(
|
||||
message: Some(render_memory_report()?),
|
||||
json: Some(render_memory_json()?),
|
||||
}),
|
||||
SlashCommand::Workspace { path } => {
|
||||
let cwd_before = env::current_dir()?;
|
||||
let workspace_root = session
|
||||
.workspace_root()
|
||||
.map(Path::to_path_buf)
|
||||
.unwrap_or_else(|| cwd_before.clone());
|
||||
let workspace_root = canonicalize_or_clone(&workspace_root);
|
||||
let changed = if let Some(path) = path.as_deref() {
|
||||
let requested_path = Path::new(path);
|
||||
let resolved_path = if requested_path.is_absolute() {
|
||||
requested_path.to_path_buf()
|
||||
} else {
|
||||
cwd_before.join(requested_path)
|
||||
};
|
||||
let resolved_path = canonicalize_or_clone(&resolved_path);
|
||||
if !resolved_path.starts_with(&workspace_root) {
|
||||
return Err(format!(
|
||||
"workspace_change_outside_root: `{}` is outside the current workspace root `{}`.\nUse `claw --cwd {}` to start a new session there.",
|
||||
resolved_path.display(),
|
||||
workspace_root.display(),
|
||||
resolved_path.display(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
env::set_current_dir(&resolved_path)?;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let cwd_after = env::current_dir()?;
|
||||
let message =
|
||||
render_workspace_report(&cwd_after, &workspace_root, &session.session_id, changed);
|
||||
Ok(ResumeCommandOutcome {
|
||||
session: session.clone(),
|
||||
message: Some(message),
|
||||
json: Some(workspace_report_json(
|
||||
&cwd_after,
|
||||
&workspace_root,
|
||||
&session.session_id,
|
||||
changed,
|
||||
)),
|
||||
})
|
||||
}
|
||||
SlashCommand::Init => {
|
||||
// #142: run the init once, then render both text + structured JSON
|
||||
// from the same InitReport so both surfaces stay in sync.
|
||||
@@ -7746,7 +7702,6 @@ impl LiveCli {
|
||||
system_prompt.clone(),
|
||||
enable_tools,
|
||||
true,
|
||||
TerminalTail::new(),
|
||||
allowed_tools.clone(),
|
||||
permission_mode,
|
||||
None,
|
||||
@@ -7828,7 +7783,6 @@ impl LiveCli {
|
||||
fn prepare_turn_runtime(
|
||||
&self,
|
||||
emit_output: bool,
|
||||
terminal_tail: TerminalTail,
|
||||
) -> Result<(BuiltRuntime, HookAbortMonitor), Box<dyn std::error::Error>> {
|
||||
let hook_abort_signal = runtime::HookAbortSignal::new();
|
||||
let runtime = build_runtime(
|
||||
@@ -7838,7 +7792,6 @@ impl LiveCli {
|
||||
self.system_prompt.clone(),
|
||||
true,
|
||||
emit_output,
|
||||
terminal_tail,
|
||||
self.allowed_tools.clone(),
|
||||
self.permission_mode,
|
||||
None,
|
||||
@@ -7856,39 +7809,27 @@ impl LiveCli {
|
||||
}
|
||||
|
||||
fn run_turn(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let terminal_tail = TerminalTail::new();
|
||||
let (mut runtime, hook_abort_monitor) =
|
||||
self.prepare_turn_runtime(true, terminal_tail.clone())?;
|
||||
let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(true)?;
|
||||
let mut spinner = Spinner::new();
|
||||
let mut stdout = io::stdout();
|
||||
// The animated frame is cursor control: it saves the cursor, rewrites the
|
||||
// current line, and restores the cursor. None of that describes anything
|
||||
// when stdout is a pipe — the escapes survive as literal bytes and the
|
||||
// banner ends up glued to whatever the emitter writes next. Only animate
|
||||
// on a terminal; the one-shot completion line below is plain text and is
|
||||
// emitted either way.
|
||||
if io::stdout().is_terminal() {
|
||||
spinner.tick(
|
||||
"🦀 Thinking...",
|
||||
TerminalRenderer::new().color_theme(),
|
||||
&mut stdout,
|
||||
)?;
|
||||
}
|
||||
spinner.tick(
|
||||
"🦀 Thinking...",
|
||||
TerminalRenderer::new().color_theme(),
|
||||
&mut stdout,
|
||||
)?;
|
||||
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
|
||||
let result = runtime.run_turn(input, Some(&mut permission_prompter));
|
||||
hook_abort_monitor.stop();
|
||||
match result {
|
||||
Ok(summary) => {
|
||||
self.replace_runtime(runtime)?;
|
||||
// The emitter owns the terminal during a turn: `emit_output = true`
|
||||
// streams text deltas and echoes tool results live, so the response
|
||||
// must not be printed again here — that was the duplicate output
|
||||
// this PR's predecessor fixed.
|
||||
//
|
||||
// What the emitter may have left behind is a partial line, so ask it
|
||||
// rather than guessing from `final_assistant_text(&summary)`: that
|
||||
// guess is empty for tool-only turns, which still render.
|
||||
if !terminal_tail.at_line_start() {
|
||||
// The assistant response was already streamed to the terminal live
|
||||
// during the turn (emit_output = true), so reprinting the full text
|
||||
// here would duplicate the entire message. When text was streamed,
|
||||
// emit a newline first so `spinner.finish`'s line-clear lands on a
|
||||
// fresh line instead of erasing the last streamed line.
|
||||
let final_text = final_assistant_text(&summary);
|
||||
if !final_text.is_empty() {
|
||||
println!();
|
||||
}
|
||||
spinner.finish(
|
||||
@@ -7908,13 +7849,6 @@ impl LiveCli {
|
||||
}
|
||||
Err(error) => {
|
||||
runtime.shutdown_plugins()?;
|
||||
// Same ownership rule as the success arm: `fail` clears the current
|
||||
// line, so it may only do so when the emitter stopped on a line
|
||||
// boundary. A turn that streamed text and then failed would
|
||||
// otherwise lose its last partial line to the clear.
|
||||
if !terminal_tail.at_line_start() {
|
||||
println!();
|
||||
}
|
||||
spinner.fail(
|
||||
"❌ Request failed",
|
||||
TerminalRenderer::new().color_theme(),
|
||||
@@ -8027,14 +7961,14 @@ impl LiveCli {
|
||||
|
||||
// Build a new runtime with the compacted session and retry
|
||||
let (mut new_runtime, hook_abort_monitor) =
|
||||
self.prepare_turn_runtime(true, terminal_tail.clone())?;
|
||||
self.prepare_turn_runtime(true)?;
|
||||
drop(hook_abort_monitor);
|
||||
|
||||
let mut rp = CliPermissionPrompter::new(self.permission_mode);
|
||||
match new_runtime.run_turn(input, Some(&mut rp)) {
|
||||
Ok(summary) => {
|
||||
self.replace_runtime(new_runtime)?;
|
||||
if !terminal_tail.at_line_start() {
|
||||
if !final_assistant_text(&summary).is_empty() {
|
||||
println!();
|
||||
}
|
||||
spinner.finish(
|
||||
@@ -8119,8 +8053,7 @@ impl LiveCli {
|
||||
}
|
||||
|
||||
fn run_prompt_compact(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let (mut runtime, hook_abort_monitor) =
|
||||
self.prepare_turn_runtime(false, TerminalTail::new())?;
|
||||
let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(false)?;
|
||||
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
|
||||
let result = runtime.run_turn(input, Some(&mut permission_prompter));
|
||||
hook_abort_monitor.stop();
|
||||
@@ -8133,8 +8066,7 @@ impl LiveCli {
|
||||
}
|
||||
|
||||
fn run_prompt_compact_json(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let (mut runtime, hook_abort_monitor) =
|
||||
self.prepare_turn_runtime(false, TerminalTail::new())?;
|
||||
let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(false)?;
|
||||
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
|
||||
let result = runtime.run_turn(input, Some(&mut permission_prompter));
|
||||
hook_abort_monitor.stop();
|
||||
@@ -8159,8 +8091,7 @@ impl LiveCli {
|
||||
}
|
||||
|
||||
fn run_prompt_json(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let (mut runtime, hook_abort_monitor) =
|
||||
self.prepare_turn_runtime(false, TerminalTail::new())?;
|
||||
let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(false)?;
|
||||
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
|
||||
let result = runtime.run_turn(input, Some(&mut permission_prompter));
|
||||
hook_abort_monitor.stop();
|
||||
@@ -8259,7 +8190,6 @@ impl LiveCli {
|
||||
Self::print_config(section.as_deref())?;
|
||||
false
|
||||
}
|
||||
SlashCommand::Workspace { path } => self.handle_workspace_command(path.as_deref())?,
|
||||
SlashCommand::Mcp { action, target } => {
|
||||
let args = match (action.as_deref(), target.as_deref()) {
|
||||
(None, None) => None,
|
||||
@@ -8418,51 +8348,6 @@ impl LiveCli {
|
||||
);
|
||||
}
|
||||
|
||||
fn handle_workspace_command(
|
||||
&mut self,
|
||||
target: Option<&str>,
|
||||
) -> Result<bool, Box<dyn std::error::Error>> {
|
||||
let current_dir = env::current_dir()?;
|
||||
let workspace_root = self
|
||||
.runtime
|
||||
.session()
|
||||
.workspace_root()
|
||||
.map(Path::to_path_buf)
|
||||
.unwrap_or_else(|| current_dir.clone());
|
||||
let workspace_root = canonicalize_or_clone(&workspace_root);
|
||||
|
||||
if let Some(target) = target {
|
||||
let requested_path = Path::new(target);
|
||||
let resolved_path = if requested_path.is_absolute() {
|
||||
requested_path.to_path_buf()
|
||||
} else {
|
||||
current_dir.join(requested_path)
|
||||
};
|
||||
let resolved_path = canonicalize_or_clone(&resolved_path);
|
||||
if !resolved_path.starts_with(&workspace_root) {
|
||||
return Err(format!(
|
||||
"workspace_change_outside_root: `{}` is outside the current workspace root `{}`.\nUse `claw --cwd {}` to start a new session there.",
|
||||
resolved_path.display(),
|
||||
workspace_root.display(),
|
||||
resolved_path.display(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
env::set_current_dir(&resolved_path)?;
|
||||
}
|
||||
|
||||
println!(
|
||||
"{}",
|
||||
render_workspace_report(
|
||||
&env::current_dir()?,
|
||||
&workspace_root,
|
||||
&self.session.id,
|
||||
target.is_some(),
|
||||
)
|
||||
);
|
||||
Ok(target.is_some())
|
||||
}
|
||||
|
||||
fn record_prompt_history(&mut self, prompt: &str) {
|
||||
let timestamp_ms = std::time::SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
@@ -8562,7 +8447,6 @@ impl LiveCli {
|
||||
self.system_prompt.clone(),
|
||||
true,
|
||||
true,
|
||||
TerminalTail::new(),
|
||||
self.allowed_tools.clone(),
|
||||
self.permission_mode,
|
||||
None,
|
||||
@@ -8609,7 +8493,6 @@ impl LiveCli {
|
||||
self.system_prompt.clone(),
|
||||
true,
|
||||
true,
|
||||
TerminalTail::new(),
|
||||
self.allowed_tools.clone(),
|
||||
self.permission_mode,
|
||||
None,
|
||||
@@ -8640,7 +8523,6 @@ impl LiveCli {
|
||||
self.system_prompt.clone(),
|
||||
true,
|
||||
true,
|
||||
TerminalTail::new(),
|
||||
self.allowed_tools.clone(),
|
||||
self.permission_mode,
|
||||
None,
|
||||
@@ -8683,7 +8565,6 @@ impl LiveCli {
|
||||
self.system_prompt.clone(),
|
||||
true,
|
||||
true,
|
||||
TerminalTail::new(),
|
||||
self.allowed_tools.clone(),
|
||||
self.permission_mode,
|
||||
None,
|
||||
@@ -9037,7 +8918,6 @@ impl LiveCli {
|
||||
self.system_prompt.clone(),
|
||||
true,
|
||||
true,
|
||||
TerminalTail::new(),
|
||||
self.allowed_tools.clone(),
|
||||
self.permission_mode,
|
||||
None,
|
||||
@@ -9073,7 +8953,6 @@ impl LiveCli {
|
||||
self.system_prompt.clone(),
|
||||
true,
|
||||
true,
|
||||
TerminalTail::new(),
|
||||
self.allowed_tools.clone(),
|
||||
self.permission_mode,
|
||||
None,
|
||||
@@ -9168,7 +9047,6 @@ impl LiveCli {
|
||||
self.system_prompt.clone(),
|
||||
true,
|
||||
true,
|
||||
TerminalTail::new(),
|
||||
self.allowed_tools.clone(),
|
||||
self.permission_mode,
|
||||
None,
|
||||
@@ -9189,7 +9067,6 @@ impl LiveCli {
|
||||
self.system_prompt.clone(),
|
||||
true,
|
||||
true,
|
||||
TerminalTail::new(),
|
||||
self.allowed_tools.clone(),
|
||||
self.permission_mode,
|
||||
None,
|
||||
@@ -9214,7 +9091,6 @@ impl LiveCli {
|
||||
self.system_prompt.clone(),
|
||||
enable_tools,
|
||||
false,
|
||||
TerminalTail::new(),
|
||||
self.allowed_tools.clone(),
|
||||
self.permission_mode,
|
||||
progress,
|
||||
@@ -9303,41 +9179,6 @@ fn new_cli_session() -> Result<Session, Box<dyn std::error::Error>> {
|
||||
Ok(Session::new().with_workspace_root(env::current_dir()?))
|
||||
}
|
||||
|
||||
fn canonicalize_or_clone(path: &Path) -> PathBuf {
|
||||
fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
|
||||
}
|
||||
|
||||
fn render_workspace_report(
|
||||
cwd: &Path,
|
||||
workspace_root: &Path,
|
||||
session_id: &str,
|
||||
changed: bool,
|
||||
) -> String {
|
||||
let action = if changed { "change" } else { "show" };
|
||||
format!(
|
||||
"Workspace\n Action {action}\n Session {session_id}\n Workspace root {}\n Current directory {}",
|
||||
workspace_root.display(),
|
||||
cwd.display(),
|
||||
)
|
||||
}
|
||||
|
||||
fn workspace_report_json(
|
||||
cwd: &Path,
|
||||
workspace_root: &Path,
|
||||
session_id: &str,
|
||||
changed: bool,
|
||||
) -> Value {
|
||||
serde_json::json!({
|
||||
"kind": "workspace",
|
||||
"action": if changed { "change" } else { "show" },
|
||||
"status": "ok",
|
||||
"session_id": session_id,
|
||||
"workspace_root": workspace_root.display().to_string(),
|
||||
"current_directory": cwd.display().to_string(),
|
||||
"changed": changed,
|
||||
})
|
||||
}
|
||||
|
||||
fn create_managed_session_handle(
|
||||
session_id: &str,
|
||||
) -> Result<SessionHandle, Box<dyn std::error::Error>> {
|
||||
@@ -12659,7 +12500,6 @@ fn build_runtime(
|
||||
system_prompt: Vec<String>,
|
||||
enable_tools: bool,
|
||||
emit_output: bool,
|
||||
terminal_tail: TerminalTail,
|
||||
allowed_tools: Option<AllowedToolSet>,
|
||||
permission_mode: PermissionMode,
|
||||
progress_reporter: Option<InternalPromptProgressReporter>,
|
||||
@@ -12672,7 +12512,6 @@ fn build_runtime(
|
||||
system_prompt,
|
||||
enable_tools,
|
||||
emit_output,
|
||||
terminal_tail,
|
||||
allowed_tools,
|
||||
permission_mode,
|
||||
progress_reporter,
|
||||
@@ -12689,7 +12528,6 @@ fn build_runtime_with_plugin_state(
|
||||
system_prompt: Vec<String>,
|
||||
enable_tools: bool,
|
||||
emit_output: bool,
|
||||
terminal_tail: TerminalTail,
|
||||
allowed_tools: Option<AllowedToolSet>,
|
||||
permission_mode: PermissionMode,
|
||||
progress_reporter: Option<InternalPromptProgressReporter>,
|
||||
@@ -12708,7 +12546,6 @@ fn build_runtime_with_plugin_state(
|
||||
plugin_registry.initialize()?;
|
||||
let policy = permission_policy(permission_mode, &feature_config, &tool_registry)
|
||||
.map_err(std::io::Error::other)?;
|
||||
let provider_config = provider_config_from_runtime(feature_config.provider())?;
|
||||
let mut runtime = ConversationRuntime::new_with_features(
|
||||
session,
|
||||
AnthropicRuntimeClient::new(
|
||||
@@ -12716,16 +12553,13 @@ fn build_runtime_with_plugin_state(
|
||||
model,
|
||||
enable_tools,
|
||||
emit_output,
|
||||
terminal_tail.clone(),
|
||||
allowed_tools.clone(),
|
||||
tool_registry.clone(),
|
||||
progress_reporter,
|
||||
provider_config,
|
||||
)?,
|
||||
CliToolExecutor::new(
|
||||
allowed_tools.clone(),
|
||||
emit_output,
|
||||
terminal_tail,
|
||||
tool_registry.clone(),
|
||||
mcp_state.clone(),
|
||||
),
|
||||
@@ -12821,157 +12655,12 @@ impl runtime::PermissionPrompter for CliPermissionPrompter {
|
||||
}
|
||||
}
|
||||
|
||||
/// Tracks whether the shared terminal stream currently sits at the start of a
|
||||
/// fresh line.
|
||||
///
|
||||
/// A turn's streamed assistant text has no trailing newline, and
|
||||
/// `Spinner::finish` moves to column 0 and clears the current line — so without
|
||||
/// a guard it erases the last thing the emitter rendered. Both writers that
|
||||
/// render live during a turn (the assistant/reasoning deltas in
|
||||
/// `AnthropicRuntimeClient::consume_stream` and the tool-result blocks in
|
||||
/// `CliToolExecutor::execute`) push their bytes
|
||||
/// through a `TailTrackingWriter` sharing one of these, which lets the caller
|
||||
/// that ends the turn ask the emitter what it actually left on the terminal.
|
||||
///
|
||||
/// The previous approach inferred this from `final_assistant_text(&summary)`,
|
||||
/// which agrees today only because every *other* writer terminates its output
|
||||
/// with a newline — tool results go through `stream_markdown` (which appends
|
||||
/// one), thinking summaries are written with a trailing newline, and tool-call
|
||||
/// headers use `writeln!`. That is a property of the current renderers rather
|
||||
/// than of the streaming protocol, and the text path already violates it, so
|
||||
/// recording the fact is cheaper than depending on it. The heuristic also left
|
||||
/// the failure arm unguarded: a turn that streamed text and then errored still
|
||||
/// had that line cleared.
|
||||
#[derive(Clone, Default)]
|
||||
struct TerminalTail {
|
||||
at_line_start: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl TerminalTail {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
at_line_start: Arc::new(AtomicBool::new(true)),
|
||||
}
|
||||
}
|
||||
|
||||
/// True when nothing has been written since the last newline, so clearing
|
||||
/// the current terminal line cannot destroy rendered output.
|
||||
fn at_line_start(&self) -> bool {
|
||||
self.at_line_start.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
fn record(&self, written: &[u8]) {
|
||||
if let Some(last) = written.last() {
|
||||
self.at_line_start.store(*last == b'\n', Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Forwards writes to `inner` while reporting the tail byte to a `TerminalTail`.
|
||||
struct TailTrackingWriter<'a, W: Write> {
|
||||
inner: &'a mut W,
|
||||
tail: TerminalTail,
|
||||
}
|
||||
|
||||
impl<'a, W: Write> TailTrackingWriter<'a, W> {
|
||||
fn new(inner: &'a mut W, tail: TerminalTail) -> Self {
|
||||
Self { inner, tail }
|
||||
}
|
||||
}
|
||||
|
||||
impl<W: Write> Write for TailTrackingWriter<'_, W> {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
let written = self.inner.write(buf)?;
|
||||
self.tail.record(&buf[..written]);
|
||||
Ok(written)
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
self.inner.flush()
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: Despite the historical name `AnthropicRuntimeClient`, this struct
|
||||
// now holds an `ApiProviderClient` which dispatches to Anthropic, xAI,
|
||||
// OpenAI, or DashScope at construction time. Explicit persisted provider
|
||||
// configuration takes precedence; model-based provider detection remains
|
||||
// the fallback when no provider configuration is present. The struct name
|
||||
// is kept to avoid churning `BuiltRuntime` and every Deref/DerefMut site
|
||||
// that references it.
|
||||
|
||||
fn provider_config_from_runtime(
|
||||
provider: &runtime::RuntimeProviderConfig,
|
||||
) -> Result<Option<api::ProviderConfig>, Box<dyn std::error::Error>> {
|
||||
let Some(kind) = provider.kind() else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let kind = match kind.trim().to_ascii_lowercase().as_str() {
|
||||
"anthropic" => api::ProviderConfigKind::Anthropic,
|
||||
"xai" => api::ProviderConfigKind::Xai,
|
||||
"openai" => api::ProviderConfigKind::OpenAi,
|
||||
"dashscope" => api::ProviderConfigKind::DashScope,
|
||||
other => {
|
||||
return Err(format!(
|
||||
"invalid provider configuration: unsupported provider kind `{other}`"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Some(api::ProviderConfig {
|
||||
kind,
|
||||
model: provider.model().unwrap_or_default().to_string(),
|
||||
api_key: provider.api_key().map(str::to_string),
|
||||
base_url: provider.base_url().map(str::to_string),
|
||||
}))
|
||||
}
|
||||
|
||||
fn explicit_provider_kind_for_model(
|
||||
model: &str,
|
||||
persisted_model: &str,
|
||||
) -> Option<api::ProviderConfigKind> {
|
||||
let resolved_model = api::resolve_model_alias(model);
|
||||
let persisted_resolved = api::resolve_model_alias(persisted_model);
|
||||
|
||||
if resolved_model.eq_ignore_ascii_case(&persisted_resolved) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let namespace = model
|
||||
.trim()
|
||||
.split_once('/')
|
||||
.map(|(namespace, _)| namespace.to_ascii_lowercase());
|
||||
|
||||
match namespace.as_deref() {
|
||||
Some("anthropic") => Some(api::ProviderConfigKind::Anthropic),
|
||||
Some("xai") => Some(api::ProviderConfigKind::Xai),
|
||||
Some("openai") | Some("local") => Some(api::ProviderConfigKind::OpenAi),
|
||||
Some("dashscope") | Some("qwen") | Some("kimi") => Some(api::ProviderConfigKind::DashScope),
|
||||
_ => {
|
||||
let lower = resolved_model.to_ascii_lowercase();
|
||||
if lower.starts_with("qwen/")
|
||||
|| lower.starts_with("qwen-")
|
||||
|| lower.starts_with("kimi/")
|
||||
|| lower.starts_with("kimi-")
|
||||
{
|
||||
Some(api::ProviderConfigKind::DashScope)
|
||||
} else if lower.starts_with("grok/") || lower.starts_with("grok-") {
|
||||
Some(api::ProviderConfigKind::Xai)
|
||||
} else if lower.starts_with("claude") || lower.starts_with("anthropic/") {
|
||||
Some(api::ProviderConfigKind::Anthropic)
|
||||
} else if lower.starts_with("openai/")
|
||||
|| lower.starts_with("gpt-")
|
||||
|| lower.starts_with("local/")
|
||||
{
|
||||
Some(api::ProviderConfigKind::OpenAi)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// OpenAI, or DashScope at construction time based on
|
||||
// `detect_provider_kind(&model)`. The struct name is kept to avoid
|
||||
// churning `BuiltRuntime` and every Deref/DerefMut site that references
|
||||
// it. See ROADMAP #29 for the provider-dispatch routing fix.
|
||||
struct AnthropicRuntimeClient {
|
||||
runtime: tokio::runtime::Runtime,
|
||||
client: ApiProviderClient,
|
||||
@@ -12979,7 +12668,6 @@ struct AnthropicRuntimeClient {
|
||||
model: String,
|
||||
enable_tools: bool,
|
||||
emit_output: bool,
|
||||
terminal_tail: TerminalTail,
|
||||
allowed_tools: Option<AllowedToolSet>,
|
||||
tool_registry: GlobalToolRegistry,
|
||||
progress_reporter: Option<InternalPromptProgressReporter>,
|
||||
@@ -12992,69 +12680,50 @@ impl AnthropicRuntimeClient {
|
||||
model: String,
|
||||
enable_tools: bool,
|
||||
emit_output: bool,
|
||||
terminal_tail: TerminalTail,
|
||||
allowed_tools: Option<AllowedToolSet>,
|
||||
tool_registry: GlobalToolRegistry,
|
||||
progress_reporter: Option<InternalPromptProgressReporter>,
|
||||
provider_config: Option<api::ProviderConfig>,
|
||||
) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
// Explicit persisted provider configuration takes precedence.
|
||||
// When absent, preserve the existing model-based provider detection.
|
||||
// Dispatch to the correct provider at construction time.
|
||||
// `ApiProviderClient` (exposed by the api crate as
|
||||
// `ProviderClient`) is an enum over Anthropic / xAI / OpenAI
|
||||
// variants, where xAI and OpenAI both use the OpenAI-compat
|
||||
// wire format under the hood. We consult
|
||||
// `detect_provider_kind(&resolved_model)` so model-name prefix
|
||||
// routing (`openai/`, `gpt-`, `grok`, `qwen/`) wins over
|
||||
// env-var presence.
|
||||
//
|
||||
// For Anthropic we build the client directly instead of going
|
||||
// through `ApiProviderClient::from_model_with_anthropic_auth`
|
||||
// so we can explicitly apply `api::read_base_url()` — that
|
||||
// reads `ANTHROPIC_BASE_URL` and is required for the local
|
||||
// mock-server test harness
|
||||
// (`crates/rusty-claude-cli/tests/compact_output.rs`) to point
|
||||
// claw at its fake Anthropic endpoint. We also attach a
|
||||
// session-scoped prompt cache on the Anthropic path; the
|
||||
// prompt cache is Anthropic-only so non-Anthropic variants
|
||||
// skip it.
|
||||
let resolved_model = api::resolve_model_alias(&model);
|
||||
let provider_config = provider_config.map(|config| {
|
||||
if let Some(kind) = explicit_provider_kind_for_model(&model, config.model.as_str()) {
|
||||
api::ProviderConfig {
|
||||
kind,
|
||||
model: resolved_model.clone(),
|
||||
..config
|
||||
}
|
||||
} else {
|
||||
config
|
||||
let client = match detect_provider_kind(&resolved_model) {
|
||||
ProviderKind::Anthropic => {
|
||||
let auth = resolve_cli_auth_source()?;
|
||||
let inner = AnthropicClient::from_auth(auth)
|
||||
.with_base_url(api::read_base_url())
|
||||
.with_prompt_cache(PromptCache::new(session_id));
|
||||
ApiProviderClient::Anthropic(inner)
|
||||
}
|
||||
});
|
||||
let client = if let Some(config) = provider_config {
|
||||
match config.kind {
|
||||
api::ProviderConfigKind::Anthropic => {
|
||||
let auth = match resolve_cli_auth_source() {
|
||||
Ok(auth) => auth,
|
||||
Err(_) => {
|
||||
config
|
||||
.api_key
|
||||
.clone()
|
||||
.map(AuthSource::ApiKey)
|
||||
.ok_or_else(|| {
|
||||
api::ApiError::missing_credentials(
|
||||
"Anthropic",
|
||||
&["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"],
|
||||
)
|
||||
})?
|
||||
}
|
||||
};
|
||||
let mut inner = AnthropicClient::from_auth(auth);
|
||||
let base_url = std::env::var("ANTHROPIC_BASE_URL")
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.or_else(|| config.base_url.clone())
|
||||
.unwrap_or_else(api::read_base_url);
|
||||
inner = inner.with_base_url(base_url);
|
||||
ApiProviderClient::Anthropic(
|
||||
inner.with_prompt_cache(PromptCache::new(session_id)),
|
||||
)
|
||||
}
|
||||
_ => ApiProviderClient::from_config(&config)?,
|
||||
}
|
||||
} else {
|
||||
match detect_provider_kind(&resolved_model) {
|
||||
ProviderKind::Anthropic => {
|
||||
let auth = resolve_cli_auth_source()?;
|
||||
let inner = AnthropicClient::from_auth(auth)
|
||||
.with_base_url(api::read_base_url())
|
||||
.with_prompt_cache(PromptCache::new(session_id));
|
||||
ApiProviderClient::Anthropic(inner)
|
||||
}
|
||||
ProviderKind::Xai | ProviderKind::OpenAi => {
|
||||
ApiProviderClient::from_model_with_anthropic_auth(&resolved_model, None)?
|
||||
}
|
||||
ProviderKind::Xai | ProviderKind::OpenAi => {
|
||||
// The api crate's `ProviderClient::from_model_with_anthropic_auth`
|
||||
// with `None` for the anthropic auth routes via
|
||||
// `detect_provider_kind` and builds an
|
||||
// `OpenAiCompatClient::from_env` with the matching
|
||||
// `OpenAiCompatConfig` (openai / xai / dashscope).
|
||||
// That reads the correct API-key env var and BASE_URL
|
||||
// override internally, so this one call covers OpenAI,
|
||||
// OpenRouter, xAI, DashScope, Ollama, and any other
|
||||
// OpenAI-compat endpoint users configure via
|
||||
// `OPENAI_BASE_URL` / `XAI_BASE_URL` / `DASHSCOPE_BASE_URL`.
|
||||
ApiProviderClient::from_model_with_anthropic_auth(&resolved_model, None)?
|
||||
}
|
||||
};
|
||||
Ok(Self {
|
||||
@@ -13064,7 +12733,6 @@ impl AnthropicRuntimeClient {
|
||||
model,
|
||||
enable_tools,
|
||||
emit_output,
|
||||
terminal_tail,
|
||||
allowed_tools,
|
||||
tool_registry,
|
||||
progress_reporter,
|
||||
@@ -13164,9 +12832,8 @@ impl AnthropicRuntimeClient {
|
||||
})?;
|
||||
let mut stdout = io::stdout();
|
||||
let mut sink = io::sink();
|
||||
let mut tracked = TailTrackingWriter::new(&mut stdout, self.terminal_tail.clone());
|
||||
let out: &mut dyn Write = if self.emit_output {
|
||||
&mut tracked
|
||||
&mut stdout
|
||||
} else {
|
||||
&mut sink
|
||||
};
|
||||
@@ -14330,7 +13997,6 @@ fn prompt_cache_record_to_runtime_event(
|
||||
struct CliToolExecutor {
|
||||
renderer: TerminalRenderer,
|
||||
emit_output: bool,
|
||||
terminal_tail: TerminalTail,
|
||||
allowed_tools: Option<AllowedToolSet>,
|
||||
tool_registry: GlobalToolRegistry,
|
||||
mcp_state: Option<Arc<Mutex<RuntimeMcpState>>>,
|
||||
@@ -14340,14 +14006,12 @@ impl CliToolExecutor {
|
||||
fn new(
|
||||
allowed_tools: Option<AllowedToolSet>,
|
||||
emit_output: bool,
|
||||
terminal_tail: TerminalTail,
|
||||
tool_registry: GlobalToolRegistry,
|
||||
mcp_state: Option<Arc<Mutex<RuntimeMcpState>>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
renderer: TerminalRenderer::new(),
|
||||
emit_output,
|
||||
terminal_tail,
|
||||
allowed_tools,
|
||||
tool_registry,
|
||||
mcp_state,
|
||||
@@ -14441,11 +14105,8 @@ impl ToolExecutor for CliToolExecutor {
|
||||
Ok(output) => {
|
||||
if self.emit_output {
|
||||
let markdown = format_tool_result(tool_name, &output, false);
|
||||
let mut stdout = io::stdout();
|
||||
let mut tracked =
|
||||
TailTrackingWriter::new(&mut stdout, self.terminal_tail.clone());
|
||||
self.renderer
|
||||
.stream_markdown(&markdown, &mut tracked)
|
||||
.stream_markdown(&markdown, &mut io::stdout())
|
||||
.map_err(|error| ToolError::new(error.to_string()))?;
|
||||
}
|
||||
Ok(output)
|
||||
@@ -14453,11 +14114,8 @@ impl ToolExecutor for CliToolExecutor {
|
||||
Err(error) => {
|
||||
if self.emit_output {
|
||||
let markdown = format_tool_result(tool_name, &error.to_string(), true);
|
||||
let mut stdout = io::stdout();
|
||||
let mut tracked =
|
||||
TailTrackingWriter::new(&mut stdout, self.terminal_tail.clone());
|
||||
self.renderer
|
||||
.stream_markdown(&markdown, &mut tracked)
|
||||
.stream_markdown(&markdown, &mut io::stdout())
|
||||
.map_err(|stream_error| ToolError::new(stream_error.to_string()))?;
|
||||
}
|
||||
Err(error)
|
||||
@@ -14772,8 +14430,8 @@ mod tests {
|
||||
CliOutputFormat, CliToolExecutor, GitOperation, GitWorkspaceSummary,
|
||||
InternalPromptProgressEvent, InternalPromptProgressState, LiveCli, LocalHelpTopic,
|
||||
PermissionModeProvenance, PromptHistoryEntry, SessionLifecycleKind,
|
||||
SessionLifecycleSummary, SlashCommand, StatusUsage, TerminalTail, TmuxPaneSnapshot,
|
||||
DEFAULT_MODEL, LATEST_SESSION_REFERENCE, STUB_COMMANDS,
|
||||
SessionLifecycleSummary, SlashCommand, StatusUsage, TmuxPaneSnapshot, DEFAULT_MODEL,
|
||||
LATEST_SESSION_REFERENCE, STUB_COMMANDS,
|
||||
};
|
||||
use api::{ApiError, MessageResponse, OutputContentBlock, Usage};
|
||||
use plugins::{
|
||||
@@ -19651,119 +19309,6 @@ UU conflicted.rs",
|
||||
let _ = fs::remove_dir_all(source_root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_model_provider_hint_overrides_persisted_provider_kind() {
|
||||
let config = api::ProviderConfig {
|
||||
kind: api::ProviderConfigKind::DashScope,
|
||||
model: "qwen-plus".to_string(),
|
||||
api_key: Some("persisted-test-key".to_string()),
|
||||
base_url: None,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
super::explicit_provider_kind_for_model("openai/gpt-5", &config.model),
|
||||
Some(api::ProviderConfigKind::OpenAi)
|
||||
);
|
||||
assert_eq!(
|
||||
super::explicit_provider_kind_for_model("qwen-plus", &config.model),
|
||||
None,
|
||||
"matching persisted model must keep the persisted provider kind"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_model_provider_hint_controls_runtime_provider_dispatch() {
|
||||
let config = api::ProviderConfig {
|
||||
kind: api::ProviderConfigKind::DashScope,
|
||||
model: "qwen-plus".to_string(),
|
||||
api_key: Some("persisted-test-key".to_string()),
|
||||
base_url: None,
|
||||
};
|
||||
|
||||
let runtime = super::AnthropicRuntimeClient::new(
|
||||
"provider-resolution-explicit-model-test",
|
||||
"openai/gpt-5".to_string(),
|
||||
false,
|
||||
false,
|
||||
TerminalTail::new(),
|
||||
None,
|
||||
GlobalToolRegistry::builtin(),
|
||||
None,
|
||||
Some(config),
|
||||
)
|
||||
.expect("runtime client should construct from explicit model provider hint");
|
||||
|
||||
match runtime.client {
|
||||
api::ProviderClient::OpenAi(client) => {
|
||||
assert!(client.base_url().contains("api.openai.com"));
|
||||
assert!(!client.base_url().contains("dashscope.aliyuncs.com"));
|
||||
}
|
||||
other => panic!(
|
||||
"explicit OpenAI model prefix must override persisted DashScope configuration, got {other:?}"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persisted_provider_configuration_controls_runtime_provider_dispatch() {
|
||||
let config_home = temp_dir();
|
||||
let workspace = temp_dir();
|
||||
fs::create_dir_all(&config_home).expect("config home");
|
||||
fs::create_dir_all(&workspace).expect("workspace");
|
||||
|
||||
fs::write(
|
||||
config_home.join("settings.json"),
|
||||
r#"{
|
||||
"provider": {
|
||||
"kind": "dashscope",
|
||||
"apiKey": "persisted-test-key",
|
||||
"baseUrl": "https://dashscope.example/v1",
|
||||
"model": "qwen-plus"
|
||||
},
|
||||
"model": "qwen-plus"
|
||||
}"#,
|
||||
)
|
||||
.expect("write provider settings");
|
||||
|
||||
let loader = ConfigLoader::new(&workspace, &config_home);
|
||||
let runtime_config = loader.load().expect("runtime config should load");
|
||||
let state = build_runtime_plugin_state_with_loader(&workspace, &loader, &runtime_config)
|
||||
.expect("runtime plugin state should load");
|
||||
|
||||
let provider_config = super::provider_config_from_runtime(state.feature_config.provider())
|
||||
.expect("provider configuration should adapt")
|
||||
.expect("persisted provider configuration should be present");
|
||||
|
||||
assert_eq!(
|
||||
provider_config.kind,
|
||||
api::ProviderConfigKind::DashScope,
|
||||
"persisted provider kind must survive the runtime configuration boundary"
|
||||
);
|
||||
|
||||
let runtime = super::AnthropicRuntimeClient::new(
|
||||
"provider-resolution-test",
|
||||
"qwen-plus".to_string(),
|
||||
false,
|
||||
false,
|
||||
TerminalTail::new(),
|
||||
None,
|
||||
GlobalToolRegistry::builtin(),
|
||||
None,
|
||||
Some(provider_config),
|
||||
)
|
||||
.expect("runtime client should construct from persisted provider configuration");
|
||||
|
||||
match runtime.client {
|
||||
api::ProviderClient::OpenAi(_) => {}
|
||||
other => panic!(
|
||||
"persisted DashScope configuration must dispatch to the OpenAI-compatible client, got {other:?}"
|
||||
),
|
||||
}
|
||||
|
||||
let _ = fs::remove_dir_all(config_home);
|
||||
let _ = fs::remove_dir_all(workspace);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn build_runtime_plugin_state_discovers_mcp_tools_and_surfaces_pending_servers() {
|
||||
@@ -19809,7 +19354,6 @@ UU conflicted.rs",
|
||||
let mut executor = CliToolExecutor::new(
|
||||
None,
|
||||
false,
|
||||
TerminalTail::new(),
|
||||
state.tool_registry.clone(),
|
||||
state.mcp_state.clone(),
|
||||
);
|
||||
@@ -19908,7 +19452,6 @@ UU conflicted.rs",
|
||||
let mut executor = CliToolExecutor::new(
|
||||
None,
|
||||
false,
|
||||
TerminalTail::new(),
|
||||
state.tool_registry.clone(),
|
||||
state.mcp_state.clone(),
|
||||
);
|
||||
@@ -19969,7 +19512,6 @@ UU conflicted.rs",
|
||||
vec!["test system prompt".to_string()],
|
||||
true,
|
||||
false,
|
||||
TerminalTail::new(),
|
||||
None,
|
||||
PermissionMode::DangerFullAccess,
|
||||
None,
|
||||
|
||||
@@ -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.4"
|
||||
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