mirror of
https://github.com/instructkr/claw-code.git
synced 2026-10-14 00:00:19 +08:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a1c4b29c7 | ||
|
|
0663c80ba1 |
@@ -15,7 +15,6 @@ status-help.txt
|
||||
.port_sessions/
|
||||
# Local agent session state
|
||||
.gjc/
|
||||
.claude/
|
||||
# Nix build outputs and direnv
|
||||
.envrc.local
|
||||
result
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{"schema_version":1,"session_id":"24aa7e35-e6af-4d2d-ab41-a38b9115a031","state":"running","ready_for_input":false,"updated_at":"2026-10-12T22:10:00.508Z","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-pr-3250-lane","workdir":"/home/bellman/Data/Workspace/gc-task-cc-pr-3250-lane","branch":null,"session_file":"/home/bellman/.gajaeway/broker/agent/sessions/v2-famocqbiduwkxmc2g2nvg6jfq2ay6qtc5aihbhbdoqopsmduj4sa/2026-10-12T22-02-16-124Z_24aa7e35-e6af-4d2d-ab41-a38b9115a031.jsonl","activity":{"seq":149,"last_activity_at":"2026-10-12T22:10:01.196Z","tool":"bash","phase":"started","outcome":null,"elapsed_ms":null,"active_tool_count":1,"active_tools":[{"tool":"bash","started_at":"2026-10-12T22:10:01.196Z"}],"in_flight":[{"digest":"fa5f186357264e6b1be2a7a4a7162d0fff04f721630413a25d05b40bcd933836","tool":"bash","started_at":"2026-10-12T22:10:01.196Z"}]}}
|
||||
@@ -1 +0,0 @@
|
||||
{"pid":1,"start_time":"unknown","token":"8389ee99-c41c-47dd-9615-e76a5268967c","owner_host_id":"7af2fb0e8e37bd543e3d0bda6f205b56f30077b5a9b77e83208e94ae441315ac","released":true}
|
||||
-1
@@ -1 +0,0 @@
|
||||
{"pid":1,"start_time":"unknown","token":"6e35ded8-fda1-421e-a727-42906ef57646","owner_host_id":"7af2fb0e8e37bd543e3d0bda6f205b56f30077b5a9b77e83208e94ae441315ac","released":true}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"sessionId": "24aa7e35-e6af-4d2d-ab41-a38b9115a031",
|
||||
"pid": 177576,
|
||||
"host": "127.0.0.1",
|
||||
"port": 34539,
|
||||
"url": "ws://127.0.0.1:34539",
|
||||
"token": "8FBuSDC3564yKKk5Fpgl6TEknzktQX8W",
|
||||
"startedAt": 1791842537691,
|
||||
"updatedAt": 1791842537691,
|
||||
"stale": false
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{"effectMarker":"7b92c148-d616-4eb5-95e8-4e95084bebe9","incarnation":"linux:159085489","pid":177576}
|
||||
@@ -1 +0,0 @@
|
||||
{"effectMarker":"7b92c148-d616-4eb5-95e8-4e95084bebe9","incarnation":"linux:159085489","pid":177576}
|
||||
@@ -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
-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`.
|
||||
|
||||
|
||||
+663
-786
File diff suppressed because it is too large
Load Diff
+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()
|
||||
|
||||
@@ -180,49 +180,6 @@ impl ApiError {
|
||||
}
|
||||
}
|
||||
|
||||
/// HTTP status code for an `Api` error, if any. Used by callers (e.g. the
|
||||
/// sub-agent provider chain) to decide whether to advance to the next
|
||||
/// configured model — a 404 "model not found" on the primary should fall
|
||||
/// through to the next fallback rather than killing the whole chain.
|
||||
#[must_use]
|
||||
pub fn status_code(&self) -> Option<reqwest::StatusCode> {
|
||||
match self {
|
||||
Self::Api { status, .. } => Some(*status),
|
||||
Self::RetriesExhausted { last_error, .. } => last_error.status_code(),
|
||||
Self::MissingCredentials { .. }
|
||||
| Self::ContextWindowExceeded { .. }
|
||||
| Self::ExpiredOAuthToken
|
||||
| Self::Auth(_)
|
||||
| Self::InvalidApiKeyEnv(_)
|
||||
| Self::Http(_)
|
||||
| Self::Io(_)
|
||||
| Self::Json { .. }
|
||||
| Self::InvalidSseFrame(_)
|
||||
| Self::BackoffOverflow { .. }
|
||||
| Self::RequestBodySizeExceeded { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Response body (best-effort) for an `Api` error, if any.
|
||||
#[must_use]
|
||||
pub fn response_body(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::Api { body, .. } => Some(body.as_str()),
|
||||
Self::RetriesExhausted { last_error, .. } => last_error.response_body(),
|
||||
Self::MissingCredentials { .. }
|
||||
| Self::ContextWindowExceeded { .. }
|
||||
| Self::ExpiredOAuthToken
|
||||
| Self::Auth(_)
|
||||
| Self::InvalidApiKeyEnv(_)
|
||||
| Self::Http(_)
|
||||
| Self::Io(_)
|
||||
| Self::Json { .. }
|
||||
| Self::InvalidSseFrame(_)
|
||||
| Self::BackoffOverflow { .. }
|
||||
| Self::RequestBodySizeExceeded { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn safe_failure_class(&self) -> &'static str {
|
||||
match self {
|
||||
|
||||
@@ -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::{
|
||||
@@ -30,9 +28,8 @@ pub use providers::openai_compat::{
|
||||
};
|
||||
pub use providers::{
|
||||
detect_provider_kind, max_tokens_for_model, max_tokens_for_model_with_override,
|
||||
model_family_identity_for, model_family_identity_for_kind, model_token_limit,
|
||||
provider_diagnostics_for_model, resolve_model_alias, ModelTokenLimit, ProviderDiagnostics,
|
||||
ProviderKind,
|
||||
model_family_identity_for, model_family_identity_for_kind, provider_diagnostics_for_model,
|
||||
resolve_model_alias, ProviderDiagnostics, ProviderKind,
|
||||
};
|
||||
pub use sse::{parse_frame, SseParser};
|
||||
pub use types::{
|
||||
|
||||
@@ -683,15 +683,13 @@ pub fn model_token_limit(model: &str) -> Option<ModelTokenLimit> {
|
||||
max_output_tokens: 8_192,
|
||||
context_window_tokens: 131_072,
|
||||
}),
|
||||
// Qwen models via DashScope / OpenAI-compat
|
||||
"qwen3.6-35b-fast" | "qwen3-235b-a22b" | "qwen-max" | "qwen-plus" | "qwen-turbo"
|
||||
| "qwen-qwq" => Some(ModelTokenLimit {
|
||||
max_output_tokens: 16_384,
|
||||
"qwen-max" => Some(ModelTokenLimit {
|
||||
max_output_tokens: 8_192,
|
||||
context_window_tokens: 131_072,
|
||||
}),
|
||||
"glm-5.1-fast" => Some(ModelTokenLimit {
|
||||
max_output_tokens: 16_384,
|
||||
context_window_tokens: 200_000,
|
||||
"qwen-plus" => Some(ModelTokenLimit {
|
||||
max_output_tokens: 8_192,
|
||||
context_window_tokens: 131_072,
|
||||
}),
|
||||
// Groq compound systems are exposed through an OpenAI-compatible API
|
||||
// but cap completions at 8,192 output tokens. Without this metadata
|
||||
@@ -701,8 +699,6 @@ pub fn model_token_limit(model: &str) -> Option<ModelTokenLimit> {
|
||||
max_output_tokens: 8_192,
|
||||
context_window_tokens: 131_072,
|
||||
}),
|
||||
// Unknown models return None so preflight skips the context-window
|
||||
// check and lets the API enforce its own limits.
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -166,8 +166,8 @@ pub fn blob_to_f32_vec(blob: &[u8], dim: usize) -> Option<Vec<f32>> {
|
||||
return None;
|
||||
}
|
||||
let mut v = Vec::with_capacity(dim);
|
||||
for chunk in blob.as_chunks::<4>().0 {
|
||||
v.push(f32::from_le_bytes(*chunk));
|
||||
for chunk in blob.chunks_exact(4) {
|
||||
v.push(f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]));
|
||||
}
|
||||
Some(v)
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ async fn flush_path_batch(
|
||||
#[cfg(feature = "qdrant-index")]
|
||||
let mut qdrant_points: Vec<ChunkPoint> = Vec::with_capacity(batch.len());
|
||||
|
||||
for ((ord, t), vec) in batch.drain(..).zip(vecs) {
|
||||
for ((ord, t), vec) in batch.drain(..).zip(vecs.into_iter()) {
|
||||
let dim = vec.len();
|
||||
let cid = insert_chunk(conn, path, ord, &t)?;
|
||||
insert_embedding(conn, cid, dim, &vec)?;
|
||||
|
||||
@@ -808,7 +808,7 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
|
||||
name: "team",
|
||||
aliases: &[],
|
||||
summary: "Manage agent teams",
|
||||
argument_hint: Some("[on|off|status]"),
|
||||
argument_hint: Some("[list|create|delete]"),
|
||||
resume_supported: true,
|
||||
},
|
||||
SlashCommandSpec {
|
||||
@@ -1191,14 +1191,10 @@ pub enum SlashCommand {
|
||||
History {
|
||||
count: Option<String>,
|
||||
},
|
||||
Lsp {
|
||||
action: Option<String>,
|
||||
target: Option<String>,
|
||||
},
|
||||
Unknown(String),
|
||||
Team {
|
||||
action: Option<String>,
|
||||
},
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -1520,9 +1516,6 @@ pub fn validate_slash_command_input(
|
||||
"history" => SlashCommand::History {
|
||||
count: optional_single_arg(command, &args, "[count]")?,
|
||||
},
|
||||
"team" => SlashCommand::Team {
|
||||
action: optional_single_arg(command, &args, "[list|create|delete]")?,
|
||||
},
|
||||
other => SlashCommand::Unknown(other.to_string()),
|
||||
}))
|
||||
}
|
||||
@@ -5408,7 +5401,6 @@ pub fn handle_slash_command(
|
||||
| SlashCommand::OutputStyle { .. }
|
||||
| SlashCommand::AddDir { .. }
|
||||
| SlashCommand::History { .. }
|
||||
| SlashCommand::Lsp { .. }
|
||||
| SlashCommand::Team { .. }
|
||||
| SlashCommand::Setup
|
||||
| SlashCommand::Unknown(_) => None,
|
||||
@@ -5541,16 +5533,6 @@ mod tests {
|
||||
#[test]
|
||||
fn parses_supported_slash_commands() {
|
||||
assert_eq!(SlashCommand::parse("/help"), Ok(Some(SlashCommand::Help)));
|
||||
assert_eq!(
|
||||
SlashCommand::parse("/team"),
|
||||
Ok(Some(SlashCommand::Team { action: None }))
|
||||
);
|
||||
assert_eq!(
|
||||
SlashCommand::parse("/team on"),
|
||||
Ok(Some(SlashCommand::Team {
|
||||
action: Some("on".to_string())
|
||||
}))
|
||||
);
|
||||
assert_eq!(
|
||||
SlashCommand::parse(" /status "),
|
||||
Ok(Some(SlashCommand::Status))
|
||||
|
||||
@@ -826,7 +826,7 @@ mod tests {
|
||||
"message[{}] is a ToolResult but message[{}] has no ToolUse: {:?}",
|
||||
i,
|
||||
i - 1,
|
||||
messages[i - 1].blocks
|
||||
&messages[i - 1].blocks
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,20 +164,6 @@ pub struct RuntimeFeatureConfig {
|
||||
rules_import: RulesImportConfig,
|
||||
provider: RuntimeProviderConfig,
|
||||
auto_memory_enabled: bool,
|
||||
/// Model override used when spawning sub-agents via the Agent tool.
|
||||
/// Read from `subagentModel` (or `subagent_model`) in settings; falls
|
||||
/// back to the default model when unset.
|
||||
subagent_model: Option<String>,
|
||||
/// Cap on concurrently executing parallel-safe tool calls in one batch.
|
||||
/// Read from `maxParallelToolCalls` (or `max_parallel_tool_calls`) in
|
||||
/// settings; the CLI falls back to `CLAWD_PARALLEL_TOOL_CALLS` and then
|
||||
/// to its built-in default when unset.
|
||||
max_parallel_tool_calls: Option<usize>,
|
||||
/// Default cap on concurrently *running* sub-agents per team. Read from
|
||||
/// `maxConcurrentAgents` (or `max_concurrent_agents`) in settings; a
|
||||
/// per-team `maxConcurrentAgents` on TeamCreate overrides it. `None`
|
||||
/// means no gate (current pre-fix behavior).
|
||||
max_concurrent_agents: Option<usize>,
|
||||
}
|
||||
|
||||
/// Controls which external AI coding framework rules are imported into the system prompt.
|
||||
@@ -219,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()
|
||||
@@ -851,15 +832,6 @@ fn build_runtime_config(
|
||||
rules_import: parse_optional_rules_import(&merged_value)?,
|
||||
provider: parse_optional_provider_config(&merged_value)?,
|
||||
auto_memory_enabled: parse_auto_memory_enabled(&merged_value),
|
||||
subagent_model: parse_optional_subagent_model(&merged_value),
|
||||
max_parallel_tool_calls: parse_optional_positive_usize(
|
||||
&merged_value,
|
||||
&["maxParallelToolCalls", "max_parallel_tool_calls"],
|
||||
)?,
|
||||
max_concurrent_agents: parse_optional_positive_usize(
|
||||
&merged_value,
|
||||
&["maxConcurrentAgents", "max_concurrent_agents"],
|
||||
)?,
|
||||
};
|
||||
|
||||
Ok(RuntimeConfig {
|
||||
@@ -942,29 +914,6 @@ impl RuntimeConfig {
|
||||
self.feature_config.model.as_deref()
|
||||
}
|
||||
|
||||
/// Model override used when spawning sub-agents via the Agent tool.
|
||||
/// Read from `subagentModel` in settings; `None` means "use default model".
|
||||
#[must_use]
|
||||
pub fn subagent_model(&self) -> Option<&str> {
|
||||
self.feature_config.subagent_model.as_deref()
|
||||
}
|
||||
|
||||
/// Cap on concurrently executing parallel-safe tool calls in one batch.
|
||||
/// Read from `maxParallelToolCalls` in settings; `None` means the CLI
|
||||
/// falls back to `CLAWD_PARALLEL_TOOL_CALLS` and then its built-in default.
|
||||
#[must_use]
|
||||
pub fn max_parallel_tool_calls(&self) -> Option<usize> {
|
||||
self.feature_config.max_parallel_tool_calls
|
||||
}
|
||||
|
||||
/// Default cap on concurrently running sub-agents per team. Read from
|
||||
/// `maxConcurrentAgents` in settings; `None` means the built-in default
|
||||
/// applies. A per-team `maxConcurrentAgents` on TeamCreate overrides it.
|
||||
#[must_use]
|
||||
pub fn max_concurrent_agents(&self) -> Option<usize> {
|
||||
self.feature_config.max_concurrent_agents
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn aliases(&self) -> &BTreeMap<String, String> {
|
||||
&self.feature_config.aliases
|
||||
@@ -1025,20 +974,6 @@ impl RuntimeFeatureConfig {
|
||||
&self.provider
|
||||
}
|
||||
|
||||
/// Cap on concurrently executing parallel-safe tool calls in one batch.
|
||||
/// `None` means fall back to env/default.
|
||||
#[must_use]
|
||||
pub fn max_parallel_tool_calls(&self) -> Option<usize> {
|
||||
self.max_parallel_tool_calls
|
||||
}
|
||||
|
||||
/// Default per-team cap on concurrently running sub-agents; `None`
|
||||
/// means unlimited (pre-fix behavior).
|
||||
#[must_use]
|
||||
pub fn max_concurrent_agents(&self) -> Option<usize> {
|
||||
self.max_concurrent_agents
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_hooks(mut self, hooks: RuntimeHookConfig) -> Self {
|
||||
self.hooks = hooks;
|
||||
@@ -1821,51 +1756,6 @@ fn parse_optional_model(root: &JsonValue) -> Option<String> {
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
/// Reads `subagentModel` (or the snake_case `subagent_model` alias) from
|
||||
/// merged settings. Returns `None` when absent or blank so the Agent tool
|
||||
/// falls back to the default model.
|
||||
fn parse_optional_subagent_model(root: &JsonValue) -> Option<String> {
|
||||
root.as_object()
|
||||
.and_then(|object| {
|
||||
object
|
||||
.get("subagentModel")
|
||||
.or_else(|| object.get("subagent_model"))
|
||||
})
|
||||
.and_then(JsonValue::as_str)
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.map(|s| s.trim().to_string())
|
||||
}
|
||||
|
||||
/// Reads the first present key from `keys` as a positive integer.
|
||||
/// Absent, null, or non-positive values parse to `None` so callers fall
|
||||
/// back to their own defaults rather than silently disabling a cap via a
|
||||
/// `0` typo. Accepts JSON numbers or numeric strings.
|
||||
fn parse_optional_positive_usize(
|
||||
root: &JsonValue,
|
||||
keys: &[&str],
|
||||
) -> Result<Option<usize>, ConfigError> {
|
||||
let Some(object) = root.as_object() else {
|
||||
return Ok(None);
|
||||
};
|
||||
for key in keys {
|
||||
let Some(value) = object.get(*key) else {
|
||||
continue;
|
||||
};
|
||||
if matches!(value, JsonValue::Null) {
|
||||
continue;
|
||||
}
|
||||
let parsed = value
|
||||
.as_i64()
|
||||
.or_else(|| value.as_str().and_then(|s| s.trim().parse::<i64>().ok()))
|
||||
.ok_or_else(|| ConfigError::Parse(format!("{key} must be a positive integer")))?;
|
||||
if parsed <= 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
return Ok(usize::try_from(parsed).ok());
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn parse_optional_aliases(root: &JsonValue) -> Result<BTreeMap<String, String>, ConfigError> {
|
||||
let Some(object) = root.as_object() else {
|
||||
return Ok(BTreeMap::new());
|
||||
@@ -2737,49 +2627,6 @@ fn deep_merge_objects(
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the provider config saved by `/setup` and inject its credentials
|
||||
/// into the environment so `ProviderClient::from_model()` can find them via
|
||||
/// the env-var-based provider dispatch. Only sets vars that aren't already
|
||||
/// present (preserves explicit env). Idempotent.
|
||||
///
|
||||
/// Called by the main CLI at startup and by sub-agent threads in the tools
|
||||
/// crate so that custom/ exotic providers work for spawned agents too.
|
||||
pub fn inject_config_as_env_fallbacks() {
|
||||
let cwd = std::env::current_dir().unwrap_or_default();
|
||||
let Ok(config) = ConfigLoader::default_for(&cwd).load() else {
|
||||
return;
|
||||
};
|
||||
let provider = config.provider();
|
||||
|
||||
// Map provider kind to the expected env var names
|
||||
let (api_key_env, base_url_env) = match provider.kind().unwrap_or("anthropic") {
|
||||
"anthropic" => ("ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL"),
|
||||
"xai" => ("XAI_API_KEY", "XAI_BASE_URL"),
|
||||
"openai" => ("OPENAI_API_KEY", "OPENAI_BASE_URL"),
|
||||
"dashscope" => ("DASHSCOPE_API_KEY", "DASHSCOPE_BASE_URL"),
|
||||
"custom-openai" => ("CLAWCUSTOMOPENAI_API_KEY", "CLAWCUSTOMOPENAI_BASE_URL"),
|
||||
_ => return, // unknown provider kind — don't inject
|
||||
};
|
||||
|
||||
// Only set env vars that aren't already set (preserve user's explicit env)
|
||||
if let Some(api_key) = provider.api_key() {
|
||||
if std::env::var(api_key_env).is_err() {
|
||||
std::env::set_var(api_key_env, api_key);
|
||||
}
|
||||
}
|
||||
if let Some(base_url) = provider.base_url() {
|
||||
if std::env::var(base_url_env).is_err() {
|
||||
std::env::set_var(base_url_env, base_url);
|
||||
}
|
||||
}
|
||||
// Also inject the saved model so resolve_model_alias sees it
|
||||
if let Some(model) = provider.model() {
|
||||
if std::env::var("CLAWD_PROVIDER_MODEL").is_err() {
|
||||
std::env::set_var("CLAWD_PROVIDER_MODEL", model);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
@@ -2811,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();
|
||||
@@ -4177,151 +3988,4 @@ mod tests {
|
||||
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subagent_model_reads_camel_case_config_key() {
|
||||
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"),
|
||||
"{\n \"subagentModel\": \"custom/openclaw\"\n}\n",
|
||||
)
|
||||
.expect("write settings");
|
||||
|
||||
let config = ConfigLoader::new(&cwd, &home)
|
||||
.load()
|
||||
.expect("config should load");
|
||||
assert_eq!(
|
||||
config.subagent_model(),
|
||||
Some("custom/openclaw"),
|
||||
"subagentModel camelCase key should be read end-to-end"
|
||||
);
|
||||
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subagent_model_reads_snake_case_config_key() {
|
||||
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"),
|
||||
"{\n \"subagent_model\": \"qwen3.6-35b-fast\"\n}\n",
|
||||
)
|
||||
.expect("write settings");
|
||||
|
||||
let config = ConfigLoader::new(&cwd, &home)
|
||||
.load()
|
||||
.expect("config should load");
|
||||
assert_eq!(
|
||||
config.subagent_model(),
|
||||
Some("qwen3.6-35b-fast"),
|
||||
"subagent_model snake_case key should be read end-to-end"
|
||||
);
|
||||
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subagent_model_blank_value_returns_none() {
|
||||
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"),
|
||||
"{\n \"subagentModel\": \" \"\n}\n",
|
||||
)
|
||||
.expect("write settings");
|
||||
|
||||
let config = ConfigLoader::new(&cwd, &home)
|
||||
.load()
|
||||
.expect("config should load");
|
||||
assert_eq!(
|
||||
config.subagent_model(),
|
||||
None,
|
||||
"blank subagentModel should fall back to None"
|
||||
);
|
||||
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parallel_and_agent_caps_read_camel_and_snake_case_keys() {
|
||||
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"),
|
||||
"{\n \"maxParallelToolCalls\": 4,\n \"max_concurrent_agents\": \"6\"\n}\n",
|
||||
)
|
||||
.expect("write settings");
|
||||
|
||||
let config = ConfigLoader::new(&cwd, &home)
|
||||
.load()
|
||||
.expect("config should load");
|
||||
assert_eq!(
|
||||
config.max_parallel_tool_calls(),
|
||||
Some(4),
|
||||
"maxParallelToolCalls camelCase key should parse"
|
||||
);
|
||||
assert_eq!(
|
||||
config.max_concurrent_agents(),
|
||||
Some(6),
|
||||
"max_concurrent_agents snake_case key should parse numeric strings"
|
||||
);
|
||||
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parallel_and_agent_caps_reject_nonpositive_and_malformed() {
|
||||
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"),
|
||||
"{\n \"maxParallelToolCalls\": 0\n}\n",
|
||||
)
|
||||
.expect("write settings");
|
||||
|
||||
let config = ConfigLoader::new(&cwd, &home)
|
||||
.load()
|
||||
.expect("zero cap should parse as unset");
|
||||
assert_eq!(config.max_parallel_tool_calls(), None);
|
||||
assert_eq!(config.max_concurrent_agents(), None);
|
||||
fs::remove_dir_all(&root).expect("cleanup temp dir");
|
||||
|
||||
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"),
|
||||
"{\n \"maxParallelToolCalls\": \"lots\"\n}\n",
|
||||
)
|
||||
.expect("write settings");
|
||||
|
||||
let error = ConfigLoader::new(&cwd, &home)
|
||||
.load()
|
||||
.expect_err("non-numeric cap should be a config error");
|
||||
assert!(
|
||||
error.to_string().contains("maxParallelToolCalls"),
|
||||
"error should name the offending key: {error}"
|
||||
);
|
||||
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,40 +61,6 @@ pub trait ApiClient {
|
||||
/// Trait implemented by tool dispatchers that execute model-requested tools.
|
||||
pub trait ToolExecutor {
|
||||
fn execute(&mut self, tool_name: &str, input: &str) -> Result<String, ToolError>;
|
||||
|
||||
/// Execute a batch of tool calls, potentially in parallel.
|
||||
/// Returns results in the same order as the input calls.
|
||||
/// The default implementation executes sequentially via `execute`.
|
||||
/// Override this to provide parallel execution for read-only tools.
|
||||
fn execute_batch(&mut self, calls: Vec<ToolCall>) -> Vec<ToolResult> {
|
||||
calls
|
||||
.into_iter()
|
||||
.map(|call| {
|
||||
let result = self.execute(&call.tool_name, &call.input);
|
||||
ToolResult {
|
||||
tool_use_id: call.tool_use_id,
|
||||
tool_name: call.tool_name,
|
||||
result,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// A single tool call to execute.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ToolCall {
|
||||
pub tool_use_id: String,
|
||||
pub tool_name: String,
|
||||
pub input: String,
|
||||
}
|
||||
|
||||
/// The result of executing a tool call.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ToolResult {
|
||||
pub tool_use_id: String,
|
||||
pub tool_name: String,
|
||||
pub result: Result<String, ToolError>,
|
||||
}
|
||||
|
||||
/// Error returned when a tool invocation fails locally.
|
||||
@@ -120,22 +86,6 @@ impl Display for ToolError {
|
||||
|
||||
impl std::error::Error for ToolError {}
|
||||
|
||||
/// Callback trait for reporting tool execution progress during a turn.
|
||||
/// Implementations can post progress to a team inbox, log to stderr, etc.
|
||||
/// Called after each tool call completes (success or failure).
|
||||
pub trait TurnProgressReporter: Send + Sync {
|
||||
/// Called after a tool execution completes.
|
||||
/// `iteration` is 1-based index of the tool call within this turn.
|
||||
fn on_tool_result(
|
||||
&self,
|
||||
iteration: usize,
|
||||
max_iterations: usize,
|
||||
tool_name: &str,
|
||||
input: &str,
|
||||
result: Result<&str, &str>,
|
||||
);
|
||||
}
|
||||
|
||||
/// Error returned when a conversation turn cannot be completed.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RuntimeError {
|
||||
@@ -190,7 +140,6 @@ pub struct ConversationRuntime<C, T> {
|
||||
hook_abort_signal: HookAbortSignal,
|
||||
hook_progress_reporter: Option<Box<dyn HookProgressReporter>>,
|
||||
session_tracer: Option<SessionTracer>,
|
||||
turn_progress_reporter: Option<Box<dyn TurnProgressReporter>>,
|
||||
}
|
||||
|
||||
impl<C, T> ConversationRuntime<C, T>
|
||||
@@ -245,7 +194,6 @@ where
|
||||
hook_abort_signal: HookAbortSignal::default(),
|
||||
hook_progress_reporter: None,
|
||||
session_tracer: None,
|
||||
turn_progress_reporter: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,11 +237,6 @@ where
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_turn_progress_reporter(mut self, reporter: Box<dyn TurnProgressReporter>) -> Self {
|
||||
self.turn_progress_reporter = Some(reporter);
|
||||
self
|
||||
}
|
||||
|
||||
fn run_pre_tool_use_hook(&mut self, tool_name: &str, input: &str) -> HookRunResult {
|
||||
if let Some(reporter) = self.hook_progress_reporter.as_mut() {
|
||||
self.hook_runner.run_pre_tool_use_with_context(
|
||||
@@ -477,24 +420,11 @@ where
|
||||
break;
|
||||
}
|
||||
|
||||
// Phase 1: Pre-hooks and permission checks (sequential).
|
||||
// Hooks may mutate state and must run in order.
|
||||
struct PendingTool {
|
||||
tool_use_id: String,
|
||||
tool_name: String,
|
||||
effective_input: String,
|
||||
pre_hook_messages: Vec<String>,
|
||||
allowed: bool,
|
||||
deny_reason: Option<String>,
|
||||
}
|
||||
|
||||
let mut pending = Vec::with_capacity(pending_tool_uses.len());
|
||||
for (tool_use_id, tool_name, input) in &pending_tool_uses {
|
||||
let pre_hook_result = self.run_pre_tool_use_hook(tool_name, input);
|
||||
for (tool_use_id, tool_name, input) in pending_tool_uses {
|
||||
let pre_hook_result = self.run_pre_tool_use_hook(&tool_name, &input);
|
||||
let effective_input = pre_hook_result
|
||||
.updated_input()
|
||||
.map_or_else(|| input.clone(), ToOwned::to_owned);
|
||||
let pre_hook_messages = pre_hook_result.messages().to_vec();
|
||||
let permission_context = PermissionContext::new(
|
||||
pre_hook_result.permission_override(),
|
||||
pre_hook_result.permission_reason().map(ToOwned::to_owned),
|
||||
@@ -523,157 +453,71 @@ where
|
||||
}
|
||||
} else if let Some(prompt) = prompter.as_mut() {
|
||||
self.permission_policy.authorize_with_context(
|
||||
tool_name,
|
||||
&tool_name,
|
||||
&effective_input,
|
||||
&permission_context,
|
||||
Some(*prompt),
|
||||
)
|
||||
} else {
|
||||
self.permission_policy.authorize_with_context(
|
||||
tool_name,
|
||||
&tool_name,
|
||||
&effective_input,
|
||||
&permission_context,
|
||||
None,
|
||||
)
|
||||
};
|
||||
|
||||
match permission_outcome {
|
||||
let result_message = match permission_outcome {
|
||||
PermissionOutcome::Allow => {
|
||||
pending.push(PendingTool {
|
||||
tool_use_id: tool_use_id.clone(),
|
||||
tool_name: tool_name.clone(),
|
||||
effective_input,
|
||||
pre_hook_messages,
|
||||
allowed: true,
|
||||
deny_reason: None,
|
||||
});
|
||||
}
|
||||
PermissionOutcome::Deny { reason } => {
|
||||
pending.push(PendingTool {
|
||||
tool_use_id: tool_use_id.clone(),
|
||||
tool_name: tool_name.clone(),
|
||||
effective_input: String::new(),
|
||||
pre_hook_messages,
|
||||
allowed: false,
|
||||
deny_reason: Some(reason),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
self.record_tool_started(iterations, &tool_name);
|
||||
let (mut output, mut is_error) =
|
||||
match self.tool_executor.execute(&tool_name, &effective_input) {
|
||||
Ok(output) => (output, false),
|
||||
Err(error) => (error.to_string(), true),
|
||||
};
|
||||
output = merge_hook_feedback(pre_hook_result.messages(), output, false);
|
||||
|
||||
// Phase 2: Execute allowed tools (batch, may run in parallel).
|
||||
let allowed_calls: Vec<ToolCall> = pending
|
||||
.iter()
|
||||
.filter(|p| p.allowed)
|
||||
.map(|p| {
|
||||
self.record_tool_started(iterations, &p.tool_name);
|
||||
ToolCall {
|
||||
tool_use_id: p.tool_use_id.clone(),
|
||||
tool_name: p.tool_name.clone(),
|
||||
input: p.effective_input.clone(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let batch_results = self.tool_executor.execute_batch(allowed_calls);
|
||||
let mut batch_index = 0;
|
||||
|
||||
// Phase 3: Post-hooks and session updates (sequential, original order).
|
||||
for p in &pending {
|
||||
// Capture progress data for the reporter.
|
||||
let (
|
||||
progress_tool_name,
|
||||
progress_input,
|
||||
progress_output,
|
||||
progress_is_error,
|
||||
result_message,
|
||||
) = if p.allowed {
|
||||
let batch_result = &batch_results[batch_index];
|
||||
batch_index += 1;
|
||||
let (mut output, mut is_error) = match &batch_result.result {
|
||||
Ok(output) => (output.clone(), false),
|
||||
Err(error) => (error.to_string(), true),
|
||||
};
|
||||
output = merge_hook_feedback(&p.pre_hook_messages, output, false);
|
||||
|
||||
let post_hook_result = if is_error {
|
||||
self.run_post_tool_use_failure_hook(
|
||||
&p.tool_name,
|
||||
&p.effective_input,
|
||||
&output,
|
||||
)
|
||||
} else {
|
||||
self.run_post_tool_use_hook(
|
||||
&p.tool_name,
|
||||
&p.effective_input,
|
||||
&output,
|
||||
false,
|
||||
)
|
||||
};
|
||||
if post_hook_result.is_denied()
|
||||
|| post_hook_result.is_failed()
|
||||
|| post_hook_result.is_cancelled()
|
||||
{
|
||||
is_error = true;
|
||||
}
|
||||
output = merge_hook_feedback(
|
||||
post_hook_result.messages(),
|
||||
output,
|
||||
post_hook_result.is_denied()
|
||||
let post_hook_result = if is_error {
|
||||
self.run_post_tool_use_failure_hook(
|
||||
&tool_name,
|
||||
&effective_input,
|
||||
&output,
|
||||
)
|
||||
} else {
|
||||
self.run_post_tool_use_hook(
|
||||
&tool_name,
|
||||
&effective_input,
|
||||
&output,
|
||||
false,
|
||||
)
|
||||
};
|
||||
if post_hook_result.is_denied()
|
||||
|| post_hook_result.is_failed()
|
||||
|| post_hook_result.is_cancelled(),
|
||||
);
|
||||
let progress_output = output.clone();
|
||||
let result_message = ConversationMessage::tool_result(
|
||||
p.tool_use_id.clone(),
|
||||
p.tool_name.clone(),
|
||||
output,
|
||||
is_error,
|
||||
);
|
||||
(
|
||||
p.tool_name.clone(),
|
||||
p.effective_input.clone(),
|
||||
progress_output,
|
||||
is_error,
|
||||
result_message,
|
||||
)
|
||||
} else {
|
||||
let denied_output = merge_hook_feedback(
|
||||
&p.pre_hook_messages,
|
||||
p.deny_reason.clone().unwrap_or_default(),
|
||||
|| post_hook_result.is_cancelled()
|
||||
{
|
||||
is_error = true;
|
||||
}
|
||||
output = merge_hook_feedback(
|
||||
post_hook_result.messages(),
|
||||
output,
|
||||
post_hook_result.is_denied()
|
||||
|| post_hook_result.is_failed()
|
||||
|| post_hook_result.is_cancelled(),
|
||||
);
|
||||
|
||||
ConversationMessage::tool_result(tool_use_id, tool_name, output, is_error)
|
||||
}
|
||||
PermissionOutcome::Deny { reason } => ConversationMessage::tool_result(
|
||||
tool_use_id,
|
||||
tool_name,
|
||||
merge_hook_feedback(pre_hook_result.messages(), reason, true),
|
||||
true,
|
||||
);
|
||||
let result_message = ConversationMessage::tool_result(
|
||||
p.tool_use_id.clone(),
|
||||
p.tool_name.clone(),
|
||||
denied_output,
|
||||
true,
|
||||
);
|
||||
(
|
||||
p.tool_name.clone(),
|
||||
String::new(),
|
||||
String::new(),
|
||||
true,
|
||||
result_message,
|
||||
)
|
||||
),
|
||||
};
|
||||
self.session
|
||||
.push_message(result_message.clone())
|
||||
.map_err(|error| RuntimeError::new(error.to_string()))?;
|
||||
self.record_tool_finished(iterations, &result_message);
|
||||
if let Some(ref reporter) = self.turn_progress_reporter {
|
||||
let report_result = if progress_is_error {
|
||||
Err(progress_output.as_str())
|
||||
} else {
|
||||
Ok(progress_output.as_str())
|
||||
};
|
||||
reporter.on_tool_result(
|
||||
iterations,
|
||||
self.max_iterations,
|
||||
&progress_tool_name,
|
||||
&progress_input,
|
||||
report_result,
|
||||
);
|
||||
}
|
||||
tool_results.push(result_message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -67,16 +66,15 @@ pub use compact::{
|
||||
get_compact_continuation_message, should_compact, CompactionConfig, CompactionResult,
|
||||
};
|
||||
pub use config::{
|
||||
clear_user_provider_settings, default_config_home, inject_config_as_env_fallbacks,
|
||||
save_user_provider_settings, suppress_config_warnings_for_json_mode, ApiTimeoutConfig,
|
||||
ConfigEntry, ConfigError, ConfigFileReport, ConfigFileStatus, ConfigInspection, ConfigLoader,
|
||||
ConfigSource, McpConfigCollection, McpInvalidServerConfig, McpManagedProxyServerConfig,
|
||||
McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig, McpServerConfig,
|
||||
McpStdioServerConfig, McpTransport, McpWebSocketServerConfig, OAuthConfig,
|
||||
ProviderFallbackConfig, ResolvedPermissionMode, RulesImportConfig, RuntimeConfig,
|
||||
RuntimeFeatureConfig, RuntimeHookCommand, RuntimeHookConfig, RuntimeInvalidHookConfig,
|
||||
RuntimePermissionRuleConfig, RuntimePluginConfig, RuntimeProviderConfig, ScopedMcpServerConfig,
|
||||
CLAW_SETTINGS_SCHEMA_NAME,
|
||||
clear_user_provider_settings, default_config_home, save_user_provider_settings,
|
||||
suppress_config_warnings_for_json_mode, ApiTimeoutConfig, ConfigEntry, ConfigError,
|
||||
ConfigFileReport, ConfigFileStatus, ConfigInspection, ConfigLoader, ConfigSource,
|
||||
McpConfigCollection, McpInvalidServerConfig, McpManagedProxyServerConfig, McpOAuthConfig,
|
||||
McpRemoteServerConfig, McpSdkServerConfig, McpServerConfig, McpStdioServerConfig, McpTransport,
|
||||
McpWebSocketServerConfig, OAuthConfig, ProviderFallbackConfig, ResolvedPermissionMode,
|
||||
RulesImportConfig, RuntimeConfig, RuntimeFeatureConfig, RuntimeHookCommand, RuntimeHookConfig,
|
||||
RuntimeInvalidHookConfig, RuntimePermissionRuleConfig, RuntimePluginConfig,
|
||||
RuntimeProviderConfig, ScopedMcpServerConfig, CLAW_SETTINGS_SCHEMA_NAME,
|
||||
};
|
||||
pub use config_validate::{
|
||||
check_unsupported_format, format_diagnostics, validate_config_file, ConfigDiagnostic,
|
||||
@@ -84,13 +82,12 @@ pub use config_validate::{
|
||||
};
|
||||
pub use conversation::{
|
||||
auto_compaction_threshold_from_env, ApiClient, ApiRequest, AssistantEvent, AutoCompactionEvent,
|
||||
ConversationRuntime, PromptCacheEvent, RuntimeError, StaticToolExecutor, ToolCall, ToolError,
|
||||
ToolExecutor, ToolResult, TurnProgressReporter, TurnSummary,
|
||||
ConversationRuntime, PromptCacheEvent, RuntimeError, StaticToolExecutor, ToolError,
|
||||
ToolExecutor, TurnSummary,
|
||||
};
|
||||
pub use file_ops::{
|
||||
edit_file, edit_file_in_workspace, glob_search, glob_search_in_workspace, grep_search,
|
||||
grep_search_in_workspace, read_file, read_file_in_workspace,
|
||||
read_file_in_workspace_with_selector, read_file_with_selector, write_file,
|
||||
grep_search_in_workspace, read_file, read_file_in_workspace, write_file,
|
||||
write_file_in_workspace, EditFileOutput, GlobSearchOutput, GrepSearchInput, GrepSearchOutput,
|
||||
ReadFileOutput, StructuredPatchHunk, TextFilePayload, WriteFileOutput,
|
||||
};
|
||||
|
||||
@@ -1,347 +0,0 @@
|
||||
/// GJC-compatible line range selector parsing for the read tool.
|
||||
///
|
||||
/// Supports selector syntax:
|
||||
/// - `:50` = start at line 50
|
||||
/// - `:50-200` = lines 50-200 inclusive
|
||||
/// - `:50+150` = 150 lines starting at 50
|
||||
/// - `:50+1` = exactly one line
|
||||
/// - `:5-16,960-973` = multiple ranges
|
||||
/// - `:raw` = full verbatim
|
||||
/// - `:conflicts` = git merge conflicts only
|
||||
use std::num::ParseIntError;
|
||||
|
||||
/// Parsed line range specification.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum LineRangeSelector {
|
||||
/// Full file verbatim (`:raw`)
|
||||
Raw,
|
||||
/// Git merge conflict sections only (`:conflicts`)
|
||||
Conflicts,
|
||||
/// Line range(s) (`:L-L+N` syntax)
|
||||
Lines(Vec<LineRange>),
|
||||
/// No selector (full file, may be truncated by default budget)
|
||||
None,
|
||||
}
|
||||
|
||||
/// A single line range, zero-indexed internally but displayed as 1-indexed.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct LineRange {
|
||||
/// Start line (1-indexed, user-facing)
|
||||
pub start_line: usize,
|
||||
/// End line inclusive (1-indexed, user-facing)
|
||||
pub end_line: usize,
|
||||
}
|
||||
|
||||
impl LineRange {
|
||||
/// Create a new line range (1-indexed).
|
||||
pub fn new(start_line: usize, end_line: usize) -> Self {
|
||||
Self {
|
||||
start_line,
|
||||
end_line,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert to zero-indexed range for internal use.
|
||||
pub fn zero_indexed(&self) -> (usize, usize) {
|
||||
(
|
||||
self.start_line.saturating_sub(1),
|
||||
self.end_line.saturating_sub(1),
|
||||
)
|
||||
}
|
||||
|
||||
/// Get the number of lines in this range.
|
||||
pub fn len(&self) -> usize {
|
||||
if self.start_line <= self.end_line {
|
||||
self.end_line - self.start_line + 1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this range is valid.
|
||||
pub fn is_valid(&self) -> bool {
|
||||
self.start_line > 0 && self.end_line > 0 && self.start_line <= self.end_line
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse error types for line range selectors.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ParseError {
|
||||
InvalidFormat(String),
|
||||
InvalidNumber(ParseIntError),
|
||||
InvalidRange(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ParseError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
ParseError::InvalidFormat(msg) => write!(f, "Invalid format: {}", msg),
|
||||
ParseError::InvalidNumber(e) => write!(f, "Invalid number: {}", e),
|
||||
ParseError::InvalidRange(msg) => write!(f, "Invalid range: {}", msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ParseError {}
|
||||
|
||||
impl From<ParseIntError> for ParseError {
|
||||
fn from(e: ParseIntError) -> Self {
|
||||
ParseError::InvalidNumber(e)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a line range selector string.
|
||||
///
|
||||
/// Examples:
|
||||
/// - `"50"` → `LineRangeSelector::Lines(vec![LineRange::new(50, 50)])`
|
||||
/// - `"50-200"` → `LineRangeSelector::Lines(vec![LineRange::new(50, 200)])`
|
||||
/// - `"50+150"` → `LineRangeSelector::Lines(vec![LineRange::new(50, 199)])`
|
||||
/// - `"raw"` → `LineRangeSelector::Raw`
|
||||
/// - `"conflicts"` → `LineRangeSelector::Conflicts`
|
||||
pub fn parse_selector(selector: &str) -> Result<LineRangeSelector, ParseError> {
|
||||
if selector.is_empty() {
|
||||
return Ok(LineRangeSelector::None);
|
||||
}
|
||||
|
||||
match selector {
|
||||
"raw" => Ok(LineRangeSelector::Raw),
|
||||
"conflicts" => Ok(LineRangeSelector::Conflicts),
|
||||
_ => parse_line_ranges(selector),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse comma-separated line ranges.
|
||||
fn parse_line_ranges(selector: &str) -> Result<LineRangeSelector, ParseError> {
|
||||
let mut ranges = Vec::new();
|
||||
|
||||
for range_str in selector.split(',') {
|
||||
let range_str = range_str.trim();
|
||||
if range_str.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let range = parse_single_range(range_str)?;
|
||||
ranges.push(range);
|
||||
}
|
||||
|
||||
if ranges.is_empty() {
|
||||
Ok(LineRangeSelector::None)
|
||||
} else {
|
||||
// Merge overlapping/adjacent ranges
|
||||
ranges.sort_by_key(|r| r.start_line);
|
||||
let merged = merge_ranges(ranges);
|
||||
Ok(LineRangeSelector::Lines(merged))
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a single line range in formats: "50", "50-200", "50+150".
|
||||
fn parse_single_range(range_str: &str) -> Result<LineRange, ParseError> {
|
||||
// Try "50+150" format first (start+count)
|
||||
if let Some(plus_idx) = range_str.find('+') {
|
||||
let start_str = &range_str[..plus_idx];
|
||||
let count_str = &range_str[plus_idx + 1..];
|
||||
|
||||
if start_str.is_empty() || count_str.is_empty() {
|
||||
return Err(ParseError::InvalidFormat(range_str.to_string()));
|
||||
}
|
||||
|
||||
let start = start_str.parse::<usize>()?;
|
||||
let count = count_str.parse::<usize>()?;
|
||||
|
||||
if start == 0 || count == 0 {
|
||||
return Err(ParseError::InvalidRange(
|
||||
"Line numbers and counts must be >= 1".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
return Ok(LineRange::new(start, start + count - 1));
|
||||
}
|
||||
|
||||
// Try "50-200" format (start-end)
|
||||
if let Some(dash_idx) = range_str.find('-') {
|
||||
// Skip leading dash (negative numbers aren't valid here)
|
||||
if dash_idx == 0 {
|
||||
return Err(ParseError::InvalidFormat(range_str.to_string()));
|
||||
}
|
||||
|
||||
let start_str = &range_str[..dash_idx];
|
||||
let end_str = &range_str[dash_idx + 1..];
|
||||
|
||||
if start_str.is_empty() || end_str.is_empty() {
|
||||
return Err(ParseError::InvalidFormat(range_str.to_string()));
|
||||
}
|
||||
|
||||
let start = start_str.parse::<usize>()?;
|
||||
let end = end_str.parse::<usize>()?;
|
||||
|
||||
if start == 0 || end == 0 {
|
||||
return Err(ParseError::InvalidRange(
|
||||
"Line numbers must be >= 1".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if start > end {
|
||||
return Err(ParseError::InvalidRange(format!(
|
||||
"Start line {} cannot be greater than end line {}",
|
||||
start, end
|
||||
)));
|
||||
}
|
||||
|
||||
return Ok(LineRange::new(start, end));
|
||||
}
|
||||
|
||||
// Try plain "50" format (single line)
|
||||
let line_num = range_str.parse::<usize>()?;
|
||||
if line_num == 0 {
|
||||
return Err(ParseError::InvalidRange(
|
||||
"Line number must be >= 1".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(LineRange::new(line_num, line_num))
|
||||
}
|
||||
|
||||
/// Merge overlapping and adjacent ranges.
|
||||
fn merge_ranges(ranges: Vec<LineRange>) -> Vec<LineRange> {
|
||||
let mut merged: Vec<LineRange> = Vec::new();
|
||||
|
||||
for range in ranges {
|
||||
if let Some(last) = merged.last_mut() {
|
||||
// If ranges overlap or are adjacent, merge them
|
||||
if range.start_line <= last.end_line + 1 {
|
||||
last.end_line = last.end_line.max(range.end_line);
|
||||
} else {
|
||||
merged.push(range);
|
||||
}
|
||||
} else {
|
||||
merged.push(range);
|
||||
}
|
||||
}
|
||||
|
||||
merged
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_raw() {
|
||||
assert_eq!(parse_selector("raw").unwrap(), LineRangeSelector::Raw);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_conflicts() {
|
||||
assert_eq!(
|
||||
parse_selector("conflicts").unwrap(),
|
||||
LineRangeSelector::Conflicts
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_single_line() {
|
||||
match parse_selector("50").unwrap() {
|
||||
LineRangeSelector::Lines(ranges) => {
|
||||
assert_eq!(ranges.len(), 1);
|
||||
assert_eq!(ranges[0].start_line, 50);
|
||||
assert_eq!(ranges[0].end_line, 50);
|
||||
}
|
||||
_ => panic!("Expected Lines variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_range_start_dash_end() {
|
||||
match parse_selector("50-200").unwrap() {
|
||||
LineRangeSelector::Lines(ranges) => {
|
||||
assert_eq!(ranges.len(), 1);
|
||||
assert_eq!(ranges[0].start_line, 50);
|
||||
assert_eq!(ranges[0].end_line, 200);
|
||||
}
|
||||
_ => panic!("Expected Lines variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_range_start_plus_count() {
|
||||
match parse_selector("50+150").unwrap() {
|
||||
LineRangeSelector::Lines(ranges) => {
|
||||
assert_eq!(ranges.len(), 1);
|
||||
assert_eq!(ranges[0].start_line, 50);
|
||||
assert_eq!(ranges[0].end_line, 199);
|
||||
}
|
||||
_ => panic!("Expected Lines variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_multiple_ranges() {
|
||||
match parse_selector("5-16,960-973").unwrap() {
|
||||
LineRangeSelector::Lines(ranges) => {
|
||||
assert_eq!(ranges.len(), 2);
|
||||
assert_eq!(ranges[0].start_line, 5);
|
||||
assert_eq!(ranges[0].end_line, 16);
|
||||
assert_eq!(ranges[1].start_line, 960);
|
||||
assert_eq!(ranges[1].end_line, 973);
|
||||
}
|
||||
_ => panic!("Expected Lines variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merge_overlapping_ranges() {
|
||||
// "5-16,10-20" should merge into "5-20"
|
||||
match parse_selector("5-16,10-20").unwrap() {
|
||||
LineRangeSelector::Lines(ranges) => {
|
||||
assert_eq!(ranges.len(), 1);
|
||||
assert_eq!(ranges[0].start_line, 5);
|
||||
assert_eq!(ranges[0].end_line, 20);
|
||||
}
|
||||
_ => panic!("Expected Lines variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merge_adjacent_ranges() {
|
||||
// "5-16,17-20" should merge into "5-20"
|
||||
match parse_selector("5-16,17-20").unwrap() {
|
||||
LineRangeSelector::Lines(ranges) => {
|
||||
assert_eq!(ranges.len(), 1);
|
||||
assert_eq!(ranges[0].start_line, 5);
|
||||
assert_eq!(ranges[0].end_line, 20);
|
||||
}
|
||||
_ => panic!("Expected Lines variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_range_start_greater_than_end() {
|
||||
let result = parse_selector("200-50");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_zero_line() {
|
||||
let result = parse_selector("0");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_selector() {
|
||||
assert_eq!(parse_selector("").unwrap(), LineRangeSelector::None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_line_range_zero_indexed() {
|
||||
let range = LineRange::new(50, 200);
|
||||
let (start, end) = range.zero_indexed();
|
||||
assert_eq!(start, 49);
|
||||
assert_eq!(end, 199);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_line_range_len() {
|
||||
assert_eq!(LineRange::new(50, 200).len(), 151);
|
||||
assert_eq!(LineRange::new(50, 50).len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ impl Default for TridentConfig {
|
||||
}
|
||||
|
||||
/// Statistics from a Trident compaction run.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TridentStats {
|
||||
pub superseded_count: usize,
|
||||
pub collapsed_chains: usize,
|
||||
@@ -41,6 +41,21 @@ pub struct TridentStats {
|
||||
pub final_message_count: usize,
|
||||
}
|
||||
|
||||
impl Default for TridentStats {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
superseded_count: 0,
|
||||
collapsed_chains: 0,
|
||||
messages_collapsed: 0,
|
||||
clusters_found: 0,
|
||||
messages_clustered: 0,
|
||||
tokens_saved_estimate: 0,
|
||||
original_message_count: 0,
|
||||
final_message_count: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TridentStats {
|
||||
pub fn format_report(&self) -> String {
|
||||
let compression = if self.final_message_count > 0 {
|
||||
@@ -177,7 +192,7 @@ fn stage1_supersede(messages: &[ConversationMessage]) -> (Vec<ConversationMessag
|
||||
|
||||
let mut obsolete_indices: BTreeSet<usize> = BTreeSet::new();
|
||||
|
||||
for ops in file_ops.values() {
|
||||
for (_path, ops) in &file_ops {
|
||||
if ops.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
@@ -190,9 +205,9 @@ fn stage1_supersede(messages: &[ConversationMessage]) -> (Vec<ConversationMessag
|
||||
|
||||
if let Some(last_write) = last_write_idx {
|
||||
for op in ops {
|
||||
if (op.op_type == FileOp::Read
|
||||
|| op.op_type == FileOp::Write
|
||||
|| op.op_type == FileOp::Edit)
|
||||
if op.op_type == FileOp::Read && op.index < last_write {
|
||||
obsolete_indices.insert(op.index);
|
||||
} else if (op.op_type == FileOp::Write || op.op_type == FileOp::Edit)
|
||||
&& op.index < last_write
|
||||
{
|
||||
obsolete_indices.insert(op.index);
|
||||
@@ -310,7 +325,7 @@ fn stage2_collapse(
|
||||
usage: None,
|
||||
});
|
||||
} else {
|
||||
result.append(&mut buffer);
|
||||
result.extend(buffer.drain(..));
|
||||
}
|
||||
buffer.clear();
|
||||
result.push(msg.clone());
|
||||
@@ -461,7 +476,7 @@ fn stage3_cluster(
|
||||
}
|
||||
|
||||
let total_clustered: usize = cluster_assignments.len();
|
||||
let clusters_found = cluster_id;
|
||||
let clusters_found = cluster_id as usize;
|
||||
|
||||
let mut result: Vec<ConversationMessage> = Vec::new();
|
||||
let mut cluster_buffers: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
|
||||
@@ -662,7 +677,7 @@ fn truncate_text(text: &str, max_chars: usize) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::compact::CompactionConfig;
|
||||
use crate::session::{ContentBlock, ConversationMessage, Session};
|
||||
use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session};
|
||||
|
||||
#[test]
|
||||
fn stage1_removes_obsolete_file_reads() {
|
||||
@@ -721,7 +736,7 @@ mod tests {
|
||||
fn stage2_collapses_chatty_messages() {
|
||||
let mut messages = vec![];
|
||||
for i in 0..6 {
|
||||
messages.push(ConversationMessage::user_text(format!("ok {i}")));
|
||||
messages.push(ConversationMessage::user_text(&format!("ok {i}")));
|
||||
messages.push(ConversationMessage::assistant(vec![ContentBlock::Text {
|
||||
text: format!("got {i}"),
|
||||
}]));
|
||||
@@ -752,9 +767,9 @@ mod tests {
|
||||
},
|
||||
]));
|
||||
messages.push(ConversationMessage::tool_result(
|
||||
format!("read_{i}"),
|
||||
&format!("read_{i}"),
|
||||
"read_file",
|
||||
format!(r#"{{"path":"src/{i}.rs","content":"data {i}"}}"#),
|
||||
&format!(r#"{{"path":"src/{i}.rs","content":"data {i}"}}"#),
|
||||
false,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ api = { path = "../api" }
|
||||
commands = { path = "../commands" }
|
||||
crossterm = "0.28"
|
||||
pulldown-cmark = "0.13"
|
||||
once_cell = "1"
|
||||
rustyline = "15"
|
||||
runtime = { path = "../runtime" }
|
||||
plugins = { path = "../plugins" }
|
||||
@@ -26,15 +25,8 @@ tools = { path = "../tools" }
|
||||
log = "0.4"
|
||||
|
||||
|
||||
[lints.rust]
|
||||
unsafe_code = "allow"
|
||||
|
||||
[lints.clippy]
|
||||
all = { level = "warn", priority = -1 }
|
||||
pedantic = { level = "allow", priority = -1 }
|
||||
module_name_repetitions = "allow"
|
||||
missing_panics_doc = "allow"
|
||||
missing_errors_doc = "allow"
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
mock-anthropic-service = { path = "../mock-anthropic-service" }
|
||||
|
||||
@@ -18,8 +18,6 @@ pub enum ReadOutcome {
|
||||
Submit(String),
|
||||
Cancel,
|
||||
Exit,
|
||||
ProviderSwap,
|
||||
TeamToggle,
|
||||
}
|
||||
|
||||
struct SlashCommandHelper {
|
||||
@@ -88,19 +86,12 @@ impl Hinter for SlashCommandHelper {
|
||||
impl Highlighter for SlashCommandHelper {
|
||||
fn highlight<'l>(&self, line: &'l str, _pos: usize) -> Cow<'l, str> {
|
||||
self.set_current_line(line);
|
||||
// When sentinel is present, show visible prompt instead of invisible char
|
||||
if line.contains('\x01') {
|
||||
let display = line.replace('\x01', "\x1b[36m[Provider Swap]\x1b[0m ");
|
||||
Cow::Owned(display)
|
||||
} else {
|
||||
Cow::Borrowed(line)
|
||||
}
|
||||
Cow::Borrowed(line)
|
||||
}
|
||||
|
||||
fn highlight_char(&self, line: &str, _pos: usize, _kind: CmdKind) -> bool {
|
||||
self.set_current_line(line);
|
||||
// Re-highlight when sentinel is present to show the prompt
|
||||
line.contains('\x01')
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,18 +115,6 @@ impl LineEditor {
|
||||
editor.set_helper(Some(SlashCommandHelper::new(completions)));
|
||||
editor.bind_sequence(KeyEvent(KeyCode::Char('J'), Modifiers::CTRL), Cmd::Newline);
|
||||
editor.bind_sequence(KeyEvent(KeyCode::Enter, Modifiers::SHIFT), Cmd::Newline);
|
||||
// Ctrl+P inserts a sentinel character that triggers provider swap.
|
||||
// The sentinel is invisible but the highlighter shows "[Provider Swap]" prompt.
|
||||
// User must press Enter to confirm (rustyline cannot chain commands).
|
||||
editor.bind_sequence(
|
||||
KeyEvent(KeyCode::Char('P'), Modifiers::CTRL),
|
||||
Cmd::SelfInsert(1, '\x01'),
|
||||
);
|
||||
// Ctrl+T inserts a sentinel character that toggles agent teams mode.
|
||||
editor.bind_sequence(
|
||||
KeyEvent(KeyCode::Char('T'), Modifiers::CTRL),
|
||||
Cmd::SelfInsert(1, '\x02'),
|
||||
);
|
||||
|
||||
Self {
|
||||
prompt: prompt.into(),
|
||||
@@ -168,18 +147,7 @@ impl LineEditor {
|
||||
}
|
||||
|
||||
match self.editor.readline(&self.prompt) {
|
||||
Ok(line) => {
|
||||
// Ctrl+P inserts \x01 sentinel — triggers provider swap wizard.
|
||||
// The sentinel is stripped and we return ProviderSwap to the REPL loop.
|
||||
if line.contains('\x01') {
|
||||
return Ok(ReadOutcome::ProviderSwap);
|
||||
}
|
||||
// Ctrl+T inserts \x02 sentinel — toggles team mode.
|
||||
if line.contains('\x02') {
|
||||
return Ok(ReadOutcome::TeamToggle);
|
||||
}
|
||||
Ok(ReadOutcome::Submit(line))
|
||||
}
|
||||
Ok(line) => Ok(ReadOutcome::Submit(line)),
|
||||
Err(ReadlineError::Interrupted) => {
|
||||
let has_input = !self.current_line().is_empty();
|
||||
self.finish_interrupted_read()?;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -249,6 +249,16 @@ impl TerminalRenderer {
|
||||
|
||||
#[must_use]
|
||||
pub fn render_markdown(&self, markdown: &str) -> String {
|
||||
self.render_markdown_raw(markdown).trim_end().to_string()
|
||||
}
|
||||
|
||||
/// Render markdown to ANSI without trimming trailing whitespace.
|
||||
///
|
||||
/// Block-level events (paragraphs, headings, lists, code blocks) emit their
|
||||
/// trailing newline separators here. `render_markdown` trims them for
|
||||
/// one-shot output, while streaming keeps them so that successive chunks
|
||||
/// stay on their own lines instead of running together.
|
||||
fn render_markdown_raw(&self, markdown: &str) -> String {
|
||||
let normalized = normalize_nested_fences(markdown);
|
||||
let mut output = String::new();
|
||||
let mut state = RenderState::default();
|
||||
@@ -267,30 +277,7 @@ impl TerminalRenderer {
|
||||
);
|
||||
}
|
||||
|
||||
output.trim_end().to_string()
|
||||
}
|
||||
|
||||
/// Ensures the output ends with at least `required` newlines.
|
||||
/// This is idempotent: calling it multiple times with the same output
|
||||
/// will not add extra newlines beyond what is needed.
|
||||
/// The function is crucial for streaming rendering where chunks may
|
||||
/// split at block boundaries, causing the parser to lose newlines
|
||||
/// that normally separate blocks.
|
||||
fn ensure_newlines(output: &mut String, required: usize) {
|
||||
if output.is_empty() {
|
||||
// Don't start with blank lines
|
||||
return;
|
||||
}
|
||||
|
||||
// Count trailing newlines
|
||||
let trailing_newlines = output.chars().rev().take_while(|&c| c == '\n').count();
|
||||
|
||||
// Add newlines if we don't have enough
|
||||
if trailing_newlines < required {
|
||||
for _ in 0..(required - trailing_newlines) {
|
||||
output.push('\n');
|
||||
}
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
@@ -298,6 +285,18 @@ impl TerminalRenderer {
|
||||
self.render_markdown(markdown)
|
||||
}
|
||||
|
||||
/// Stream-friendly variant of `markdown_to_ansi` that preserves trailing
|
||||
/// block separators (such as a paragraph's closing newlines).
|
||||
///
|
||||
/// `MarkdownStreamState::push` writes each rendered chunk directly to the
|
||||
/// terminal with no separator of its own, so the trailing newlines must
|
||||
/// survive here to keep a paragraph from sticking to the block that follows
|
||||
/// it. The final `flush` still uses `markdown_to_ansi` to trim the tail.
|
||||
#[must_use]
|
||||
pub fn markdown_to_ansi_stream(&self, markdown: &str) -> String {
|
||||
self.render_markdown_raw(markdown)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn render_event(
|
||||
&self,
|
||||
@@ -310,14 +309,10 @@ impl TerminalRenderer {
|
||||
) {
|
||||
match event {
|
||||
Event::Start(Tag::Heading { level, .. }) => {
|
||||
Self::ensure_newlines(output, 1);
|
||||
Self::start_heading(state, level as u8, output);
|
||||
}
|
||||
Event::End(TagEnd::Paragraph) => output.push_str("\n\n"),
|
||||
Event::Start(Tag::BlockQuote(..)) => {
|
||||
Self::ensure_newlines(output, 1);
|
||||
self.start_quote(state, output);
|
||||
}
|
||||
Event::Start(Tag::BlockQuote(..)) => self.start_quote(state, output),
|
||||
Event::End(TagEnd::BlockQuote(..)) => {
|
||||
state.quote = state.quote.saturating_sub(1);
|
||||
output.push('\n');
|
||||
@@ -330,7 +325,6 @@ impl TerminalRenderer {
|
||||
state.append_raw(output, "\n");
|
||||
}
|
||||
Event::Start(Tag::List(first_item)) => {
|
||||
Self::ensure_newlines(output, 1);
|
||||
let kind = match first_item {
|
||||
Some(index) => ListKind::Ordered { next_index: index },
|
||||
None => ListKind::Unordered,
|
||||
@@ -343,7 +337,6 @@ impl TerminalRenderer {
|
||||
}
|
||||
Event::Start(Tag::Item) => Self::start_item(state, output),
|
||||
Event::Start(Tag::CodeBlock(kind)) => {
|
||||
Self::ensure_newlines(output, 2);
|
||||
*in_code_block = true;
|
||||
*code_language = match kind {
|
||||
CodeBlockKind::Indented => String::from("text"),
|
||||
@@ -367,10 +360,7 @@ impl TerminalRenderer {
|
||||
format!("{}", format!("`{code}`").with(self.color_theme.inline_code));
|
||||
state.append_raw(output, &rendered);
|
||||
}
|
||||
Event::Rule => {
|
||||
Self::ensure_newlines(output, 1);
|
||||
output.push_str("---\n");
|
||||
}
|
||||
Event::Rule => output.push_str("---\n"),
|
||||
Event::Text(text) => {
|
||||
self.push_text(text.as_ref(), state, output, code_buffer, *in_code_block);
|
||||
}
|
||||
@@ -415,10 +405,7 @@ impl TerminalRenderer {
|
||||
);
|
||||
state.append_raw(output, &rendered);
|
||||
}
|
||||
Event::Start(Tag::Table(..)) => {
|
||||
Self::ensure_newlines(output, 1);
|
||||
state.table = Some(TableState::default());
|
||||
}
|
||||
Event::Start(Tag::Table(..)) => state.table = Some(TableState::default()),
|
||||
Event::End(TagEnd::Table) => {
|
||||
if let Some(table) = state.table.take() {
|
||||
output.push_str(&self.render_table(&table));
|
||||
@@ -632,21 +619,9 @@ impl TerminalRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||
pub struct MarkdownStreamState {
|
||||
pending: String,
|
||||
/// Track whether the last emitted output ended with a newline.
|
||||
/// Used to ensure proper spacing when resuming from a buffer.
|
||||
last_output_ends_with_newline: bool,
|
||||
}
|
||||
|
||||
impl Default for MarkdownStreamState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
pending: String::new(),
|
||||
last_output_ends_with_newline: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MarkdownStreamState {
|
||||
@@ -656,40 +631,7 @@ impl MarkdownStreamState {
|
||||
let split = find_stream_safe_boundary(&self.pending)?;
|
||||
let ready = self.pending[..split].to_string();
|
||||
self.pending.drain(..split);
|
||||
|
||||
let mut rendered = renderer.markdown_to_ansi(&ready);
|
||||
let stripped = strip_ansi(&rendered);
|
||||
|
||||
// If the previous output didn't end with a newline and this chunk starts
|
||||
// with a block-level element, ensure proper spacing before it.
|
||||
if !self.last_output_ends_with_newline && !rendered.is_empty() {
|
||||
if stripped.starts_with("\u{256d}\u{2500}")
|
||||
|| stripped.starts_with("```")
|
||||
|| stripped.starts_with("|")
|
||||
|| stripped.starts_with("• ")
|
||||
|| stripped.starts_with("1. ")
|
||||
|| stripped.starts_with("-")
|
||||
{
|
||||
rendered = format!("\n\n{}", rendered);
|
||||
}
|
||||
}
|
||||
|
||||
// When markdown ends with a code block closing marker or other block-level element,
|
||||
// render_markdown() trims the trailing newlines that finish_code_block() added.
|
||||
// For streaming, we need to restore these newlines so proper spacing is preserved
|
||||
// when chunks are combined. This is critical for idempotence across re-renders.
|
||||
if stripped.ends_with("\u{2570}\u{2500}")
|
||||
|| stripped.ends_with("---")
|
||||
|| stripped.ends_with("|")
|
||||
{
|
||||
// Block element that should have trailing spacing
|
||||
if !rendered.ends_with('\n') {
|
||||
rendered.push_str("\n\n");
|
||||
}
|
||||
}
|
||||
|
||||
self.last_output_ends_with_newline = rendered.ends_with('\n');
|
||||
Some(rendered)
|
||||
Some(renderer.markdown_to_ansi_stream(&ready))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
@@ -699,26 +641,7 @@ impl MarkdownStreamState {
|
||||
None
|
||||
} else {
|
||||
let pending = std::mem::take(&mut self.pending);
|
||||
let mut rendered = renderer.markdown_to_ansi(&pending);
|
||||
|
||||
// If the previous output didn't end with a newline and this output
|
||||
// starts with a block-level element, ensure proper spacing.
|
||||
if !self.last_output_ends_with_newline && !rendered.is_empty() {
|
||||
// Check if output starts with a block element by looking for common markers
|
||||
let stripped = strip_ansi(&rendered);
|
||||
if stripped.starts_with("\u{256d}\u{2500}")
|
||||
|| stripped.starts_with("```")
|
||||
|| stripped.starts_with("|")
|
||||
|| stripped.starts_with("• ")
|
||||
|| stripped.starts_with("1. ")
|
||||
|| stripped.starts_with("-")
|
||||
{
|
||||
rendered = format!("\n\n{}", rendered);
|
||||
}
|
||||
}
|
||||
|
||||
self.last_output_ends_with_newline = rendered.ends_with('\n');
|
||||
Some(rendered)
|
||||
Some(renderer.markdown_to_ansi(&pending))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -912,11 +835,6 @@ fn normalize_nested_fences(markdown: &str) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// Find a safe boundary for streaming markdown rendering.
|
||||
/// Safe boundaries are:
|
||||
/// - After a closing fence line (when fence is closed)
|
||||
/// - After an empty line
|
||||
/// This function returns the position of the last safe boundary found.
|
||||
fn find_stream_safe_boundary(markdown: &str) -> Option<usize> {
|
||||
let mut open_fence: Option<FenceMarker> = None;
|
||||
let mut last_boundary = None;
|
||||
@@ -1156,6 +1074,34 @@ mod tests {
|
||||
assert!(plain_text.contains("fn nested()"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_rendering_preserves_block_separators() {
|
||||
let renderer = TerminalRenderer::new();
|
||||
|
||||
// The stream variant keeps the paragraph-ending newlines that the
|
||||
// one-shot variant trims, so consecutive streamed chunks do not run
|
||||
// into each other when written back-to-back to the terminal.
|
||||
let streamed = renderer.markdown_to_ansi_stream("Intro paragraph.\n\n");
|
||||
assert!(streamed.ends_with('\n'));
|
||||
assert!(!renderer
|
||||
.markdown_to_ansi("Intro paragraph.\n\n")
|
||||
.ends_with('\n'));
|
||||
|
||||
let mut state = MarkdownStreamState::default();
|
||||
let first = state
|
||||
.push(&renderer, "Intro paragraph.\n\n")
|
||||
.expect("blank line closes the paragraph block");
|
||||
let second = state
|
||||
.push(&renderer, "1. first item\n\n")
|
||||
.expect("blank line closes the list block");
|
||||
|
||||
let combined = strip_ansi(&format!("{first}{second}"));
|
||||
assert!(combined.contains("Intro paragraph."));
|
||||
assert!(combined.contains("1. first item"));
|
||||
// The two blocks stay on separate lines instead of sticking together.
|
||||
assert!(!combined.contains("Intro paragraph.1."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spinner_advances_frames() {
|
||||
let terminal_renderer = TerminalRenderer::new();
|
||||
@@ -1171,189 +1117,4 @@ mod tests {
|
||||
let output = String::from_utf8_lossy(&out);
|
||||
assert!(output.contains("Working"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streaming_text_before_code_block_preserves_newline_boundary() {
|
||||
let renderer = TerminalRenderer::new();
|
||||
let mut state = MarkdownStreamState::default();
|
||||
|
||||
// Simulate streaming: paragraph text arrives in one chunk
|
||||
assert_eq!(
|
||||
state.push(&renderer, "This is some text."),
|
||||
None,
|
||||
"incomplete paragraph should not flush"
|
||||
);
|
||||
|
||||
// Then code block arrives in the next chunk
|
||||
let rendered = state
|
||||
.push(&renderer, "\n\n```rust\nfn main() {}\n```\n")
|
||||
.expect("fenced block should trigger flush");
|
||||
let plain_text = strip_ansi(&rendered);
|
||||
|
||||
// Verify the paragraph text and code block are present
|
||||
assert!(plain_text.contains("This is some text."));
|
||||
assert!(plain_text.contains("fn main()"));
|
||||
|
||||
// The critical check: there should be a blank line between the paragraph and code block
|
||||
// The paragraph ends, then there should be newlines before the code block marker
|
||||
let lines: Vec<&str> = plain_text.lines().collect();
|
||||
let text_line_idx = lines
|
||||
.iter()
|
||||
.position(|line| line.contains("This is some text."))
|
||||
.expect("find text line");
|
||||
// Skip past the text line and find the code block
|
||||
let code_marker_idx = lines[text_line_idx + 1..]
|
||||
.iter()
|
||||
.position(|line| line.contains("╭─ rust"))
|
||||
.map(|i| i + text_line_idx + 1);
|
||||
|
||||
// There should be at least one empty line between the text and the code block marker
|
||||
if let Some(code_idx) = code_marker_idx {
|
||||
assert!(
|
||||
code_idx > text_line_idx + 1,
|
||||
"there should be spacing between paragraph and code block"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rendering_paragraph_immediately_before_code_block_has_blank_line() {
|
||||
let renderer = TerminalRenderer::new();
|
||||
let markdown = "This is a paragraph.\n\n```rust\nfn main() {}\n```";
|
||||
let output = renderer.markdown_to_ansi(markdown);
|
||||
let plain_text = strip_ansi(&output);
|
||||
let lines: Vec<&str> = plain_text.lines().collect();
|
||||
|
||||
// Find the paragraph and code block
|
||||
let text_idx = lines
|
||||
.iter()
|
||||
.position(|l| l.contains("This is a paragraph."))
|
||||
.expect("find paragraph");
|
||||
let code_idx = lines[text_idx + 1..]
|
||||
.iter()
|
||||
.position(|l| l.contains("╭─ rust"))
|
||||
.map(|i| i + text_idx + 1)
|
||||
.expect("find code block");
|
||||
|
||||
// There should be at least one blank line between them
|
||||
assert!(
|
||||
code_idx > text_idx + 1,
|
||||
"code block should not immediately follow paragraph"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_block_ending_with_trailing_text_preserves_spacing() {
|
||||
let renderer = TerminalRenderer::new();
|
||||
let markdown = "```rust\nfn main() {}\n```\n\nText after.";
|
||||
let output = renderer.markdown_to_ansi(markdown);
|
||||
let plain_text = strip_ansi(&output);
|
||||
|
||||
// The code block should end with "╰─" followed by blank lines, then text
|
||||
assert!(
|
||||
plain_text.contains("\u{256d}\u{2500}"),
|
||||
"should contain code block close marker"
|
||||
);
|
||||
assert!(
|
||||
plain_text.contains("\n\nText"),
|
||||
"should have blank line before text"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streaming_code_block_preserves_trailing_newlines() {
|
||||
let renderer = TerminalRenderer::new();
|
||||
let mut state = MarkdownStreamState::default();
|
||||
let markdown = "Text before.\n\n```rust\nfn test() {}\n```\n\nText after.";
|
||||
|
||||
// Debug: let's check what the code block part alone renders to
|
||||
let code_part = "```rust\nfn test() {}\n```\n\nText after.";
|
||||
let code_rendered = strip_ansi(&renderer.markdown_to_ansi(code_part));
|
||||
|
||||
// Simulate the streaming at split position 1
|
||||
let r1 = state.push(&renderer, "T").map(|s| strip_ansi(&s));
|
||||
let r2 = state
|
||||
.push(
|
||||
&renderer,
|
||||
"ext before.\n\n```rust\nfn test() {}\n```\n\nText after.",
|
||||
)
|
||||
.map(|s| strip_ansi(&s));
|
||||
|
||||
// After r2, the pending should contain the code block part
|
||||
let pending_before_r3 = state.pending.clone();
|
||||
|
||||
let r3 = state.flush(&renderer).map(|s| strip_ansi(&s));
|
||||
|
||||
// Combine the results
|
||||
let mut full_streamed = String::new();
|
||||
if let Some(s) = &r1 {
|
||||
full_streamed.push_str(s);
|
||||
}
|
||||
if let Some(s) = &r2 {
|
||||
full_streamed.push_str(s);
|
||||
}
|
||||
if let Some(s) = &r3 {
|
||||
full_streamed.push_str(s);
|
||||
}
|
||||
|
||||
let full_rendered = strip_ansi(&renderer.markdown_to_ansi(markdown));
|
||||
|
||||
assert_eq!(full_streamed, full_rendered,
|
||||
"Streaming mismatch.\n\nCode part alone renders to: {:?}\nPending before r3: {:?}\n\nBreakdown:\n r1: {:?}\n r2: {:?}\n r3: {:?}\n\nExpected:\n{:?}\n\nGot:\n{:?}",
|
||||
code_rendered, pending_before_r3, r1, r2, r3, full_rendered, full_streamed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streaming_with_split_at_every_byte_position_produces_consistent_output() {
|
||||
let renderer = TerminalRenderer::new();
|
||||
let markdown = "Text before.\n\n```rust\nfn test() {}\n```\n\nText after.";
|
||||
|
||||
// First, render the full document
|
||||
let full_output = renderer.markdown_to_ansi(markdown);
|
||||
let full_plain = strip_ansi(&full_output);
|
||||
|
||||
// Now simulate streaming by splitting at different positions
|
||||
for split_pos in 1..markdown.len() {
|
||||
let mut state = MarkdownStreamState::default();
|
||||
let part1 = &markdown[..split_pos];
|
||||
let part2 = &markdown[split_pos..];
|
||||
|
||||
// Try to push both parts
|
||||
let r1 = state.push(&renderer, part1);
|
||||
let r2 = state.push(&renderer, part2);
|
||||
let final_r = state.flush(&renderer);
|
||||
|
||||
// Reconstruct the streamed output
|
||||
let mut streamed_output = String::new();
|
||||
if let Some(out) = r1 {
|
||||
streamed_output.push_str(&out);
|
||||
}
|
||||
if let Some(out) = r2 {
|
||||
streamed_output.push_str(&out);
|
||||
}
|
||||
if let Some(out) = final_r {
|
||||
streamed_output.push_str(&out);
|
||||
}
|
||||
|
||||
let streamed_plain = strip_ansi(&streamed_output);
|
||||
|
||||
if streamed_plain != full_plain {
|
||||
eprintln!("\n=== MISMATCH at split position {} ===", split_pos);
|
||||
eprintln!("Part 1: {:?}", part1);
|
||||
eprintln!("Part 2: {:?}", part2);
|
||||
eprintln!("\nExpected (full):");
|
||||
eprintln!("{:?}", full_plain);
|
||||
eprintln!("\nGot (streamed):");
|
||||
eprintln!("{:?}", streamed_plain);
|
||||
eprintln!("\nStreamed rendered output:");
|
||||
eprintln!("{:?}", streamed_output);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
streamed_plain, full_plain,
|
||||
"streaming with split at position {} produces different output",
|
||||
split_pos
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -441,6 +441,9 @@ fn status_and_sandbox_emit_json_when_requested() {
|
||||
|
||||
// #831: direct resume-safe slash commands should use the same local CliAction
|
||||
// JSON surfaces as their bare subcommands, not interactive_only guidance.
|
||||
// Note: /sandbox status depends on kernel support for user namespaces.
|
||||
// On systems with working user namespaces the status is "ok".
|
||||
// On systems without user namespaces the status is "warn" (#731).
|
||||
#[test]
|
||||
fn direct_resume_safe_slash_commands_route_to_local_json_actions_831() {
|
||||
let root = unique_temp_dir("direct-resume-safe-slash-831");
|
||||
@@ -451,11 +454,11 @@ fn direct_resume_safe_slash_commands_route_to_local_json_actions_831() {
|
||||
.output()
|
||||
.expect("git init should launch");
|
||||
|
||||
for (command, expected_kind, expected_status) in [
|
||||
("/version", "version", "ok"),
|
||||
("/sandbox", "sandbox", "warn"),
|
||||
("/diff", "diff", "ok"),
|
||||
("/status", "status", "ok"),
|
||||
for (command, expected_kind, expected_statuses) in [
|
||||
("/version", "version", &["ok"][..]),
|
||||
("/sandbox", "sandbox", &["ok", "warn"][..]),
|
||||
("/diff", "diff", &["ok"][..]),
|
||||
("/status", "status", &["ok"][..]),
|
||||
] {
|
||||
let output = run_claw(&root, &["--output-format", "json", command], &[]);
|
||||
assert!(
|
||||
@@ -470,9 +473,9 @@ fn direct_resume_safe_slash_commands_route_to_local_json_actions_831() {
|
||||
.unwrap_or_else(|_| panic!("{command} must emit JSON (#831), got: {stdout:?}"));
|
||||
|
||||
assert_eq!(parsed["kind"], expected_kind, "{command} kind: {parsed}");
|
||||
assert_eq!(
|
||||
parsed["status"], expected_status,
|
||||
"{command} status: {parsed}"
|
||||
assert!(
|
||||
expected_statuses.contains(&parsed["status"].as_str().unwrap_or("")),
|
||||
"{command} status must be one of {expected_statuses:?}: {parsed}"
|
||||
);
|
||||
assert_ne!(
|
||||
parsed["error_kind"], "interactive_only",
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
# Multi-Tool Execution & Sub-Agent Delegation
|
||||
|
||||
Two complementary features that dramatically reduce latency and token usage when the model needs to perform multiple operations or gather context.
|
||||
|
||||
## Feature 1: Parallel Tool Execution
|
||||
|
||||
When the model returns multiple tool_use blocks in a single response, read-only tools now execute concurrently instead of sequentially.
|
||||
|
||||
### How it works
|
||||
|
||||
The `run_turn` loop is refactored into 3 phases:
|
||||
|
||||
1. **Pre-hooks + permission checks** (sequential — hooks may mutate state)
|
||||
2. **Tool execution** (batch — parallel for read-only tools via `std::thread::scope`)
|
||||
3. **Post-hooks + session updates** (sequential — preserves original ordering)
|
||||
|
||||
### Parallel-safe tools
|
||||
|
||||
These tools are safe to run concurrently because they only read state and dispatch through the stateless tool registry:
|
||||
|
||||
- `read_file`, `glob_search`, `grep_search`
|
||||
- `WebFetch`, `WebSearch`
|
||||
- `ToolSearch`, `Skill`
|
||||
- `LSP`
|
||||
- `GitStatus`, `GitDiff`, `GitLog`, `GitShow`, `GitBlame`
|
||||
|
||||
### Sequential-only tools
|
||||
|
||||
Tools that require `&mut self` or have side effects continue to run one at a time:
|
||||
|
||||
- `bash`, `write_file`, `edit_file` (side effects)
|
||||
- `MCP`, `McpAuth`, `RemoteTrigger` (network state)
|
||||
- `Agent`, `TaskCreate`, `WorkerCreate` (stateful)
|
||||
- `NotebookEdit`, `REPL`, `PowerShell` (side effects)
|
||||
|
||||
### Safety guarantees
|
||||
|
||||
- Pre/post hooks always run sequentially
|
||||
- Permission checks complete before any tool executes
|
||||
- Tool results are pushed to the session in the original model order
|
||||
- Falls back to sequential for single-tool batches
|
||||
- Thread scopes ensure all parallel work completes before `execute_batch` returns
|
||||
|
||||
### Impact
|
||||
|
||||
For a response with 5 `read_file` calls: **~5x faster** execution. The main model still sees all results in order.
|
||||
|
||||
---
|
||||
|
||||
## Feature 2: SubAgent Delegation
|
||||
|
||||
A `SubAgent` tool that lets the main model delegate multi-step tasks to a fast sub-agent. The sub-agent runs autonomously with its own `ConversationRuntime`, making multiple tool calls without round-tripping through the main model.
|
||||
|
||||
### Tool parameters
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `prompt` | string | yes | Task description for the sub-agent |
|
||||
| `task_type` | string | no | `Explore` (default), `Plan`, or `Verify` |
|
||||
| `model` | string | no | Override the sub-agent model |
|
||||
|
||||
### Task types
|
||||
|
||||
| Type | Available tools | Use for |
|
||||
|------|----------------|---------|
|
||||
| `Explore` | read_file, glob_search, grep_search, WebFetch, WebSearch, ToolSearch, StructuredOutput | Searching code, reading files, gathering context |
|
||||
| `Plan` | Explore + TodoWrite | Planning approaches with structured todo output |
|
||||
| `Verify` | Plan + bash | Running tests, checking builds, verifying changes |
|
||||
|
||||
### Configuration
|
||||
|
||||
Set the sub-agent model in `~/.claw/settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "openai/glm-5.1-fast",
|
||||
"subagentModel": "openai/qwen3.6-35b-fast"
|
||||
}
|
||||
```
|
||||
|
||||
If `subagentModel` is not set, the sub-agent uses the same model as the main session (or the `model` override parameter on the tool call).
|
||||
|
||||
### Example
|
||||
|
||||
Main model prompt: "Find all Rust files that import `ConversationRuntime` and list their paths"
|
||||
|
||||
Without SubAgent: 5–10 sequential tool calls (grep → read each file → summarize)
|
||||
With SubAgent: 1 SubAgent call → sub-agent does all the work autonomously → returns summary
|
||||
|
||||
**Result**: ~10x fewer tokens consumed by the main model, faster overall completion.
|
||||
|
||||
### Architecture
|
||||
|
||||
The sub-agent reuses the same building blocks as the existing `Agent` tool:
|
||||
|
||||
- `ProviderRuntimeClient` — API client with fallback chain
|
||||
- `SubagentToolExecutor` — filtered tool access with permission enforcement
|
||||
- `ConversationRuntime` — full conversation loop with hooks and compaction
|
||||
- `agent_permission_policy()` — auto-approve read-only, deny write tools
|
||||
|
||||
Key differences from the `Agent` tool:
|
||||
- **Synchronous** — blocks until complete, returns result directly
|
||||
- **Lighter** — fewer default tools, focused on the task type
|
||||
- **Configurable model** — uses `subagentModel` or the tool's `model` param
|
||||
- **Structured output** — returns `result`, `tool_calls`, and `iterations`
|
||||
|
||||
---
|
||||
|
||||
## Changed files
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `rust/crates/runtime/src/conversation.rs` | `ToolCall`, `ToolResult` types; `execute_batch` on `ToolExecutor`; 3-phase `run_turn` |
|
||||
| `rust/crates/runtime/src/lib.rs` | Exports for `ToolCall`, `ToolResult` |
|
||||
| `rust/crates/runtime/src/config.rs` | `subagent_model` field, `parse_optional_subagent_model()`, accessor |
|
||||
| `rust/crates/runtime/src/config_validate.rs` | `subagentModel` field spec |
|
||||
| `rust/crates/rusty-claude-cli/src/main.rs` | `CliToolExecutor::execute_batch` with parallel-safe classification |
|
||||
| `rust/crates/tools/src/lib.rs` | `SubAgent` tool spec, `SubAgentInput`, `run_sub_agent()`, `load_subagent_model_from_config()`, `build_sub_agent_system_prompt()` |
|
||||
@@ -116,8 +116,6 @@ mod tests {
|
||||
lane_events: vec![],
|
||||
derived_state: "working".to_string(),
|
||||
current_blocker: None,
|
||||
team_id: None,
|
||||
task_id: None,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
+53
-1906
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user