This commit is contained in:
huagusam 2026-08-16 13:20:03 +07:00 committed by GitHub
commit fa31de7949
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
237 changed files with 40584 additions and 58091 deletions

View File

@ -1 +0,0 @@
{"messages":[],"version":1}

View File

@ -1 +0,0 @@
{"messages":[],"version":1}

View File

@ -1 +0,0 @@
{"messages":[],"version":1}

View File

@ -1 +0,0 @@
{"messages":[],"version":1}

View File

@ -1 +0,0 @@
{"messages":[],"version":1}

View File

@ -1 +0,0 @@
{"messages":[],"version":1}

View File

@ -1 +0,0 @@
{"messages":[],"version":1}

View File

@ -1 +0,0 @@
{"messages":[{"blocks":[{"text":"hello","type":"text"}],"role":"user"},{"blocks":[{"text":"Hello! I'm Claude, an AI assistant built on Anthropic's Claude Agent SDK. I'm here to help you with software engineering tasks in your","type":"text"}],"role":"assistant","usage":{"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"input_tokens":141,"output_tokens":32}},{"blocks":[{"text":"who are you?","type":"text"}],"role":"user"},{"blocks":[{"text":"I'm Claude, an AI assistant built on Anthropic's Claude Agent SDK. I'm designed to help you with software engineering tasks, and I'm currently","type":"text"}],"role":"assistant","usage":{"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"input_tokens":182,"output_tokens":32}}],"version":1}

View File

@ -1 +0,0 @@
{"messages":[],"version":1}

View File

@ -1 +0,0 @@
{"messages":[],"version":1}

View File

@ -1 +0,0 @@
{"messages":[],"version":1}

View File

@ -1 +0,0 @@
{"messages":[],"version":1}

View File

@ -1 +0,0 @@
{"messages":[],"version":1}

View File

@ -1 +0,0 @@
{"messages":[{"blocks":[{"text":"build","type":"text"}],"role":"user"},{"blocks":[{"text":"\n\nI'll build the Rust project.\n\n<tool_use>\n<server_name>shell</server_name>\n<tool_name","type":"text"}],"role":"assistant","usage":{"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"input_tokens":1963,"output_tokens":32}}],"version":1}

View File

@ -1 +0,0 @@
{"messages":[],"version":1}

View File

@ -1 +0,0 @@
{"messages":[],"version":1}

View File

@ -1 +0,0 @@
{"messages":[{"blocks":[{"text":"hello","type":"text"}],"role":"user"},{"blocks":[{"text":"\n\nHello! 👋 How can I help you today? I see you're working in a Rust project (`rusty-claude-cli`) with some changes to the argument parsing in `main.rs` — specifically loading the model from config with a fallback to a default. \n\nWhat would you like to work on?","type":"text"}],"role":"assistant","usage":{"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"input_tokens":324,"output_tokens":73}}],"version":1}

View File

@ -1 +0,0 @@
{"messages":[{"blocks":[{"text":"--dangerously-skip-permissions","type":"text"}],"role":"user"},{"blocks":[{"text":"\n\nI see you're working in the `rusty-claude-cli` project. The git diff shows a simplification of the `max_tokens_for_model` function - collapsing sonnet/haiku/default cases into a single `else` branch returning 64,000.\n\nHow can I help you today? What would you like to work on?","type":"text"}],"role":"assistant","usage":{"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"input_tokens":2126,"output_tokens":81}}],"version":1}

View File

@ -1 +0,0 @@
{"messages":[],"version":1}

View File

@ -1 +0,0 @@
{"messages":[{"blocks":[{"text":"do youy have issues with bash tool calling?","type":"text"}],"role":"user"},{"blocks":[{"text":"\n\nNo, I don't have issues with the `bash` tool — it works fine for me. I can execute shell commands, run scripts, check file contents, build projects, run tests, etc.\n\nIs there something specific you're experiencing or a particular command you'd like me to run? If you've seen an error or unexpected behavior with a bash tool call, feel free to share the details and I'll look into it.","type":"text"}],"role":"assistant","usage":{"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"input_tokens":333,"output_tokens":92}},{"blocks":[{"text":"test it out","type":"text"}],"role":"user"},{"blocks":[{"text":"\n\nSure, let me run a few quick bash commands to verify everything works:","type":"text"},{"id":"toolu_01QyBjL69zgawKC1gqvqvww5","input":"{}{\"command\": \"echo \\\"Hello from bash! Date: $(date), Shell: $SHELL, PWD: $PWD\\\"\"}","name":"bash","type":"tool_use"},{"id":"toolu_01SVSCJuMUv1XWx6D52rN2Kp","input":"{}{\"command\": \"uname -a\"}","name":"bash","type":"tool_use"},{"id":"toolu_01VT7QAJKQUi8UWdu2RcNZmx","input":"{}{\"command\": \"ls -la --color=never | head -20\"}","name":"bash","type":"tool_use"}],"role":"assistant","usage":{"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"input_tokens":432,"output_tokens":172}},{"blocks":[{"is_error":true,"output":"invalid tool input JSON: trailing characters at line 1 column 3","tool_name":"bash","tool_use_id":"toolu_01QyBjL69zgawKC1gqvqvww5","type":"tool_result"}],"role":"tool"},{"blocks":[{"is_error":true,"output":"invalid tool input JSON: trailing characters at line 1 column 3","tool_name":"bash","tool_use_id":"toolu_01SVSCJuMUv1XWx6D52rN2Kp","type":"tool_result"}],"role":"tool"},{"blocks":[{"is_error":true,"output":"invalid tool input JSON: trailing characters at line 1 column 3","tool_name":"bash","tool_use_id":"toolu_01VT7QAJKQUi8UWdu2RcNZmx","type":"tool_result"}],"role":"tool"},{"blocks":[{"text":"Well, looks like you were right — there *is* an issue! The tool calls are failing with a JSON parsing error. Let me try again with the correct format:","type":"text"},{"id":"toolu_01MxrSFY7LsdjiTYEmi9zQ4f","input":"{}{\"command\": \"echo \\\"Hello from bash! Date: $(date), Shell: $SHELL, PWD: $PWD\\\"\"}","name":"bash","type":"tool_use"}],"role":"assistant","usage":{"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"input_tokens":750,"output_tokens":110}},{"blocks":[{"is_error":true,"output":"invalid tool input JSON: trailing characters at line 1 column 3","tool_name":"bash","tool_use_id":"toolu_01MxrSFY7LsdjiTYEmi9zQ4f","type":"tool_result"}],"role":"tool"},{"blocks":[{"id":"toolu_01REJA2MvdEz8CeCDTQBCJpp","input":"{}{\"command\": \"echo \\\"Hello from bash! Date: $(date), Shell: $SHELL, PWD: $PWD\\\"\"}","name":"bash","type":"tool_use"}],"role":"assistant","usage":{"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"input_tokens":901,"output_tokens":74}},{"blocks":[{"is_error":true,"output":"invalid tool input JSON: trailing characters at line 1 column 3","tool_name":"bash","tool_use_id":"toolu_01REJA2MvdEz8CeCDTQBCJpp","type":"tool_result"}],"role":"tool"},{"blocks":[{"id":"toolu_01FPqWmNjSqMyKx5GeuNAfHu","input":"{}{\"command\": \"echo hello\"}","name":"bash","type":"tool_use"}],"role":"assistant","usage":{"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"input_tokens":1016,"output_tokens":52}},{"blocks":[{"is_error":true,"output":"invalid tool input JSON: trailing characters at line 1 column 3","tool_name":"bash","tool_use_id":"toolu_01FPqWmNjSqMyKx5GeuNAfHu","type":"tool_result"}],"role":"tool"},{"blocks":[{"text":"Yep, confirmed — there's definitely a bug. The error `\"trailing characters at line 1 column 3\"` suggests the JSON being sent for the `bash` tool input is malformed. It looks like there's an empty `{}` being prepended to the actual JSON payload, resulting in `{}{\"command\": \"...\"}` which is invalid JSON (two objects concatenated).\n\nThis is likely a bug in your Rust CLI's tool call serialization. Want me to look at the relevant code in `crates/rusty-claude-cli/src/main.rs` to track down the root cause?","type":"text"}],"role":"assistant","usage":{"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"input_tokens":1109,"output_tokens":137}}],"version":1}

View File

@ -1 +0,0 @@
{"messages":[{"blocks":[{"text":"hello use bash tool for testing","type":"text"}],"role":"user"},{"blocks":[{"text":"\n\nHello! I'm ready to help. Let me run a quick bash command to confirm everything is working:","type":"text"},{"id":"toolu_01EuTzVfUK7iPRBvjZAovzfV","input":"{\"command\": \"echo \\\"Hello! Bash tool is working. 🎉\\\" && date && pwd\"}","name":"bash","type":"tool_use"}],"role":"assistant","usage":{"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"input_tokens":4277,"output_tokens":92}},{"blocks":[{"is_error":false,"output":"{\n \"stdout\": \"Hello! Bash tool is working. 🎉\\nWed Apr 1 02:25:46 AM UTC 2026\\n/home/bellman/Workspace/clawd-code/rust\\n\",\n \"stderr\": \"\",\n \"rawOutputPath\": null,\n \"interrupted\": false,\n \"isImage\": null,\n \"backgroundTaskId\": null,\n \"backgroundedByUser\": null,\n \"assistantAutoBackgrounded\": null,\n \"dangerouslyDisableSandbox\": null,\n \"returnCodeInterpretation\": null,\n \"noOutputExpected\": false,\n \"structuredContent\": null,\n \"persistedOutputPath\": null,\n \"persistedOutputSize\": null,\n \"sandboxStatus\": {\n \"enabled\": true,\n \"requested\": {\n \"enabled\": true,\n \"namespace_restrictions\": true,\n \"network_isolation\": false,\n \"filesystem_mode\": \"workspace-only\",\n \"allowed_mounts\": []\n },\n \"supported\": true,\n \"active\": true,\n \"namespace_supported\": true,\n \"namespace_active\": true,\n \"network_supported\": true,\n \"network_active\": false,\n \"filesystem_mode\": \"workspace-only\",\n \"filesystem_active\": true,\n \"allowed_mounts\": [],\n \"in_container\": false,\n \"container_markers\": [],\n \"fallback_reason\": null\n }\n}","tool_name":"bash","tool_use_id":"toolu_01EuTzVfUK7iPRBvjZAovzfV","type":"tool_result"}],"role":"tool"},{"blocks":[{"text":"Bash tool is working perfectly! ✅\n\nHere's a quick summary:\n- **Status**: Operational 🎉\n- **Date**: Wed Apr 1, 2026\n- **Working directory**: `/home/bellman/Workspace/clawd-code/rust`\n- **Sandbox**: Enabled and active\n\nHow can I help you today?","type":"text"}],"role":"assistant","usage":{"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"input_tokens":4746,"output_tokens":84}}],"version":1}

View File

@ -1 +0,0 @@
{"messages":[{"blocks":[{"text":"who are you?","type":"text"}],"role":"user"},{"blocks":[{"text":"I'm **Claude**, an AI assistant built by Anthropic, currently running as a **Claude Code agent** (Claude Opus 4.6). I'm set up in your workspace at `/home/bellman/Workspace/clawd-code/rust` with the **oh-my-claudecode (OMC)** orchestration layer active.\n\nHere's what I can help you with:\n\n- **Software engineering tasks** — reading, writing, and refactoring code\n- **Debugging** — diagnosing issues, running tests, tracing errors\n- **Research & planning** — exploring codebases, understanding architecture, planning changes\n- **Shell commands** — building, testing, running tools in your environment\n- **Multi-agent workflows** — delegating specialized work (reviews, deep analysis, etc.) via OMC\n\nI have access to your local filesystem, can execute shell commands, search the web, and coordinate specialized sub-agents for complex tasks.\n\nWhat can I help you with today?","type":"text"}],"role":"assistant","usage":{"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"input_tokens":4292,"output_tokens":220}}],"version":1}

View File

@ -1 +0,0 @@
{"messages":[],"version":1}

View File

@ -1 +0,0 @@
{"messages":[],"version":1}

File diff suppressed because one or more lines are too long

View File

@ -1 +0,0 @@
{"messages":[{"blocks":[{"text":"clear","type":"text"}],"role":"user"},{"blocks":[{"text":"\n\nI've cleared the conversation. How can I help you today?","type":"text"}],"role":"assistant","usage":{"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"input_tokens":4272,"output_tokens":17}}],"version":1}

View File

@ -1 +0,0 @@
{"messages":[{"blocks":[{"text":"exit","type":"text"}],"role":"user"},{"blocks":[{"text":"\n\nGoodbye! 👋","type":"text"}],"role":"assistant","usage":{"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"input_tokens":4272,"output_tokens":10}}],"version":1}

View File

@ -1 +0,0 @@
{"messages":[],"version":1}

View File

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

View File

@ -1 +0,0 @@
{"created_at_ms":1775777421902,"session_id":"session-1775777421902-1","type":"session_meta","updated_at_ms":1775777421902,"version":1}

View File

@ -1,2 +0,0 @@
{"created_at_ms":1775386842352,"session_id":"session-1775386842352-0","type":"session_meta","updated_at_ms":1775386842352,"version":1}
{"message":{"blocks":[{"text":"doctor --help","type":"text"}],"role":"user"},"type":"message"}

View File

@ -1,2 +0,0 @@
{"created_at_ms":1775386852257,"session_id":"session-1775386852257-0","type":"session_meta","updated_at_ms":1775386852257,"version":1}
{"message":{"blocks":[{"text":"doctor --help","type":"text"}],"role":"user"},"type":"message"}

View File

@ -1,2 +0,0 @@
{"created_at_ms":1775386853666,"session_id":"session-1775386853666-0","type":"session_meta","updated_at_ms":1775386853666,"version":1}
{"message":{"blocks":[{"text":"status --help","type":"text"}],"role":"user"},"type":"message"}

View File

@ -1,27 +0,0 @@
[
{
"content": "Architecture & dependency analysis",
"activeForm": "Complete",
"status": "completed"
},
{
"content": "Runtime crate deep analysis",
"activeForm": "Complete",
"status": "completed"
},
{
"content": "CLI & Tools analysis",
"activeForm": "Complete",
"status": "completed"
},
{
"content": "Code quality verification",
"activeForm": "Complete",
"status": "completed"
},
{
"content": "Synthesize findings into unified report",
"activeForm": "Writing report",
"status": "in_progress"
}
]

View File

@ -1,15 +0,0 @@
# This .dockerignore applies to docker-compose build context: ./rust
target
**/target
.claw
.claw-rag
.claude
node_modules
dist
build
*.log
*.tmp
*.sqlite
*.sqlite-wal
*.sqlite-shm
.DS_Store

8
rust/.gitignore vendored
View File

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

View File

@ -1,221 +0,0 @@
# TUI Enhancement Plan — Claw Code (`rusty-claude-cli`)
## Executive Summary
This plan covers a comprehensive analysis of the current terminal user interface and proposes phased enhancements that will transform the existing REPL/prompt CLI into a polished, modern TUI experience — while preserving the existing clean architecture and test coverage.
---
## 1. Current Architecture Analysis
### Crate Map
| Crate | Purpose | Lines | TUI Relevance |
|---|---|---|---|
| `rusty-claude-cli` | Main binary: REPL loop, arg parsing, rendering, API bridge | ~3,600 | **Primary TUI surface** |
| `runtime` | Session, conversation loop, config, permissions, compaction | ~5,300 | Provides data/state |
| `api` | Anthropic HTTP client + SSE streaming | ~1,500 | Provides stream events |
| `commands` | Slash command metadata/parsing/help | ~470 | Drives command dispatch |
| `tools` | 18 built-in tool implementations | ~3,500 | Tool execution display |
### Current TUI Components
| Component | File | What It Does Today | Quality |
|---|---|---|---|
| **Input** | `input.rs` (269 lines) | `rustyline`-based line editor with slash-command tab completion, Shift+Enter newline, history | ✅ Solid |
| **Rendering** | `render.rs` (641 lines) | Markdown→terminal rendering (headings, lists, tables, code blocks with syntect highlighting, blockquotes), spinner widget | ✅ Good |
| **App/REPL loop** | `main.rs` (3,159 lines) | The monolithic `LiveCli` struct: REPL loop, all slash command handlers, streaming output, tool call display, permission prompting, session management | ⚠️ Monolithic |
| **Alt App** | `app.rs` (398 lines) | An earlier `CliApp` prototype with `ConversationClient`, stream event handling, `TerminalRenderer`, output format support | ⚠️ Appears unused/legacy |
### Key Dependencies
- **crossterm 0.28** — terminal control (cursor, colors, clear)
- **pulldown-cmark 0.13** — Markdown parsing
- **syntect 5** — syntax highlighting
- **rustyline 15** — line editing with completion
- **serde_json** — tool I/O formatting
### Strengths
1. **Clean rendering pipeline**: Markdown rendering is well-structured with state tracking, table rendering, code highlighting
2. **Rich tool display**: Tool calls get box-drawing borders (`╭─ name ─╮`), results show ✓/✗ icons
3. **Comprehensive slash commands**: 15 commands covering model switching, permissions, sessions, config, diff, export
4. **Session management**: Full persistence, resume, list, switch, compaction
5. **Permission prompting**: Interactive Y/N approval for restricted tool calls
6. **Thorough tests**: Every formatting function, every parse path has unit tests
### Weaknesses & Gaps
1. **`main.rs` is a 3,159-line monolith** — all REPL logic, formatting, API bridging, session management, and tests in one file
2. **No alternate-screen / full-screen layout** — everything is inline scrolling output
3. **No progress bars** — only a single braille spinner; no indication of streaming progress or token counts during generation
4. **No visual diff rendering**`/diff` just dumps raw git diff text
5. **No syntax highlighting in streamed output** — markdown rendering only applies to tool results, not to the main assistant response stream
6. **No status bar / HUD** — model, tokens, session info not visible during interaction
7. **No image/attachment preview**`SendUserMessage` resolves attachments but never displays them
8. **Streaming is char-by-char with artificial delay**`stream_markdown` sleeps 8ms per whitespace-delimited chunk
9. **No color theme customization** — hardcoded `ColorTheme::default()`
10. **No resize handling** — no terminal size awareness for wrapping, truncation, or layout
11. **Dual app structs**`app.rs` has a separate `CliApp` that duplicates `LiveCli` from `main.rs`
12. **No pager for long outputs**`/status`, `/config`, `/memory` can overflow the viewport
13. **Tool results not collapsible** — large bash outputs flood the screen
14. **No thinking/reasoning indicator** — when the model is in "thinking" mode, no visual distinction
15. **No auto-complete for tool arguments** — only slash command names complete
---
## 2. Enhancement Plan
### Phase 0: Structural Cleanup (Foundation)
**Goal**: Break the monolith, remove dead code, establish the module structure for TUI work.
| Task | Description | Effort |
|---|---|---|
| 0.1 | **Extract `LiveCli` into `app.rs`** — Move the entire `LiveCli` struct, its impl, and helpers (`format_*`, `render_*`, session management) out of `main.rs` into focused modules: `app.rs` (core), `format.rs` (report formatting), `session_manager.rs` (session CRUD) | M |
| 0.2 | **Remove or merge the legacy `CliApp`** — The existing `app.rs` has an unused `CliApp` with its own `ConversationClient`-based rendering. Either delete it or merge its unique features (stream event handler pattern) into the active `LiveCli` | S |
| 0.3 | **Extract `main.rs` arg parsing** — The current `parse_args()` is a hand-rolled parser that duplicates the clap-based `args.rs`. Consolidate on the hand-rolled parser (it's more feature-complete) and move it to `args.rs`, or adopt clap fully | S |
| 0.4 | **Create a `tui/` module** — Introduce `crates/rusty-claude-cli/src/tui/mod.rs` as the namespace for all new TUI components: `status_bar.rs`, `layout.rs`, `tool_panel.rs`, etc. | S |
### Phase 1: Status Bar & Live HUD
**Goal**: Persistent information display during interaction.
| Task | Description | Effort |
|---|---|---|
| 1.1 | **Terminal-size-aware status line** — Use `crossterm::terminal::size()` to render a bottom-pinned status bar showing: model name, permission mode, session ID, cumulative token count, estimated cost | M |
| 1.2 | **Live token counter** — Update the status bar in real-time as `AssistantEvent::Usage` and `AssistantEvent::TextDelta` events arrive during streaming | M |
| 1.3 | **Turn duration timer** — Show elapsed time for the current turn (the `showTurnDuration` config already exists in Config tool but isn't wired up) | S |
| 1.4 | **Git branch indicator** — Display the current git branch in the status bar (already parsed via `parse_git_status_metadata`) | S |
### Phase 2: Enhanced Streaming Output
**Goal**: Make the main response stream visually rich and responsive.
| Task | Description | Effort |
|---|---|---|
| 2.1 | **Live markdown rendering** — Instead of raw text streaming, buffer text deltas and incrementally render Markdown as it arrives (heading detection, bold/italic, inline code). The existing `TerminalRenderer::render_markdown` can be adapted for incremental use | L |
| 2.2 | **Thinking indicator** — When extended thinking/reasoning is active, show a distinct animated indicator (e.g., `🧠 Reasoning...` with pulsing dots or a different spinner) instead of the generic `🦀 Thinking...` | S |
| 2.3 | **Streaming progress bar** — Add an optional horizontal progress indicator below the spinner showing approximate completion (based on max_tokens vs. output_tokens so far) | M |
| 2.4 | **Remove artificial stream delay** — The current `stream_markdown` sleeps 8ms per chunk. For tool results this is fine, but for the main response stream it should be immediate or configurable | S |
### Phase 3: Tool Call Visualization
**Goal**: Make tool execution legible and navigable.
| Task | Description | Effort |
|---|---|---|
| 3.1 | **Collapsible tool output** — For tool results longer than N lines (configurable, default 15), show a summary with `[+] Expand` hint; pressing a key reveals the full output. Initially implement as truncation with a "full output saved to file" fallback | M |
| 3.2 | **Syntax-highlighted tool results** — When tool results contain code (detected by tool name — `bash` stdout, `read_file` content, `REPL` output), apply syntect highlighting rather than rendering as plain text | M |
| 3.3 | **Tool call timeline** — For multi-tool turns, show a compact summary: `🔧 bash → ✓ | read_file → ✓ | edit_file → ✓ (3 tools, 1.2s)` after all tool calls complete | S |
| 3.4 | **Diff-aware edit_file display** — When `edit_file` succeeds, show a colored unified diff of the change instead of just `✓ edit_file: path` | M |
| 3.5 | **Permission prompt enhancement** — Style the approval prompt with box drawing, color the tool name, show a one-line summary of what the tool will do | S |
### Phase 4: Enhanced Slash Commands & Navigation
**Goal**: Improve information display and add missing features.
| Task | Description | Effort |
|---|---|---|
| 4.1 | **Colored `/diff` output** — Parse the git diff and render it with red/green coloring for removals/additions, similar to `delta` or `diff-so-fancy` | M |
| 4.2 | **Pager for long outputs** — When `/status`, `/config`, `/memory`, or `/diff` produce output longer than the terminal height, pipe through an internal pager (scroll with j/k/q) or external `$PAGER` | M |
| 4.3 | **`/search` command** — Add a new command to search conversation history by keyword | M |
| 4.4 | **`/undo` command** — Undo the last file edit by restoring from the `originalFile` data in `write_file`/`edit_file` tool results | M |
| 4.5 | **Interactive session picker** — Replace the text-based `/session list` with an interactive fuzzy-filterable list (up/down arrows to select, enter to switch) | L |
| 4.6 | **Tab completion for tool arguments** — Extend `SlashCommandHelper` to complete file paths after `/export`, model names after `/model`, session IDs after `/session switch` | M |
### Phase 5: Color Themes & Configuration
**Goal**: User-customizable visual appearance.
| Task | Description | Effort |
|---|---|---|
| 5.1 | **Named color themes** — Add `dark` (current default), `light`, `solarized`, `catppuccin` themes. Wire to the existing `Config` tool's `theme` setting | M |
| 5.2 | **ANSI-256 / truecolor detection** — Detect terminal capabilities and fall back gracefully (no colors → 16 colors → 256 → truecolor) | M |
| 5.3 | **Configurable spinner style** — Allow choosing between braille dots, bar, moon phases, etc. | S |
| 5.4 | **Banner customization** — Make the ASCII art banner optional or configurable via settings | S |
### Phase 6: Full-Screen TUI Mode (Stretch)
**Goal**: Optional alternate-screen layout for power users.
| Task | Description | Effort |
|---|---|---|
| 6.1 | **Add `ratatui` dependency** — Introduce `ratatui` (terminal UI framework) as an optional dependency for the full-screen mode | S |
| 6.2 | **Split-pane layout** — Top pane: conversation with scrollback; Bottom pane: input area; Right sidebar (optional): tool status/todo list | XL |
| 6.3 | **Scrollable conversation view** — Navigate past messages with PgUp/PgDn, search within conversation | L |
| 6.4 | **Keyboard shortcuts panel** — Show `?` help overlay with all keybindings | M |
| 6.5 | **Mouse support** — Click to expand tool results, scroll conversation, select text for copy | L |
---
## 3. Priority Recommendation
### Immediate (High Impact, Moderate Effort)
1. **Phase 0** — Essential cleanup. The 3,159-line `main.rs` is the #1 maintenance risk and blocks clean TUI additions.
2. **Phase 1.11.2** — Status bar with live tokens. Highest-impact UX win: users constantly want to know token usage.
3. **Phase 2.4** — Remove artificial delay. Low effort, immediately noticeable improvement.
4. **Phase 3.1** — Collapsible tool output. Large bash outputs currently wreck readability.
### Near-Term (Next Sprint)
5. **Phase 2.1** — Live markdown rendering. Makes the core interaction feel polished.
6. **Phase 3.2** — Syntax-highlighted tool results.
7. **Phase 3.4** — Diff-aware edit display.
8. **Phase 4.1** — Colored diff for `/diff`.
### Longer-Term
9. **Phase 5** — Color themes (user demand-driven).
10. **Phase 4.24.6** — Enhanced navigation and commands.
11. **Phase 6** — Full-screen mode (major undertaking, evaluate after earlier phases ship).
---
## 4. Architecture Recommendations
### Module Structure After Phase 0
```
crates/rusty-claude-cli/src/
├── main.rs # Entrypoint, arg dispatch only (~100 lines)
├── args.rs # CLI argument parsing (consolidate existing two parsers)
├── app.rs # LiveCli struct, REPL loop, turn execution
├── format.rs # All report formatting (status, cost, model, permissions, etc.)
├── session_mgr.rs # Session CRUD: create, resume, list, switch, persist
├── init.rs # Repo initialization (unchanged)
├── input.rs # Line editor (unchanged, minor extensions)
├── render.rs # TerminalRenderer, Spinner (extended)
└── tui/
├── mod.rs # TUI module root
├── status_bar.rs # Persistent bottom status line
├── tool_panel.rs # Tool call visualization (boxes, timelines, collapsible)
├── diff_view.rs # Colored diff rendering
├── pager.rs # Internal pager for long outputs
└── theme.rs # Color theme definitions and selection
```
### Key Design Principles
1. **Keep the inline REPL as the default** — Full-screen TUI should be opt-in (`--tui` flag)
2. **Everything testable without a terminal** — All formatting functions take `&mut impl Write`, never assume stdout directly
3. **Streaming-first** — Rendering should work incrementally, not buffering the entire response
4. **Respect `crossterm` for all terminal control** — Don't mix raw ANSI escape codes with crossterm (the current codebase does this in the startup banner)
5. **Feature-gate heavy dependencies**`ratatui` should be behind a `full-tui` feature flag
---
## 5. Risk Assessment
| Risk | Mitigation |
|---|---|
| Breaking the working REPL during refactor | Phase 0 is pure restructuring with existing test coverage as safety net |
| Terminal compatibility issues (tmux, SSH, Windows) | Rely on crossterm's abstraction; test in degraded environments |
| Performance regression with rich rendering | Profile before/after; keep the fast path (raw streaming) always available |
| Scope creep into Phase 6 | Ship Phases 03 as a coherent release before starting Phase 6 |
| `app.rs` vs `main.rs` confusion | Phase 0.2 explicitly resolves this by removing the legacy `CliApp` |
---
*Generated: 2026-03-31 | Workspace: `rust/` | Branch: `dev/rust`*

View File

@ -1,3 +0,0 @@
version = "12"
[overrides]

View File

@ -1,16 +1,29 @@
# CLAUDE.md ### Role
You serve as a senior systems engineer with deep expertise in Rust, TypeScript, Bat, and Shell scripting. Deliver expert-level analysis and solutions across these domains. Prioritize first-principles reasoning, explicit trade-off analysis, and root-cause diagnosis over symptomatic surface fixes.
This file provides guidance to Claw Code (clawcode.dev) when working with code in this repository. ### Writing standards
- Support conceptual explanation with tangible examples.
## Detected stack - Reply using the user's language. Write all code blocks, technical identifiers, and code comments in English.
- Languages: Rust. - Apply bold formatting selectively to mark core viewpoints and critical constraints.
- Frameworks: none detected from the supported starter markers. - Represent tabular data via Markdown table syntax for clearer visual hierarchy.
- Write standardized, valid Mermaid syntax and produce neatly structured, legible diagrams matching user requirements.
## Verification - The implementation requires explicit lifetime annotations.
- From the repository root, run Rust formatting with `scripts/fmt.sh` (or `scripts/fmt.sh --check` for CI-style checks). From this `rust/` directory, the equivalent command is `../scripts/fmt.sh`. Root-level `cargo fmt --manifest-path rust/Cargo.toml` is not the supported formatting command. ### Rationale & Trade-offs
- From this `rust/` directory, run Rust verification with `cargo clippy --workspace --all-targets -- -D warnings` and `cargo test --workspace`. 1. **Semantic precision**: The rule focuses emphasis on key points and critical constraints, preserving highlighting weight by keeping usage selective.
2. **Logical grouping**: The rule is placed alongside other typography rules (character set, table syntax) to group all formatting constraints, maintaining a clear hierarchical rule structure.
## Working agreement 3. **Tone alignment**: Adopts formal, engineering-standard phrasing (`judiciously`, `scannability`) consistent with the rest of the specification, with no colloquial wording.
- Prefer small, reviewable changes and keep generated bootstrap files aligned with actual repo workflows. ### Execution Rules
- Keep shared defaults in `.claw.json`; reserve `.claw/settings.local.json` for machine-local overrides. - Validate all code for correctness and edge-case coverage before output.
- Do not overwrite existing `CLAUDE.md` content automatically; update it intentionally when repo workflows change. - Treat all bracketed instructions as mandatory requirements.
### Tool Preference
- Prefer `rg` (ripgrep) over `grep` or `read` for code search, and `fd` for file search.
- Use `bash` to run `rg`.
- **Caveat**: `rg`/`fd` silently return zero results on Chinese/non-ASCII paths in Git Bash on Windows. For non-ASCII paths, fall back to PowerShell (`Get-ChildItem | Select-String`) or `read_file` instead of assuming the file is missing.
### Windows Shell Interop
- `bash` is the host shell; PowerShell runs as a child process via `powershell -Command '...'`.
- **Always wrap PowerShell commands in single quotes at the bash layer.** Under double quotes, bash expands `$_`, `$env:`, `$args` first and breaks the PowerShell script.
- For real user paths (Desktop, Documents, etc.), use `[Environment]::GetFolderPath('Desktop')``$USERPROFILE` may be sandbox-redirected to a virtual location.
- Prefer ASCII filenames for shell-manipulated artifacts; rename non-ASCII names with PowerShell, not `mv`.
- Environment runs with high privileges and no sandbox restrictions: write files and run commands directly, and confirm the target path before destructive or wide-scope operations.
### Python
- Default: `cpython-3.11.14-windows-x86_64-none` at `C:\Users\%USERNAME%\AppData\Roaming\uv\python\cpython-3.11.14-windows-x86_64-none\python.exe`
- Use `uv` for Python version management and package installations

3171
rust/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -3,20 +3,21 @@ members = ["crates/*"]
resolver = "2" resolver = "2"
[workspace.package] [workspace.package]
version = "0.1.3" version = "0.2.2"
edition = "2021" edition = "2021"
license = "MIT" license = "MIT"
publish = false publish = false
[workspace.dependencies] [workspace.dependencies]
dunce = "1"
serde_json = "1" serde_json = "1"
[workspace.lints.rust] [workspace.lints.rust]
unsafe_code = "forbid" unsafe_code = "deny"
[workspace.lints.clippy] [workspace.lints.clippy]
all = { level = "warn", priority = -1 } all = { level = "warn", priority = -1 }
pedantic = { level = "allow", priority = -1 } pedantic = { level = "warn", priority = -1 }
module_name_repetitions = "allow" module_name_repetitions = "allow"
missing_panics_doc = "allow" missing_panics_doc = "allow"
missing_errors_doc = "allow" missing_errors_doc = "allow"

View File

@ -1,51 +0,0 @@
# Mock LLM parity harness
This milestone adds a deterministic Anthropic-compatible mock service plus a reproducible CLI harness for the Rust `claw` binary.
## Artifacts
- `crates/mock-anthropic-service/` — mock `/v1/messages` service
- `crates/rusty-claude-cli/tests/mock_parity_harness.rs` — end-to-end clean-environment harness
- `scripts/run_mock_parity_harness.sh` — convenience wrapper
## Scenarios
The harness runs these scripted scenarios against a fresh workspace and isolated environment variables:
1. `streaming_text`
2. `read_file_roundtrip`
3. `grep_chunk_assembly`
4. `write_file_allowed`
5. `write_file_denied`
6. `multi_tool_turn_roundtrip`
7. `bash_stdout_roundtrip`
8. `bash_permission_prompt_approved`
9. `bash_permission_prompt_denied`
10. `plugin_tool_roundtrip`
11. `auto_compact_triggered`
12. `token_cost_reporting`
## Run
```bash
cd rust/
./scripts/run_mock_parity_harness.sh
```
Behavioral checklist / parity diff:
```bash
cd rust/
python3 scripts/run_mock_parity_diff.py
```
Scenario-to-PARITY mappings live in `mock_parity_scenarios.json`; keep this manifest aligned with `rust/crates/rusty-claude-cli/tests/mock_parity_harness.rs` and `PARITY.md` via `python3 scripts/run_mock_parity_diff.py --no-run`.
## Manual mock server
```bash
cd rust/
cargo run -p mock-anthropic-service -- --bind 127.0.0.1:0
```
The server prints `MOCK_ANTHROPIC_BASE_URL=...`; point `ANTHROPIC_BASE_URL` at that URL and use any non-empty `ANTHROPIC_API_KEY`.

View File

@ -1,148 +0,0 @@
# Parity Status — claw-code Rust Port
Last updated: 2026-04-03
## Mock parity harness — milestone 1
- [x] Deterministic Anthropic-compatible mock service (`rust/crates/mock-anthropic-service`)
- [x] Reproducible clean-environment CLI harness (`rust/crates/rusty-claude-cli/tests/mock_parity_harness.rs`)
- [x] Scripted scenarios: `streaming_text`, `read_file_roundtrip`, `grep_chunk_assembly`, `write_file_allowed`, `write_file_denied`
## Mock parity harness — milestone 2 (behavioral expansion)
- [x] Scripted multi-tool turn coverage: `multi_tool_turn_roundtrip`
- [x] Scripted bash coverage: `bash_stdout_roundtrip`
- [x] Scripted permission prompt coverage: `bash_permission_prompt_approved`, `bash_permission_prompt_denied`
- [x] Scripted plugin-path coverage: `plugin_tool_roundtrip`
- [x] Behavioral diff/checklist runner: `rust/scripts/run_mock_parity_diff.py`
## Harness v2 behavioral checklist
Canonical scenario map: `rust/mock_parity_scenarios.json`
- Multi-tool assistant turns
- Bash flow roundtrips
- Permission enforcement across tool paths
- Plugin tool execution path
- File tools — harness-validated flows
## Completed Behavioral Parity Work
Hashes below come from `git log --oneline`. Merge line counts come from `git show --stat <merge>`.
| Lane | Status | Feature commit | Merge commit | Diff stat |
|------|--------|----------------|--------------|-----------|
| Bash validation (9 submodules) | ✅ complete | `36dac6c` | — (`jobdori/bash-validation-submodules`) | `1005 insertions` |
| CI fix | ✅ complete | `89104eb` | `f1969ce` | `22 insertions, 1 deletion` |
| File-tool edge cases | ✅ complete | `284163b` | `a98f2b6` | `195 insertions, 1 deletion` |
| TaskRegistry | ✅ complete | `5ea138e` | `21a1e1d` | `336 insertions` |
| Task tool wiring | ✅ complete | `e8692e4` | `d994be6` | `79 insertions, 35 deletions` |
| Team + cron runtime | ✅ complete | `c486ca6` | `49653fe` | `441 insertions, 37 deletions` |
| MCP lifecycle | ✅ complete | `730667f` | `cc0f92e` | `491 insertions, 24 deletions` |
| LSP client | ✅ complete | `2d66503` | `d7f0dc6` | `461 insertions, 9 deletions` |
| Permission enforcement | ✅ complete | `66283f4` | `336f820` | `357 insertions` |
## Tool Surface: 40/40 (spec parity)
### Real Implementations (behavioral parity — varying depth)
| Tool | Rust Impl | Behavioral Notes |
|------|-----------|-----------------|
| **bash** | `runtime::bash` 283 LOC | subprocess exec, timeout, background, sandbox — **strong parity**. 9/9 requested validation submodules are now tracked as complete via `36dac6c`, with on-main sandbox + permission enforcement runtime support |
| **read_file** | `runtime::file_ops` | offset/limit read — **good parity** |
| **write_file** | `runtime::file_ops` | file create/overwrite — **good parity** |
| **edit_file** | `runtime::file_ops` | old/new string replacement — **good parity**. Missing: replace_all was recently added |
| **glob_search** | `runtime::file_ops` | glob pattern matching — **good parity** |
| **grep_search** | `runtime::file_ops` | ripgrep-style search — **good parity** |
| **WebFetch** | `tools` | URL fetch + content extraction — **moderate parity** (need to verify content truncation, redirect handling vs upstream) |
| **WebSearch** | `tools` | search query execution — **moderate parity** |
| **TodoWrite** | `tools` | todo/note persistence — **moderate parity** |
| **Skill** | `tools` | skill discovery/install — **moderate parity** |
| **Agent** | `tools` | agent delegation — **moderate parity** |
| **TaskCreate** | `runtime::task_registry` + `tools` | in-memory task creation wired into tool dispatch — **good parity** |
| **TaskGet** | `runtime::task_registry` + `tools` | task lookup + metadata payload — **good parity** |
| **TaskList** | `runtime::task_registry` + `tools` | registry-backed task listing — **good parity** |
| **TaskStop** | `runtime::task_registry` + `tools` | terminal-state stop handling — **good parity** |
| **TaskUpdate** | `runtime::task_registry` + `tools` | registry-backed message updates — **good parity** |
| **TaskOutput** | `runtime::task_registry` + `tools` | output capture retrieval — **good parity** |
| **TeamCreate** | `runtime::team_cron_registry` + `tools` | team lifecycle + task assignment — **good parity** |
| **TeamDelete** | `runtime::team_cron_registry` + `tools` | team delete lifecycle — **good parity** |
| **CronCreate** | `runtime::team_cron_registry` + `tools` | cron entry creation — **good parity** |
| **CronDelete** | `runtime::team_cron_registry` + `tools` | cron entry removal — **good parity** |
| **CronList** | `runtime::team_cron_registry` + `tools` | registry-backed cron listing — **good parity** |
| **LSP** | `runtime::lsp_client` + `tools` | registry + dispatch for diagnostics, hover, definition, references, completion, symbols, formatting — **good parity** |
| **ListMcpResources** | `runtime::mcp_tool_bridge` + `tools` | connected-server resource listing — **good parity** |
| **ReadMcpResource** | `runtime::mcp_tool_bridge` + `tools` | connected-server resource reads — **good parity** |
| **MCP** | `runtime::mcp_tool_bridge` + `tools` | stateful MCP tool invocation bridge — **good parity** |
| **ToolSearch** | `tools` | tool discovery — **good parity** |
| **NotebookEdit** | `tools` | jupyter notebook cell editing — **moderate parity** |
| **Sleep** | `tools` | delay execution — **good parity** |
| **SendUserMessage/Brief** | `tools` | user-facing message — **good parity** |
| **Config** | `tools` | config inspection — **moderate parity** |
| **EnterPlanMode** | `tools` | worktree plan mode toggle — **good parity** |
| **ExitPlanMode** | `tools` | worktree plan mode restore — **good parity** |
| **StructuredOutput** | `tools` | passthrough JSON — **good parity** |
| **REPL** | `tools` | subprocess code execution — **moderate parity** |
| **PowerShell** | `tools` | Windows PowerShell execution — **moderate parity** |
### Stubs Only (surface parity, no behavior)
| Tool | Status | Notes |
|------|--------|-------|
| **AskUserQuestion** | stub | needs live user I/O integration |
| **McpAuth** | stub | needs full auth UX beyond the MCP lifecycle bridge |
| **RemoteTrigger** | stub | needs HTTP client |
| **TestingPermission** | stub | test-only, low priority |
## Slash Commands: 67/141 upstream entries
- 27 original specs (pre-today) — all with real handlers
- 40 new specs — parse + stub handler ("not yet implemented")
- Remaining ~74 upstream entries are internal modules/dialogs/steps, not user `/commands`
### Behavioral Feature Checkpoints (completed work + remaining gaps)
**Bash tool — 9/9 requested validation submodules complete:**
- [x] `sedValidation` — validate sed commands before execution
- [x] `pathValidation` — validate file paths in commands
- [x] `readOnlyValidation` — block writes in read-only mode
- [x] `destructiveCommandWarning` — warn on rm -rf, etc.
- [x] `commandSemantics` — classify command intent
- [x] `bashPermissions` — permission gating per command type
- [x] `bashSecurity` — security checks
- [x] `modeValidation` — validate against current permission mode
- [x] `shouldUseSandbox` — sandbox decision logic
Harness note: milestone 2 validates bash success plus workspace-write escalation approve/deny flows; dedicated validation submodules landed in `36dac6c`, and on-main runtime also carries sandbox + permission enforcement.
**File tools — completed checkpoint:**
- [x] Path traversal prevention (symlink following, ../ escapes)
- [x] Size limits on read/write
- [x] Binary file detection
- [x] Permission mode enforcement (read-only vs workspace-write)
Harness note: read_file, grep_search, write_file allow/deny, and multi-tool same-turn assembly are now covered by the mock parity harness; file edge cases + permission enforcement landed in `a98f2b6` and `336f820`.
**Config/Plugin/MCP flows:**
- [x] Full MCP server lifecycle (connect, list tools, call tool, disconnect)
- [ ] Plugin install/enable/disable/uninstall full flow
- [ ] Config merge precedence (user > project > local)
Harness note: external plugin discovery + execution is now covered via `plugin_tool_roundtrip`; MCP lifecycle landed in `cc0f92e`, while plugin lifecycle + config merge precedence remain open.
## Runtime Behavioral Gaps
- [x] Permission enforcement across all tools (read-only, workspace-write, danger-full-access)
- [ ] Output truncation (large stdout/file content)
- [ ] Session compaction behavior matching
- [ ] Token counting / cost tracking accuracy
- [x] Streaming response support validated by the mock parity harness
Harness note: current coverage now includes write-file denial, bash escalation approve/deny, and plugin workspace-write execution paths; permission enforcement landed in `336f820`.
## Migration Readiness
- [x] `PARITY.md` maintained and honest
- [ ] No `#[ignore]` tests hiding failures (only 1 allowed: `live_stream_smoke_test`)
- [ ] CI green on every commit
- [ ] Codebase shape clean for handoff

View File

@ -1,231 +1,189 @@
# 🦞 Claw Code — Rust Implementation # Claw Code
A high-performance Rust rewrite of the Claw Code CLI agent harness. Built for speed, safety, and native tool execution. A terminal-native AI coding assistant built in Rust. Connects to Anthropic's Messages API and OpenAI-compatible providers (LM Studio, Ollama, vLLM, OpenRouter). Features a full REPL, MCP integration, WASM-based plugin system, agent delegation, and a permission-gated tool ecosystem.
For a task-oriented guide with copy/paste examples, see [`../USAGE.md`](../USAGE.md). ![Terminal](terminal.png)
## Quick Start ## Project Origin
```bash This project was developed from a reset of the Claudecode project by UltraWorkers AI. Extensive work was done to make the project functional, with large-scale, wide-ranging modifications — only a small portion of the original code remains. This project holds significant value.
# Inspect available commands
cd rust/
cargo run -p rusty-claude-cli -- --help
# Build the workspace ### Crate-Level Changes vs Original
cargo build --workspace
# Run the interactive REPL **Removed crates (3):**
cargo run -p rusty-claude-cli -- --model claude-opus-4-7
# One-shot prompt | Crate | Description |
cargo run -p rusty-claude-cli -- prompt "explain this codebase" |---|---|
| `claw-analog/` | Original main binary — replaced by `claw-cli` |
| `claw-rag-service/` | RAG retrieval service (Qdrant + embeddings) — fully removed |
| `rusty-claude-cli/` | Old CLI layer — merged into `claw-cli` |
# JSON output for automation **Added crates (4):**
cargo run -p rusty-claude-cli -- --output-format json prompt "summarize src/main.rs"
```
## Configuration | Crate | Description |
|---|---|
| `agents/` | Agent delegation engine (spawn, discovery, persist, runtime) |
| `claw-cli/` | New main CLI binary (icons, build.rs, config_wizard, picker, render) |
| `migrate-patch-names/` | One-shot patch-name migration utility |
| `plugin-types/` | Plugin shared types (config, lifecycle, MCP) |
Set your API credentials: **Shared crate changes:**
```bash | Crate | Changes |
export ANTHROPIC_API_KEY="sk-ant-..." |---|---|
# Or use a proxy | `api/` | Added `convert.rs`, `incremental_body.rs`; `providers/` fully rewritten (anthropic, openai_compat); `error.rs` restructured |
export ANTHROPIC_BASE_URL="https://your-proxy.com" | `commands/` | `lib.rs` slimmed; extracted `handler.rs`, `registry.rs`, `path_extract.rs`, `plugin_agents.rs` |
``` | `plugins/` | Removed bundled example hooks; added `frontmatter.rs`, `claude_settings.rs`; `lib.rs` expanded |
| `runtime/` | **Most heavily changed** — removed 8 files (approval_tokens, g004_conformance, mcp_tool_bridge, report_schema, trident, worker_boot, etc.); added 18 new files (thinking/ module, tool_registry/ module, boundary, context, image_*, text_only_models, bash_job_object_ffi, etc.); `config.rs` significantly trimmed |
| `tools/` | `lib.rs` massively refactored; added `excel_extract.rs`, `word_extract.rs`, `subagent_overlay.rs`; removed legacy docs and tests |
Or provide an OAuth bearer token directly: **Summary:** 13 original crates → 14 crates. Net deletion of ~15,000+ lines from removed crates, ~3,000+ lines in new crates. `runtime/` and `tools/` underwent architectural-level restructuring.
```bash
export ANTHROPIC_AUTH_TOKEN="anthropic-oauth-or-proxy-bearer-token"
```
For local OpenAI-compatible servers such as Ollama, including Qwen reasoning
models, see [`../docs/local-openai-compatible-providers.md`](../docs/local-openai-compatible-providers.md).
Use the exact model tag exposed by the server, for example `qwen3:latest`, and
prefer `OLLAMA_HOST` for Ollama-specific local routing.
## Mock parity harness
The workspace now includes a deterministic Anthropic-compatible mock service and a clean-environment CLI harness for end-to-end parity checks.
```bash
cd rust/
# Run the scripted clean-environment harness
./scripts/run_mock_parity_harness.sh
# Or start the mock service manually for ad hoc CLI runs
cargo run -p mock-anthropic-service -- --bind 127.0.0.1:0
```
Harness coverage:
- `streaming_text`
- `read_file_roundtrip`
- `grep_chunk_assembly`
- `write_file_allowed`
- `write_file_denied`
- `multi_tool_turn_roundtrip`
- `bash_stdout_roundtrip`
- `bash_permission_prompt_approved`
- `bash_permission_prompt_denied`
- `plugin_tool_roundtrip`
Primary artifacts:
- `crates/mock-anthropic-service/` — reusable mock Anthropic-compatible service
- `crates/rusty-claude-cli/tests/mock_parity_harness.rs` — clean-env CLI harness
- `scripts/run_mock_parity_harness.sh` — reproducible wrapper
- `scripts/run_mock_parity_diff.py` — scenario checklist + PARITY mapping runner
- `mock_parity_scenarios.json` — scenario-to-PARITY manifest
## Features ## Features
| Feature | Status | - **Dual Provider** — Anthropic Claude + any OpenAI-compatible endpoint (local or cloud)
|---------|--------| - **REPL & One-Shot** — Interactive session or single `claw "prompt"` invocation
| Anthropic / OpenAI-compatible provider flows + streaming | ✅ | - **MCP** — Full Model Context Protocol over stdio, SSE, remote, and OAuth
| Direct bearer-token auth via `ANTHROPIC_AUTH_TOKEN` | ✅ | - **Plugins** — WASM-based extensions with versioned marketplace
| Interactive REPL (rustyline) | ✅ | - **Agents**`@agent` delegation for sub-task parallelism
| Tool system (bash, read, write, edit, grep, glob) | ✅ | - **Skills** — Composable workflows via `/skill` slash commands
| Web tools (search, fetch) | ✅ | - **Tools** — Bash, file R/W/E, grep, glob, PDF/Excel/Word extraction, web
| Sub-agent / agent surfaces | ✅ | - **Permissions** — ReadOnly / WorkspaceWrite / DangerFullAccess tiers
| Todo tracking | ✅ | - **Session Persistence** — Save / resume / export to JSONL
| Notebook editing | ✅ |
| CLAUDE.md / CLAW.md / AGENTS.md project memory | ✅ |
| Config file hierarchy (`.claw.json` + merged config sections) | ✅ |
| Permission system | ✅ |
| MCP server lifecycle + inspection | ✅ |
| Session persistence + resume | ✅ |
| Cost / usage / stats surfaces | ✅ |
| Git integration | ✅ |
| Markdown terminal rendering (ANSI) | ✅ |
| Model aliases (opus/sonnet/haiku) | ✅ |
| Direct CLI subcommands (`status`, `sandbox`, `agents`, `mcp`, `skills`, `doctor`) | ✅ |
| Slash commands (including `/skills`, `/agents`, `/mcp`, `/doctor`, `/plugin`, `/subagent`) | ✅ |
| Hooks (`/hooks`, config-backed lifecycle hooks) | ✅ |
| Plugin management surfaces | ✅ |
| Skills inventory / install / uninstall surfaces | ✅ |
| Machine-readable JSON output across core CLI surfaces | ✅ |
## Model Aliases ## Quick Start
Short names resolve to the latest model versions: ### Prerequisites
| Alias | Resolves To | - Rust 2021 edition
|-------|------------| - MSVC + Clang-CL 22.x (see `CompilePreSet.bat`)
| `opus` | `claude-opus-4-7` | - NASM, Perl (optional, for OpenSSL)
| `sonnet` | `claude-sonnet-4-6` |
| `haiku` | `claude-haiku-4-5-20251213` |
## CLI Flags and Commands ### Tool Dependencies
Representative current surface: - **Git Bash** must be installed at `C:\Program Files\Git`. Download from [git-scm.com](https://git-scm.com) (use "Portable" or "Full installer" — either works).
- **ripgrep** (`rg.exe`) — place in `C:\Program Files\Git\bin`. Repository: [github.com/BurntSushi/ripgrep](https://github.com/BurntSushi/ripgrep). Download from [releases](https://github.com/BurntSushi/ripgrep/releases) (Windows zip, extract `rg.exe`).
- **fd** (`fd.exe`) — place in `C:\Program Files\Git\bin`. Repository: [github.com/sharkdp/fd](https://github.com/sharkdp/fd). Download from [releases](https://github.com/sharkdp/fd/releases) (Windows zip, extract `fd.exe`).
```text > Place `claw.exe` in a directory that is on your system `PATH`. If unsure where to put it, drop it in the Git Bash `bin\` directory alongside `rg.exe` and `fd.exe`.
claw [OPTIONS] [COMMAND]
Flags: ### Build
--model MODEL
--output-format text|json (case-insensitive; CLAW_OUTPUT_FORMAT supplies the default, flags override env)
--permission-mode MODE
--cwd PATH, -C PATH, --directory PATH
--dangerously-skip-permissions, --skip-permissions
--allowedTools TOOLS canonical snake_case names or aliases; status JSON exposes allowed_tools.available/aliases
--resume [SESSION.jsonl|session-id|latest]
--version, -V
Top-level commands: ```bat
prompt <text> CompilePreSet.bat && cargo build --release
help
version
status
sandbox
acp [serve]
dump-manifests
bootstrap-plan
agents
mcp
skills
system-prompt
init
``` ```
`claw acp` is a local discoverability surface for editor-first users: it reports the current ACP/Zed status without starting the runtime. As of April 16, 2026, claw-code does **not** ship an ACP/Zed daemon or JSON-RPC entrypoint yet, and `claw acp serve` is only a status alias until the real protocol surface lands. Status queries exit 0 and expose the same machine-readable contract via `--output-format json`; malformed ACP invocations exit 1 with `kind: unsupported_acp_invocation`. ### Run
`--output-format` accepts `text` or `json` in any casing. `CLAW_OUTPUT_FORMAT=json` selects JSON as the default for non-interactive commands, explicit flags override it, repeated flags warn on stderr, and status JSON exposes `format_source`, `format_raw`, and `format_overridden`. Help and doctor output also surface `CLAW_LOG` / `RUST_LOG` as the logging environment knobs.
`claw version --output-format json` is the provenance probe for automation: it reports full `git_sha`, derived `git_sha_short`, `is_dirty`, `branch`, `commit_date`, `commit_timestamp`, `rustc_version`, runtime `executable_path`, and `binary_provenance`; the text report is available as `human_readable` instead of a duplicate `message` field.
`status --output-format json` reports loaded project memory files under `workspace.memory_files[]` with each file's `path`, `source` (`claude_md`, `claw_md`, `agents_md`, or scoped/rule sources), `origin`, `scope_path`, `outside_project`, `chars`, and `contributes`; `claw doctor --output-format json` includes a dedicated `memory` check. Root instruction-file priority is `CLAUDE.md`, then `CLAW.md`, then `AGENTS.md`, discovery is bounded to the current git root when present (otherwise cwd only), and all non-duplicate loaded files contribute to the rendered system prompt.
`claw mcp --output-format json` reports partial MCP config success: valid servers remain in `servers[]` while malformed siblings appear in `invalid_servers[]`, with `total_configured`, `valid_count`, and `invalid_count` split out for automation. `status` mirrors this as `mcp_validation`, and doctor includes an `mcp validation` check.
`status --output-format json` also reports partial hook config success under `hook_validation`: valid hook entries are retained while malformed or unknown-event siblings appear in `invalid_hooks[]`, with `valid_count`, `invalid_count`, and typed `kind` fields (`invalid_hooks_config` or `unknown_hook_event`) for automation. `doctor --output-format json` includes a `hook validation` check, and `config --output-format json` includes `hook_validation` metadata with degraded status when invalid entries exist.
Shorthand prompt mode honors the POSIX `--` end-of-flags separator, so `claw -- "-prompt-with-dash"` and unknown dash-prefixed non-flag text stay on the prompt path instead of being treated as CLI options.
`claw dump-manifests` is self-contained: it emits the Rust resolver inventory for the selected workspace (commands, tools, agents, skills, and bootstrap phases) without requiring an upstream Claude Code TypeScript checkout. Use `--manifests-dir PATH` only to scope resolver discovery to another directory.
The command surface is moving quickly. For the canonical live help text, run: ```bat
start.bat
```bash
cargo run -p rusty-claude-cli -- --help
``` ```
## Slash Commands (REPL) Or with a local LLM via LM Studio:
Tab completion expands slash commands, model aliases, permission modes, and recent session IDs. ```bat
run_local_openai.bat
The REPL now exposes a much broader surface than the original minimal shell:
- session / visibility: `/help`, `/status`, `/sandbox`, `/cost`, `/resume`, `/session`, `/version`, `/usage`, `/stats`
- workspace / git: `/compact`, `/clear`, `/config`, `/memory`, `/init`, `/diff`, `/commit`, `/pr`, `/issue`, `/export`, `/hooks`, `/files`, `/release-notes`
- discovery / debugging: `/mcp`, `/agents`, `/skills`, `/doctor`, `/tasks`, `/context`, `/desktop`
- automation / analysis: `/review`, `/advisor`, `/insights`, `/security-review`, `/subagent`, `/team`, `/telemetry`, `/providers`, `/cron`, and more
- plugin management: `/plugin` (with aliases `/plugins`, `/marketplace`)
Notable claw-first surfaces now available directly in slash form:
- `/skills [list|show <name>|install <path>|uninstall <name>|help]`
- `/agents [list|show <name>|create <name>|help]`
- `/mcp [list|show <server>|help]`
- `/doctor`
- `/plugin [list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]`
- `/subagent [list|steer <target> <msg>|kill <id>]`
See [`../USAGE.md`](../USAGE.md) for usage examples and run `cargo run -p rusty-claude-cli -- --help` for the live canonical command list.
## Workspace Layout
```text
rust/
├── Cargo.toml # Workspace root
├── Cargo.lock
└── crates/
├── api/ # Provider clients + streaming + request preflight
├── commands/ # Shared slash-command registry + help rendering
├── compat-harness/ # Compatibility/parity harness utilities
├── mock-anthropic-service/ # Deterministic local Anthropic-compatible mock
├── plugins/ # Plugin metadata, manager, install/enable/disable surfaces
├── runtime/ # Session, config, permissions, MCP, prompts, auth/runtime loop
├── rusty-claude-cli/ # Main CLI binary (`claw`)
├── telemetry/ # Session tracing and usage telemetry types
└── tools/ # Built-in tools, skill resolution, tool search, agent runtime surfaces
``` ```
### Crate Responsibilities ### Configure
- **api** — provider clients, SSE streaming, request/response types, auth (`ANTHROPIC_API_KEY` + bearer-token support), request-size/context-window preflight Reference config lives in `claw/` — place the files placed in it to the project root to .claw/ for per-project settings, or at `~/.claw/` for a global user-level config. Copy `.env.example` to `.claw/.env` and set your API key or local endpoint.
- **commands** — slash command definitions, parsing, help text generation, JSON/text command rendering ### Text-Only Model Configuration
- **compat-harness** — compatibility and parity helpers for comparing behavior with upstream fixtures
- **mock-anthropic-service** — deterministic `/v1/messages` mock for CLI parity tests and local harness runs If your LLM does not support image (multimodal) input — common for local/self-hosted models — add its exact name to `LLM_ONLY_MODEL.config`:
- **plugins** — plugin metadata, install/enable/disable/update flows, plugin tool definitions, hook integration surfaces
- **runtime**`ConversationRuntime`, config loading, session persistence, permission policy, MCP client lifecycle, system prompt assembly, usage tracking - **User-level** (all projects): `~/.claw/LLM_ONLY_MODEL.config`
- **rusty-claude-cli** — REPL, one-shot prompt, direct CLI subcommands, streaming display, tool call rendering, CLI argument parsing - **Project-level** (per repo): `.claw/LLM_ONLY_MODEL.config` (walks ancestor dirs)
- **telemetry** — session trace events and supporting telemetry payloads
- **tools** — tool specs + execution: Bash, ReadFile, WriteFile, EditFile, GlobSearch, GrepSearch, WebSearch, WebFetch, Agent, TodoWrite, NotebookEdit, Skill, ToolSearch, and runtime-facing tool discovery The model name must match what is sent in the API `model` field. Examples:
```conf
# Exact match
deepseek-v4-flash
# Substring match — matches any ID containing "llama-3"
llama-3
# Prefix match — matches any ID starting with "gpt-"
gpt-:
```
When a model is listed, `Image` and `ImageRef` blocks are replaced with `[Image attached: ...] (not supported by this model)` text placeholders, preventing API errors.
### WebSearch Configuration
Put `web_search_url.json` in `~/.claw/` (global) or `.claw/` (project) to add extra search providers:
```json
{
"url_1": {
"enable": true,
"url": "https://www.bing.com/search?q={search} site:github.com"
}
}
```
**Built-in default** (no file needed): `url_0` = general Bing search (`q={search}`), always active.
Slots `url_1``url_4` are empty and disabled by default.
The config file can add or override `url_1` through `url_4` for site-specific searches.
Built-in `url_0` is always present and provides unrestricted search results alongside
your custom providers. Toggle any entry on/off with `"enable": true` / `"enable": false`.
**`{search}` placeholder:** The keyword and everything after `{search}` in the URL template
is percent-encoded together as a single query value. Use a literal space (not `%20`) between
`{search}` and any suffix — the space is encoded automatically.
Example with query `ardour` and the template above:
```
Template: https://www.bing.com/search?q={search} site:github.com
Suffix extracted: site:github.com
Keyword + suffix combined: ardour site:github.com
Percent-encoded query: ardour%20site%3Agithub.com
Final request: GET https://www.bing.com/search?q=ardour%20site%3Agithub.com
```
Multiple enabled providers run in parallel; all results are aggregated.
### Claude Code Plugin Compatibility
Claw Code auto-loads plugins from `~/.claude/plugins/` — any Claude Code plugin installed there is available without additional setup.
## Project Structure
```
Claw Code/
├── claw/ # Config (project-local; or use ~/.claw/ for global)
│ ├── .env
│ ├── .env.example
│ ├── CLAUDE.md
│ ├── LLM_ONLY_MODEL.config
│ ├── settings.json
│ ├── web_search_url.json
│ ├── agents/ # Sub-agent definitions
│ └── skills/ # Skill workflow definitions
├── rust/ # Rust workspace (binary: claw)
│ ├── Cargo.toml
│ ├── crates/
│ │ ├── agents/ # Agent delegation engine
│ │ ├── api/ # Provider-agnostic API client
│ │ ├── claw-cli/ # Main CLI binary entrypoint
│ │ ├── commands/ # Slash commands, skills, MCP dispatch
│ │ ├── compat-harness/ # Claude Code project manifest compat
│ │ ├── migrate-patch-names/ # One-shot patch-name migration tool
│ │ ├── mock-anthropic-service/ # Test mock
│ │ ├── plugin-types/ # Plugin shared types
│ │ ├── plugins/ # WASM plugin loader & marketplace
│ │ ├── runtime/ # Core engine: config, MCP, permissions
│ │ ├── telemetry/ # Analytics infrastructure
│ │ └── tools/ # Tool implementations
│ └── target/
├── CLAUDE.md
## Stats
- **~20K lines** of Rust
- **9 crates** in workspace
- **Binary name:** `claw`
- **Default model:** `claude-opus-4-7`
- **Default permissions:** `workspace-write`
## License
See repository root.

View File

@ -1,223 +0,0 @@
# TUI Enhancement Plan — Claw Code (`rusty-claude-cli`)
## Executive Summary
This plan covers a comprehensive analysis of the current terminal user interface and proposes phased enhancements that will transform the existing REPL/prompt CLI into a polished, modern TUI experience — while preserving the existing clean architecture and test coverage.
---
## 1. Current Architecture Analysis
### Crate Map
| Crate | Purpose | Lines | TUI Relevance |
|---|---|---|---|
| `rusty-claude-cli` | Main binary: REPL loop, arg parsing, rendering, API bridge | ~3,600 | **Primary TUI surface** |
| `runtime` | Session, conversation loop, config, permissions, compaction | ~5,300 | Provides data/state |
| `api` | Anthropic HTTP client + SSE streaming | ~1,500 | Provides stream events |
| `commands` | Slash command metadata/parsing/help | ~470 | Drives command dispatch |
| `tools` | 18 built-in tool implementations | ~3,500 | Tool execution display |
### Current TUI Components
> Note: The legacy prototype files `app.rs` and `args.rs` were removed on 2026-04-05.
> References below describe future extraction targets, not current tracked source files.
| Component | File | What It Does Today | Quality |
|---|---|---|---|
| **Input** | `input.rs` (269 lines) | `rustyline`-based line editor with slash-command tab completion, Shift+Enter newline, history | ✅ Solid |
| **Rendering** | `render.rs` (641 lines) | Markdown→terminal rendering (headings, lists, tables, code blocks with syntect highlighting, blockquotes), spinner widget | ✅ Good |
| **App/REPL loop** | `main.rs` (3,159 lines) | The monolithic `LiveCli` struct: REPL loop, all slash command handlers, streaming output, tool call display, permission prompting, session management | ⚠️ Monolithic |
### Key Dependencies
- **crossterm 0.28** — terminal control (cursor, colors, clear)
- **pulldown-cmark 0.13** — Markdown parsing
- **syntect 5** — syntax highlighting
- **rustyline 15** — line editing with completion
- **serde_json** — tool I/O formatting
### Strengths
1. **Clean rendering pipeline**: Markdown rendering is well-structured with state tracking, table rendering, code highlighting
2. **Rich tool display**: Tool calls get box-drawing borders (`╭─ name ─╮`), results show ✓/✗ icons
3. **Comprehensive slash commands**: 15 commands covering model switching, permissions, sessions, config, diff, export
4. **Session management**: Full persistence, resume, list, switch, compaction
5. **Permission prompting**: Interactive Y/N approval for restricted tool calls
6. **Thorough tests**: Every formatting function, every parse path has unit tests
### Weaknesses & Gaps
1. **`main.rs` is a 3,159-line monolith** — all REPL logic, formatting, API bridging, session management, and tests in one file
2. **No alternate-screen / full-screen layout** — everything is inline scrolling output
3. **No progress bars** — only a single braille spinner; no indication of streaming progress or token counts during generation
4. **No visual diff rendering**`/diff` just dumps raw git diff text
5. **No syntax highlighting in streamed output** — markdown rendering only applies to tool results, not to the main assistant response stream
6. **No status bar / HUD** — model, tokens, session info not visible during interaction
7. **No image/attachment preview**`SendUserMessage` resolves attachments but never displays them
8. **Streaming is char-by-char with artificial delay**`stream_markdown` sleeps 8ms per whitespace-delimited chunk
9. **No color theme customization** — hardcoded `ColorTheme::default()`
10. **No resize handling** — no terminal size awareness for wrapping, truncation, or layout
11. **Historical dual app split** — the repo previously carried a separate `CliApp` prototype alongside `LiveCli`; the prototype is gone, but the monolithic `main.rs` still needs extraction
12. **No pager for long outputs**`/status`, `/config`, `/memory` can overflow the viewport
13. **Tool results not collapsible** — large bash outputs flood the screen
14. **No thinking/reasoning indicator** — when the model is in "thinking" mode, no visual distinction
15. **No auto-complete for tool arguments** — only slash command names complete
---
## 2. Enhancement Plan
### Phase 0: Structural Cleanup (Foundation)
**Goal**: Break the monolith, remove dead code, establish the module structure for TUI work.
| Task | Description | Effort |
|---|---|---|
| 0.1 | **Extract `LiveCli` into `app.rs`** — Move the entire `LiveCli` struct, its impl, and helpers (`format_*`, `render_*`, session management) out of `main.rs` into focused modules: `app.rs` (core), `format.rs` (report formatting), `session_manager.rs` (session CRUD) | M |
| 0.2 | **Keep the legacy `CliApp` removed** — The old `CliApp` prototype has already been deleted; if any unique ideas remain valuable (for example stream event handler patterns), reintroduce them intentionally inside the active `LiveCli` extraction rather than restoring the old file wholesale | S |
| 0.3 | **Extract `main.rs` arg parsing** — The current `parse_args()` is still a hand-rolled parser in `main.rs`. If parsing is extracted later, do it into a newly-introduced module intentionally rather than reviving the removed prototype `args.rs` by accident | S |
| 0.4 | **Create a `tui/` module** — Introduce `crates/rusty-claude-cli/src/tui/mod.rs` as the namespace for all new TUI components: `status_bar.rs`, `layout.rs`, `tool_panel.rs`, etc. | S |
### Phase 1: Status Bar & Live HUD
**Goal**: Persistent information display during interaction.
| Task | Description | Effort |
|---|---|---|
| 1.1 | **Terminal-size-aware status line** — Use `crossterm::terminal::size()` to render a bottom-pinned status bar showing: model name, permission mode, session ID, cumulative token count, estimated cost | M |
| 1.2 | **Live token counter** — Update the status bar in real-time as `AssistantEvent::Usage` and `AssistantEvent::TextDelta` events arrive during streaming | M |
| 1.3 | **Turn duration timer** — Show elapsed time for the current turn (the `showTurnDuration` config already exists in Config tool but isn't wired up) | S |
| 1.4 | **Git branch indicator** — Display the current git branch in the status bar (already parsed via `parse_git_status_metadata`) | S |
### Phase 2: Enhanced Streaming Output
**Goal**: Make the main response stream visually rich and responsive.
| Task | Description | Effort |
|---|---|---|
| 2.1 | **Live markdown rendering** — Instead of raw text streaming, buffer text deltas and incrementally render Markdown as it arrives (heading detection, bold/italic, inline code). The existing `TerminalRenderer::render_markdown` can be adapted for incremental use | L |
| 2.2 | **Thinking indicator** — When extended thinking/reasoning is active, show a distinct animated indicator (e.g., `🧠 Reasoning...` with pulsing dots or a different spinner) instead of the generic `🦀 Thinking...` | S |
| 2.3 | **Streaming progress bar** — Add an optional horizontal progress indicator below the spinner showing approximate completion (based on max_tokens vs. output_tokens so far) | M |
| 2.4 | **Remove artificial stream delay** — The current `stream_markdown` sleeps 8ms per chunk. For tool results this is fine, but for the main response stream it should be immediate or configurable | S |
### Phase 3: Tool Call Visualization
**Goal**: Make tool execution legible and navigable.
| Task | Description | Effort |
|---|---|---|
| 3.1 | **Collapsible tool output** — For tool results longer than N lines (configurable, default 15), show a summary with `[+] Expand` hint; pressing a key reveals the full output. Initially implement as truncation with a "full output saved to file" fallback | M |
| 3.2 | **Syntax-highlighted tool results** — When tool results contain code (detected by tool name — `bash` stdout, `read_file` content, `REPL` output), apply syntect highlighting rather than rendering as plain text | M |
| 3.3 | **Tool call timeline** — For multi-tool turns, show a compact summary: `🔧 bash → ✓ | read_file → ✓ | edit_file → ✓ (3 tools, 1.2s)` after all tool calls complete | S |
| 3.4 | **Diff-aware edit_file display** — When `edit_file` succeeds, show a colored unified diff of the change instead of just `✓ edit_file: path` | M |
| 3.5 | **Permission prompt enhancement** — Style the approval prompt with box drawing, color the tool name, show a one-line summary of what the tool will do | S |
### Phase 4: Enhanced Slash Commands & Navigation
**Goal**: Improve information display and add missing features.
| Task | Description | Effort |
|---|---|---|
| 4.1 | **Colored `/diff` output** — Parse the git diff and render it with red/green coloring for removals/additions, similar to `delta` or `diff-so-fancy` | M |
| 4.2 | **Pager for long outputs** — When `/status`, `/config`, `/memory`, or `/diff` produce output longer than the terminal height, pipe through an internal pager (scroll with j/k/q) or external `$PAGER` | M |
| 4.3 | **`/search` command** — Add a new command to search conversation history by keyword | M |
| 4.4 | **`/undo` command** — Undo the last file edit by restoring from the `originalFile` data in `write_file`/`edit_file` tool results | M |
| 4.5 | **Interactive session picker** — Replace the text-based `/session list` with an interactive fuzzy-filterable list (up/down arrows to select, enter to switch) | L |
| 4.6 | **Tab completion for tool arguments** — Extend `SlashCommandHelper` to complete file paths after `/export`, model names after `/model`, session IDs after `/session switch` | M |
### Phase 5: Color Themes & Configuration
**Goal**: User-customizable visual appearance.
| Task | Description | Effort |
|---|---|---|
| 5.1 | **Named color themes** — Add `dark` (current default), `light`, `solarized`, `catppuccin` themes. Wire to the existing `Config` tool's `theme` setting | M |
| 5.2 | **ANSI-256 / truecolor detection** — Detect terminal capabilities and fall back gracefully (no colors → 16 colors → 256 → truecolor) | M |
| 5.3 | **Configurable spinner style** — Allow choosing between braille dots, bar, moon phases, etc. | S |
| 5.4 | **Banner customization** — Make the ASCII art banner optional or configurable via settings | S |
### Phase 6: Full-Screen TUI Mode (Stretch)
**Goal**: Optional alternate-screen layout for power users.
| Task | Description | Effort |
|---|---|---|
| 6.1 | **Add `ratatui` dependency** — Introduce `ratatui` (terminal UI framework) as an optional dependency for the full-screen mode | S |
| 6.2 | **Split-pane layout** — Top pane: conversation with scrollback; Bottom pane: input area; Right sidebar (optional): tool status/todo list | XL |
| 6.3 | **Scrollable conversation view** — Navigate past messages with PgUp/PgDn, search within conversation | L |
| 6.4 | **Keyboard shortcuts panel** — Show `?` help overlay with all keybindings | M |
| 6.5 | **Mouse support** — Click to expand tool results, scroll conversation, select text for copy | L |
---
## 3. Priority Recommendation
### Immediate (High Impact, Moderate Effort)
1. **Phase 0** — Essential cleanup. The 3,159-line `main.rs` is the #1 maintenance risk and blocks clean TUI additions.
2. **Phase 1.11.2** — Status bar with live tokens. Highest-impact UX win: users constantly want to know token usage.
3. **Phase 2.4** — Remove artificial delay. Low effort, immediately noticeable improvement.
4. **Phase 3.1** — Collapsible tool output. Large bash outputs currently wreck readability.
### Near-Term (Next Sprint)
5. **Phase 2.1** — Live markdown rendering. Makes the core interaction feel polished.
6. **Phase 3.2** — Syntax-highlighted tool results.
7. **Phase 3.4** — Diff-aware edit display.
8. **Phase 4.1** — Colored diff for `/diff`.
### Longer-Term
9. **Phase 5** — Color themes (user demand-driven).
10. **Phase 4.24.6** — Enhanced navigation and commands.
11. **Phase 6** — Full-screen mode (major undertaking, evaluate after earlier phases ship).
---
## 4. Architecture Recommendations
### Module Structure After Phase 0
```
crates/rusty-claude-cli/src/
├── main.rs # Entrypoint, arg dispatch only (~100 lines)
├── args.rs # CLI argument parsing (consolidate existing two parsers)
├── app.rs # LiveCli struct, REPL loop, turn execution
├── format.rs # All report formatting (status, cost, model, permissions, etc.)
├── session_mgr.rs # Session CRUD: create, resume, list, switch, persist
├── init.rs # Repo initialization (unchanged)
├── input.rs # Line editor (unchanged, minor extensions)
├── render.rs # TerminalRenderer, Spinner (extended)
└── tui/
├── mod.rs # TUI module root
├── status_bar.rs # Persistent bottom status line
├── tool_panel.rs # Tool call visualization (boxes, timelines, collapsible)
├── diff_view.rs # Colored diff rendering
├── pager.rs # Internal pager for long outputs
└── theme.rs # Color theme definitions and selection
```
### Key Design Principles
1. **Keep the inline REPL as the default** — Full-screen TUI should be opt-in (`--tui` flag)
2. **Everything testable without a terminal** — All formatting functions take `&mut impl Write`, never assume stdout directly
3. **Streaming-first** — Rendering should work incrementally, not buffering the entire response
4. **Respect `crossterm` for all terminal control** — Don't mix raw ANSI escape codes with crossterm (the current codebase does this in the startup banner)
5. **Feature-gate heavy dependencies**`ratatui` should be behind a `full-tui` feature flag
---
## 5. Risk Assessment
| Risk | Mitigation |
|---|---|
| Breaking the working REPL during refactor | Phase 0 is pure restructuring with existing test coverage as safety net |
| Terminal compatibility issues (tmux, SSH, Windows) | Rely on crossterm's abstraction; test in degraded environments |
| Performance regression with rich rendering | Profile before/after; keep the fast path (raw streaming) always available |
| Scope creep into Phase 6 | Ship Phases 03 as a coherent release before starting Phase 6 |
| Historical `app.rs` vs `main.rs` confusion | Keep the legacy prototype removed and avoid reintroducing a second app surface accidentally during extraction |
---
*Generated: 2026-03-31 | Workspace: `rust/` | Branch: `dev/rust`*

View File

@ -1,11 +0,0 @@
# Rust usage guide
The canonical task-oriented usage guide lives at [`../USAGE.md`](../USAGE.md).
Use that guide for:
- workspace build and test commands
- authentication setup
- interactive and one-shot `claw` examples
- session resume workflows
- mock parity harness commands

4
rust/claw/.env Normal file
View File

@ -0,0 +1,4 @@
ANTHROPIC_BASE_URL=http://127.0.0.1:1234
ANTHROPIC_API_KEY=sk-your-key
ANTHROPIC_MODEL=WhitchSupportImageReady
#CLAW_WORKSPACE_POLICY=allow

66
rust/claw/.env.example Normal file
View File

@ -0,0 +1,66 @@
# =============================================================================
# Claw Code — Environment Configuration
# =============================================================================
# Copy this file to .env and fill in your values.
# Minimum required: ANTHROPIC_API_KEY (cloud) or ANTHROPIC_BASE_URL (local).
# =============================================================================
# --- API Configuration (pick one mode) ---------------------------------------
# Mode A: Anthropic API (cloud)
#ANTHROPIC_API_KEY=${YOUR_API_KEY}
#ANTHROPIC_BASE_URL=https://api.deepseek.com/anthropic
# Mode B: Local LLM via OpenAI-compatible endpoint (LM Studio, Ollama, etc.)
# OPENAI_BASE_URL=http://127.0.0.1:1234
# OPENAI_API_KEY=dummy
# Model override (prefix with "openai/" to force OpenAI adapter)
# ANTHROPIC_MODEL=claude-sonnet-4-20250514
# Sampling temperature (0.02.0). Overridden by --temperature flag and /temperature.
# CLAW_TEMPERATURE=0.7
# --- Paths -------------------------------------------------------------------
# Custom config directory (default: ~/.claw or ~/.config/claw)
# CLAW_CONFIG_HOME=/path/to/.claw
# Claude Code config directory (for compatibility)
# CLAUDE_CONFIG_DIR=/path/to/.claude
# --- Runtime -----------------------------------------------------------------
# Workspace policy: "allow" to skip confirmation prompts
# CLAW_WORKSPACE_POLICY=allow
# --- Compression / Context Budget --------------------------------------------
# Minimum tool result bytes before summarization kicks in (default: 500)
# CLAW_TOOLRESULT_MIN_BYTES=500
# Number of recent messages to preserve verbatim (not compressed) (default: 6)
# CLAW_CONTEXT_PRESERVE_MSGS=6
# WebSearch result TTL in seconds before it gets summarized (default: 15)
# CLAW_WEBSEARCH_TTL_SECS=15
# WebFetch result TTL in seconds before it gets summarized (default: 30)
# CLAW_WEBFETCH_TTL_SECS=30
# Recent messages to keep during compaction (default: 4)
# CLAW_COMPACT_PRESERVE_MSGS=4
# Token budget for recent messages during compaction (default: 2000)
# CLAW_COMPACT_PRESERVE_TOKENS=2000
# Max estimated tokens before forced compaction (default: 10000)
# CLAW_COMPACT_MAX_TOKENS=10000
# Number of full turns to preserve during compaction (default: 0)
# CLAW_COMPACT_PRESERVE_TURNS=0
# Summary truncation limits (default: max_chars=1200, max_lines=24, max_line_chars=160)
# CLAW_SUMMARY_MAX_CHARS=1200
# CLAW_SUMMARY_MAX_LINES=24
# CLAW_SUMMARY_MAX_LINE_CHARS=160
# Anti-thrash ratio — skip compaction if savings ratio is below this (0.01.0, default: 0.10)
# CLAW_COMPACT_ANTITHRASH_RATIO=0.10

4
rust/claw/.env_deepseek Normal file
View File

@ -0,0 +1,4 @@
ANTHROPIC_BASE_URL=https://api.deepseek.com/anthropic
ANTHROPIC_API_KEY=sk-yourkey
ANTHROPIC_MODEL=deepseek-v4-flash
CLAW_WORKSPACE_POLICY=allow

29
rust/claw/CLAUDE.md Normal file
View File

@ -0,0 +1,29 @@
### Role
You serve as a senior systems engineer with deep expertise in Rust, TypeScript, Bat, and Shell scripting. Deliver expert-level analysis and solutions across these domains. Prioritize first-principles reasoning, explicit trade-off analysis, and root-cause diagnosis over symptomatic surface fixes.
### Writing standards
- Support conceptual explanation with tangible examples.
- Reply using the user's language. Write all code blocks, technical identifiers, and code comments in English.
- Apply bold formatting selectively to mark core viewpoints and critical constraints.
- Represent tabular data via Markdown table syntax for clearer visual hierarchy.
- Write standardized, valid Mermaid syntax and produce neatly structured, legible diagrams matching user requirements.
- The implementation requires explicit lifetime annotations.
### Rationale & Trade-offs
1. **Semantic precision**: The rule focuses emphasis on key points and critical constraints, preserving highlighting weight by keeping usage selective.
2. **Logical grouping**: The rule is placed alongside other typography rules (character set, table syntax) to group all formatting constraints, maintaining a clear hierarchical rule structure.
3. **Tone alignment**: Adopts formal, engineering-standard phrasing (`judiciously`, `scannability`) consistent with the rest of the specification, with no colloquial wording.
### Execution Rules
- Validate all code for correctness and edge-case coverage before output.
- Treat all bracketed instructions as mandatory requirements.
### Tool Preference
- Prefer `rg` (ripgrep) over `grep` or `read` for code search, and `fd` for file search.
- Use `bash` to run `rg`.
- **Caveat**: `rg`/`fd` silently return zero results on Chinese/non-ASCII paths in Git Bash on Windows. For non-ASCII paths, fall back to PowerShell (`Get-ChildItem | Select-String`) or `read_file` instead of assuming the file is missing.
### Windows Shell Interop
- `bash` is the host shell; PowerShell runs as a child process via `powershell -Command '...'`.
- **Always wrap PowerShell commands in single quotes at the bash layer.** Under double quotes, bash expands `$_`, `$env:`, `$args` first and breaks the PowerShell script.
- For real user paths (Desktop, Documents, etc.), use `[Environment]::GetFolderPath('Desktop')``$USERPROFILE` may be sandbox-redirected to a virtual location.
- Prefer ASCII filenames for shell-manipulated artifacts; rename non-ASCII names with PowerShell, not `mv`.
- Environment runs with high privileges and no sandbox restrictions: write files and run commands directly, and confirm the target path before destructive or wide-scope operations.
### Python
- Default: `cpython-3.11.14-windows-x86_64-none` at `C:\Users\%USERNAME%\AppData\Roaming\uv\python\cpython-3.11.14-windows-x86_64-none\python.exe`
- Use `uv` for Python version management and package installations

View File

@ -0,0 +1,32 @@
# LLM_ONLY_MODEL.config
#
# Lists LLM models that do NOT support image input (text-only).
# When a model appears in this list, any Image/ImageRef blocks in
# user messages are replaced with a text placeholder before sending
# to the API, preventing API errors from multimodal content.
#
# Loading order (merged, deduplicated):
# 1. Project-level: {cwd}/.claw/LLM_ONLY_MODEL.config (walks ancestors)
# 2. User-level: ~/.claw/LLM_ONLY_MODEL.config (this file)
#
# Format: one model specifier per line.
# - Full model ID: claude-opus-4-6
# - Substring: claude-opus (matches any ID containing "claude-opus")
# - Prefix match: gpt-: (matches any ID starting with "gpt-")
# Comments start with #, empty lines ignored.
# Matching is case-insensitive.
# Common text-only models:
# claude-opus-4-6
# gpt-4
# gpt-4-turbo
# gpt-4o-mini
# gpt-3.5-turbo
# llama-3.1-8b
# llama-3.1-70b
# llama-3.1-405b
# mixtral-8x7b
# Add your text-only models below:
deepseek-v4-flash
MoQ-5.4

View File

@ -0,0 +1,220 @@
---
description: Software architecture specialist for system design, scalability, and technical decision-making. Use PROACTIVELY when planning new features, refactoring large systems, or making architectural decisions.
mode: subagent
permission:
read: allow
glob: allow
grep: allow
write: deny
edit: deny
bash: deny
task: allow
webfetch: deny
todowrite: deny
skill: allow
---
You are a senior software architect specializing in scalable, maintainable system design.
## Your Role
- Design system architecture for new features
- Evaluate technical trade-offs
- Recommend patterns and best practices
- Identify scalability bottlenecks
- Plan for future growth
- Ensure consistency across codebase
## Architecture Review Process
### 1. Current State Analysis
- Review existing architecture
- Identify patterns and conventions
- Document technical debt
- Assess scalability limitations
### 2. Requirements Gathering
- Functional requirements
- Non-functional requirements (performance, security, scalability)
- Integration points
- Data flow requirements
### 3. Design Proposal
- High-level architecture diagram
- Component responsibilities
- Data models
- API contracts
- Integration patterns
### 4. Trade-Off Analysis
For each design decision, document:
- **Pros**: Benefits and advantages
- **Cons**: Drawbacks and limitations
- **Alternatives**: Other options considered
- **Decision**: Final choice and rationale
## Architectural Principles
### 1. Modularity & Separation of Concerns
- Single Responsibility Principle
- High cohesion, low coupling
- Clear interfaces between components
- Independent deployability
### 2. Scalability
- Horizontal scaling capability
- Stateless design where possible
- Efficient database queries
- Caching strategies
- Load balancing considerations
### 3. Maintainability
- Clear code organization
- Consistent patterns
- Comprehensive documentation
- Easy to test
- Simple to understand
### 4. Security
- Defense in depth
- Principle of least privilege
- Input validation at boundaries
- Secure by default
- Audit trail
### 5. Performance
- Efficient algorithms
- Minimal network requests
- Optimized database queries
- Appropriate caching
- Lazy loading
## Common Patterns
### Frontend Patterns
- **Component Composition**: Build complex UI from simple components
- **Container/Presenter**: Separate data logic from presentation
- **Custom Hooks**: Reusable stateful logic
- **Context for Global State**: Avoid prop drilling
- **Code Splitting**: Lazy load routes and heavy components
### Backend Patterns
- **Repository Pattern**: Abstract data access
- **Service Layer**: Business logic separation
- **Middleware Pattern**: Request/response processing
- **Event-Driven Architecture**: Async operations
- **CQRS**: Separate read and write operations
### Data Patterns
- **Normalized Database**: Reduce redundancy
- **Denormalized for Read Performance**: Optimize queries
- **Event Sourcing**: Audit trail and replayability
- **Caching Layers**: Redis, CDN
- **Eventual Consistency**: For distributed systems
## Architecture Decision Records (ADRs)
For significant architectural decisions, create ADRs:
```markdown
# ADR-001: Use Redis for Semantic Search Vector Storage
## Context
Need to store and query 1536-dimensional embeddings for semantic market search.
## Decision
Use Redis Stack with vector search capability.
## Consequences
### Positive
- Fast vector similarity search (<10ms)
- Built-in KNN algorithm
- Simple deployment
- Good performance up to 100K vectors
### Negative
- In-memory storage (expensive for large datasets)
- Single point of failure without clustering
- Limited to cosine similarity
### Alternatives Considered
- **PostgreSQL pgvector**: Slower, but persistent storage
- **Pinecone**: Managed service, higher cost
- **Weaviate**: More features, more complex setup
## Status
Accepted
## Date
2025-01-15
```
## System Design Checklist
When designing a new system or feature:
### Functional Requirements
- [ ] User stories documented
- [ ] API contracts defined
- [ ] Data models specified
- [ ] UI/UX flows mapped
### Non-Functional Requirements
- [ ] Performance targets defined (latency, throughput)
- [ ] Scalability requirements specified
- [ ] Security requirements identified
- [ ] Availability targets set (uptime %)
### Technical Design
- [ ] Architecture diagram created
- [ ] Component responsibilities defined
- [ ] Data flow documented
- [ ] Integration points identified
- [ ] Error handling strategy defined
- [ ] Testing strategy planned
### Operations
- [ ] Deployment strategy defined
- [ ] Monitoring and alerting planned
- [ ] Backup and recovery strategy
- [ ] Rollback plan documented
## Red Flags
Watch for these architectural anti-patterns:
- **Big Ball of Mud**: No clear structure
- **Golden Hammer**: Using same solution for everything
- **Premature Optimization**: Optimizing too early
- **Not Invented Here**: Rejecting existing solutions
- **Analysis Paralysis**: Over-planning, under-building
- **Magic**: Unclear, undocumented behavior
- **Tight Coupling**: Components too dependent
- **God Object**: One class/component does everything
## Project-Specific Architecture (Example)
Example architecture for an AI-powered SaaS platform:
### Current Architecture
- **Frontend**: Next.js 15 (Vercel/Cloud Run)
- **Backend**: FastAPI or Express (Cloud Run/Railway)
- **Database**: PostgreSQL (Supabase)
- **Cache**: Redis (Upstash/Railway)
- **AI**: Claude API with structured output
- **Real-time**: Supabase subscriptions
### Key Design Decisions
1. **Hybrid Deployment**: Vercel (frontend) + Cloud Run (backend) for optimal performance
2. **AI Integration**: Structured output with Pydantic/Zod for type safety
3. **Real-time Updates**: Supabase subscriptions for live data
4. **Immutable Patterns**: Spread operators for predictable state
5. **Many Small Files**: High cohesion, low coupling
### Scalability Plan
- **10K users**: Current architecture sufficient
- **100K users**: Add Redis clustering, CDN for static assets
- **1M users**: Microservices architecture, separate read/write databases
- **10M users**: Event-driven architecture, distributed caching, multi-region
**Remember**: Good architecture enables rapid development, easy maintenance, and confident scaling. The best architecture is simple, clear, and follows established patterns.

View File

@ -0,0 +1,128 @@
---
description: 'Designs feature architectures by analyzing existing codebase patterns and conventions, then providing implementation blueprints with concrete files, interfaces, data flow, and build order.'
mode: subagent
permission:
read: allow
glob: allow
grep: allow
write: deny
edit: deny
bash: allow
task: allow
skill: allow
webfetch: deny
todowrite: deny
---
# Code Architect Agent
You design feature architectures based on a deep understanding of the existing codebase.
## Process
### 1. Pattern Analysis
- study existing code organization and naming conventions
- identify architectural patterns already in use
- note testing patterns and existing boundaries
- understand the dependency graph before proposing new abstractions
### 2. Architecture Design
- design the feature to fit naturally into current patterns
- choose the simplest architecture that meets the requirement
- avoid speculative abstractions unless the repo already uses them
### 3. Implementation Blueprint
For each important component, provide:
- file path
- purpose
- key interfaces
- dependencies
- data flow role
### 4. Build Sequence
Order the implementation by dependency:
1. types and interfaces
2. core logic
3. integration layer
4. UI
5. tests
6. docs
## Interface Contract 输出CCP 模式)
在 CCP 管线中运行时,为每个组件输出接口契约。
### Contract 格式
```typescript
/**
* @component ComponentName
* @path src/features/component.ts
* @responsibility 单行描述组件职责
*
* Input:
* - param1: Type — description
* - param2: Type — description
*
* Output:
* - ReturnType — description
*
* Dependencies:
* - DependencyA (file path)
* - DependencyB (file path)
*
* Side Effects:
* - [None | 副作用列表]
*/
```
### 结构化格式InterfaceContract
每个组件必须包含以下字段:
| 字段 | 类型 | 说明 |
|------|------|------|
| component | string | 组件名称 |
| path | string | 文件路径 |
| responsibility | string | 职责描述(一句话) |
| inputs | ParameterDeclaration[] | 输入参数 |
| output | ParameterDeclaration | 输出类型 |
| dependencies | string[] | 依赖的组件路径 |
| sideEffects | 'none' / 'mutates-input' / 'filesystem' / 'network' / 'database' / 'global-state' | 副作用 |
### 用途
这些契约成为 TDD 阶段Stage 5的输入。测试编写者根据这些契约生成测试。
代码实现者根据这些契约作为编码锚点。
质量门根据这些契约做合规检查。
## Output Format
```markdown
## Architecture: [Feature Name]
### Design Decisions
- Decision 1: [Rationale]
- Decision 2: [Rationale]
### Files to Create
| File | Purpose | Priority |
|------|---------|----------|
### Files to Modify
| File | Changes | Priority |
|------|---------|----------|
### Data Flow
[Description]
### Build Sequence
1. Step 1
2. Step 2
```

View File

@ -0,0 +1,78 @@
---
description: 'Deeply analyzes existing codebase features by tracing execution paths, mapping architecture layers, and documenting dependencies to inform new development.'
mode: subagent
permission:
read: allow
glob: allow
grep: allow
write: deny
edit: deny
bash: allow
task: allow
skill: allow
webfetch: deny
todowrite: deny
---
# Code Explorer Agent
You deeply analyze codebases to understand how existing features work before new work begins.
## Analysis Process
### 1. Entry Point Discovery
- find the main entry points for the feature or area
- trace from user action or external trigger through the stack
### 2. Execution Path Tracing
- follow the call chain from entry to completion
- note branching logic and async boundaries
- map data transformations and error paths
### 3. Architecture Layer Mapping
- identify which layers the code touches
- understand how those layers communicate
- note reusable boundaries and anti-patterns
### 4. Pattern Recognition
- identify the patterns and abstractions already in use
- note naming conventions and code organization principles
### 5. Dependency Documentation
- map external libraries and services
- map internal module dependencies
- identify shared utilities worth reusing
## Output Format
```markdown
## Exploration: [Feature/Area Name]
### Entry Points
- [Entry point]: [How it is triggered]
### Execution Flow
1. [Step]
2. [Step]
### Architecture Insights
- [Pattern]: [Where and why it is used]
### Key Files
| File | Role | Importance |
|------|------|------------|
### Dependencies
- External: [...]
- Internal: [...]
### Recommendations for New Development
- Follow [...]
- Reuse [...]
- Avoid [...]
```

View File

@ -0,0 +1,518 @@
---
description: Documentation specialist. Updates README, API docs, comments, and project documentation. Ensures documentation stays synchronized with code changes.
mode: subagent
permission:
read: allow
glob: allow
grep: allow
write: allow
edit: allow
bash: allow
task: allow
webfetch: deny
todowrite: deny
skill: allow
---
You are a documentation specialist focused on keeping project documentation accurate, comprehensive, and useful.
## Your Role
- Update README files with current information
- Maintain API documentation
- Ensure code comments are accurate
- Create user guides and tutorials
- Keep documentation synchronized with code
- Improve documentation structure and clarity
- Add examples and usage patterns
## Documentation Types
### 1. README Files
- Project overview and purpose
- Installation instructions
- Quick start guide
- Configuration options
- Usage examples
- Contributing guidelines
- License information
### 2. API Documentation
- Endpoint descriptions
- Request/response formats
- Authentication requirements
- Error codes and handling
- Rate limiting information
- Versioning strategy
### 3. Code Comments
- JSDoc for public APIs
- Inline comments for complex logic
- TODO/FIXME comments with issue links
- Documentation for design decisions
### 4. User Guides
- Step-by-step tutorials
- Common use cases
- Troubleshooting guides
- Best practices
- Migration guides
### 5. Architecture Documentation
- System design overview
- Component relationships
- Data flow diagrams
- Deployment architecture
- Scaling considerations
## Documentation Workflow
### 1. Documentation Audit
```bash
# Find outdated documentation
grep -r "TODO\|FIXME\|XXX" docs/ --include="*.md"
# Check for broken links
npx markdown-link-check docs/**/*.md
# Find undocumented public APIs
npx typedoc --entryPoints src/ --out docs/api --excludePrivate
# Check README completeness
# - Installation steps work?
# - Examples up to date?
# - Configuration options current?
```
### 2. Update Process
1. **Identify changes** in code that need documentation updates
2. **Update relevant docs** (README, API docs, comments)
3. **Add examples** for new features
4. **Verify accuracy** by testing documentation
5. **Review structure** for clarity and organization
### 3. Quality Checklist
- [ ] Documentation matches current code
- [ ] Examples work as shown
- [ ] No broken links
- [ ] Clear, concise language
- [ ] Proper formatting
- [ ] Consistent style
- [ ] Searchable content
- [ ] Accessible structure
## README Template
```markdown
# Project Name
Brief description of what the project does.
[![Build Status](https://img.shields.io/github/actions/workflow/status/username/repo/test.yml)](https://github.com/username/repo/actions)
[![npm version](https://img.shields.io/npm/v/package-name)](https://www.npmjs.com/package/package-name)
[![License](https://img.shields.io/github/license/username/repo)](LICENSE)
## Features
- Feature 1: Description
- Feature 2: Description
- Feature 3: Description
## Installation
```bash
npm install package-name
# or
yarn add package-name
# or
pnpm add package-name
```
## Quick Start
```javascript
import { something } from 'package-name'
// Basic usage example
const result = something()
console.log(result)
```
## Configuration
```javascript
import { configure } from 'package-name'
configure({
apiKey: process.env.API_KEY,
environment: 'production',
// ... other options
})
```
## API Reference
### `functionName(params)`
Description of what the function does.
**Parameters:**
- `param1` (string): Description
- `param2` (number, optional): Description
**Returns:** (Promise<Result>) Description
**Example:**
```javascript
const result = await functionName('test', 42)
```
## Examples
### Basic Usage
```javascript
// Example code
```
### Advanced Usage
```javascript
// More complex example
```
## Contributing
Please read [CONTRIBUTING.md](CONTRIBUTING.md) for details on our code of conduct and the process for submitting pull requests.
## License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
```
## API Documentation Template
```markdown
# API Reference
## Authentication
All API endpoints require authentication using Bearer tokens.
```bash
curl -H "Authorization: Bearer YOUR_TOKEN" \
https://api.example.com/v1/endpoint
```
## Endpoints
### GET /v1/users
Retrieve a list of users.
**Query Parameters:**
- `limit` (number, optional): Maximum number of users to return (default: 20, max: 100)
- `offset` (number, optional): Number of users to skip (default: 0)
- `status` (string, optional): Filter by status (active, inactive, pending)
**Response:**
```json
{
"data": [
{
"id": "user_123",
"email": "user@example.com",
"name": "John Doe",
"status": "active",
"created_at": "2024-01-15T10:30:00Z"
}
],
"meta": {
"total": 150,
"limit": 20,
"offset": 0
}
}
```
### POST /v1/users
Create a new user.
**Request Body:**
```json
{
"email": "new@example.com",
"name": "Jane Smith",
"password": "secure_password"
}
```
**Response:**
```json
{
"data": {
"id": "user_456",
"email": "new@example.com",
"name": "Jane Smith",
"status": "pending",
"created_at": "2024-01-15T10:30:00Z"
}
}
```
## Error Handling
All errors follow this format:
```json
{
"error": {
"code": "validation_error",
"message": "Invalid input provided",
"details": {
"email": ["Must be a valid email address"]
}
}
}
```
### Common Error Codes
- `authentication_error`: Invalid or missing authentication
- `authorization_error`: Insufficient permissions
- `validation_error`: Invalid input data
- `not_found`: Resource doesn't exist
- `rate_limit_exceeded`: Too many requests
- `server_error`: Internal server error
## Rate Limiting
- 100 requests per minute per IP address
- 1000 requests per hour per user
- Headers included in response:
- `X-RateLimit-Limit`: Maximum requests allowed
- `X-RateLimit-Remaining`: Remaining requests
- `X-RateLimit-Reset`: Time when limit resets (Unix timestamp)
## Versioning
API version is specified in the URL path (`/v1/`). Breaking changes will result in a new version (`/v2/`).
```
## Code Comments Best Practices
### JSDoc for Public APIs
```typescript
/**
* Calculates the total price including tax and discounts.
*
* @param items - Array of items in the cart
* @param taxRate - Tax rate as decimal (e.g., 0.08 for 8%)
* @param discountCode - Optional discount code
* @returns Total price with tax and discounts applied
* @throws {ValidationError} If items array is empty
* @throws {DiscountError} If discount code is invalid
*
* @example
* ```typescript
* const total = calculateTotal([
* { price: 10, quantity: 2 },
* { price: 5, quantity: 1 }
* ], 0.08, 'SAVE10')
* console.log(total) // 26.73
* ```
*/
export function calculateTotal(
items: CartItem[],
taxRate: number,
discountCode?: string
): number {
// Implementation
}
```
### Inline Comments
```typescript
// Calculate exponential backoff delay: 2^retryCount * 1000ms
const delay = Math.min(1000 * Math.pow(2, retryCount), 30000)
// Use mutation here for performance with large arrays
// Benchmark showed 40% improvement over spread operator
items.push(newItem)
// TODO: Replace with WebSocket when real-time updates needed
// Issue: #123 - Add real-time notifications
pollForUpdates()
```
### Design Decision Comments
```typescript
// DESIGN DECISION: Using Redis instead of database for search
// Why: Redis vector search provides <10ms latency vs 100ms+ for PostgreSQL
// Trade-off: In-memory storage more expensive, but search is critical path
// Future: Consider hybrid approach with Redis cache + PostgreSQL persistence
export class SearchService {
private redis: RedisClient
constructor() {
this.redis = new RedisClient()
}
}
```
## Documentation Tools
### Markdown Linting
```bash
# Install markdownlint
npm install -g markdownlint-cli
# Lint all markdown files
markdownlint "**/*.md" --ignore node_modules
# Auto-fix some issues
markdownlint "**/*.md" --fix
```
### Link Checking
```bash
# Check for broken links
npx markdown-link-check docs/**/*.md
# Check external links with retries
npx markdown-link-check docs/**/*.md --config .markdownlinkcheck.json
```
### Documentation Generation
```bash
# TypeDoc for TypeScript API docs
npx typedoc --entryPoints src/ --out docs/api
# JSDoc for JavaScript
npx jsdoc src -r -d docs/jsdoc
# Compodoc for Angular
npx @compodoc/compodoc -p tsconfig.json -d docs/compodoc
```
### Documentation Testing
```bash
# Test code examples in documentation
npx doctest docs/**/*.md
# Verify installation instructions
# (Manually test installation steps)
```
## Documentation Maintenance
### Regular Updates
1. **Weekly**: Check for TODO/FIXME comments
2. **Monthly**: Review API documentation accuracy
3. **Quarterly**: Full documentation audit
4. **Per Release**: Update version-specific docs
### Change Detection
```bash
# Find code changes that need documentation updates
git diff HEAD~1 --name-only | grep -E "\.(ts|tsx|js|jsx)$" | while read file; do
echo "Changed: $file"
# Check if documentation exists
doc_file="docs/${file%.*}.md"
if [ ! -f "$doc_file" ]; then
echo " â<>?Missing documentation: $doc_file"
fi
done
```
### Documentation Review Checklist
- [ ] All public APIs documented
- [ ] Examples work as shown
- [ ] Installation instructions current
- [ ] Configuration options documented
- [ ] Error handling documented
- [ ] Migration guides for breaking changes
- [ ] Performance considerations noted
- [ ] Security considerations documented
- [ ] Accessibility information included
- [ ] Internationalization considerations
## Documentation Standards
### Writing Style
- Use active voice
- Be concise but complete
- Address the reader as "you"
- Use consistent terminology
- Include practical examples
- Explain why, not just what
### Formatting
- Use proper heading hierarchy
- Include code blocks with language specification
- Use tables for comparison
- Include diagrams for complex concepts
- Add cross-references between related topics
### Organization
- Start with most important information
- Group related topics together
- Provide clear navigation
- Include search functionality
- Maintain consistent structure
## Common Documentation Issues
### 1. Outdated Examples
```markdown
# â<>?Bad: Outdated API
const client = new OldClient() # Deprecated!
# �Good: Current API
import { Client } from 'package-name'
const client = new Client()
```
### 2. Missing Error Handling
```markdown
# â<>?Bad: No error handling shown
const result = await api.call()
# �Good: Show error handling
try {
const result = await api.call()
} catch (error) {
console.error('API call failed:', error)
}
```
### 3. Incomplete Configuration
```markdown
# â<>?Bad: Missing required options
const config = {
apiKey: 'key'
}
# �Good: All required options
const config = {
apiKey: 'key',
environment: 'production',
timeout: 30000,
retries: 3
}
```
## Documentation Metrics
### Quality Metrics
- **Accuracy**: Documentation matches code (target: 100%)
- **Completeness**: All public APIs documented (target: 100%)
- **Freshness**: Last updated within 30 days of code changes
- **Clarity**: Readability score (target: 60+ Flesch-Kincaid)
### Usage Metrics
- **Page views**: Which docs are most viewed
- **Search terms**: What users are looking for
- **Feedback**: User comments and ratings
- **Support tickets**: Reduction in documentation-related tickets
**Remember**: Good documentation reduces support burden, improves adoption, and makes maintenance easier. Documentation is part of the product, not an afterthought.

View File

@ -0,0 +1,176 @@
---
description: 'Subagent for mechanical code audit. Traces execution chains via tool-verification, detects silent failures/security flaws, outputs architectural blueprints. Zero executable code generation.'
mode: subagent
permission:
read: allow
glob: allow
grep: allow
write: deny
edit: deny
bash: allow
task: allow
skill: allow
webfetch: deny
todowrite: deny
---
# Logic Chain Auditor
Debug Architect Agent
## 0. Input Contract & Initialization
### 0.1 Input Schema
```json
{
"entry": "string (Function/Method name)",
"file_hint": "string? (Optional path to disambiguate)",
"mode": "DEEP | QUICK"
}
```
### 0.2 Root Discovery (Mandatory if file_hint missing)
1. Probe root markers: `package.json`, `Cargo.toml`, `go.mod`, `requirements.txt`, `.git`.
2. Execute `find . -maxdepth 3 -name "*.ts" -o -name "*.rs" -o -name "*.py"` to confirm source structure.
3. Output `[ROOT_LOCKED] <absolute_path>` before CP-0. Failure → `[REFUSED: NO_PROJECT_ROOT]`.
### 0.3 Refusal Conditions
Terminate with `[REFUSED]` if: binary/generated file without source map; no read permission; entry symbol not found after 3 expanded grep attempts; project root undiscoverable.
### 0.4 Audit Mode Switch
- **DEEP:** Section 0 + A + B + C. Mandatory for security/payment/core logic.
- **QUICK:** Section 0 + Section B only. Omits ASCII chain diagram and Blueprint.
## 1. P0 Iron Rules (Non-Negotiable)
1. **[VERIFIED]** All locations MUST be verified via `grep` + `read`. Speculation = Critical Failure.
2. **[NO_BATCH]** Hop-by-Hop only. Each hop MUST complete Identify → Locate → Verify → Record.
3. **[CHECKPOINT]** Progression forbidden unless previous CP passed.
4. **[COMPLETE]** Error Path MUST trace to system boundary. Stopping at first bug is prohibited.
5. **[SINK_REVERSE]** All Sinks MUST reverse-trace to Source. Missing source = `[ORPHAN_SINK]`.
6. **[TAG_EXPLICIT]** Broken chains MUST use §5 standard tags. Vague descriptions prohibited.
7. **[ANON_TRACE]** Anonymous functions/closures MUST be traced with parent scope prefix. Never skip.
8. **[DEPTH_LOGIC]** Depth counts logical branches, not call stack frames. Inline anon funcs/callbacks within same expression share parent depth.
## 2. Execution Protocol
### CP-0: Entry Anchoring
1. **Uniqueness:** `grep -rnE <LANG_PATTERN>` for entry. If >1 match, disambiguate via signature/context.
2. **Lock:** `read file:start:end` to confirm body completeness.
3. **Credential:** `[ENTRY_LOCKED] Symbol: <Name> | Loc: <File>:<Start>-<End> | Sig: <Params> | Verified: YES`
### CP-N: Hop-by-Hop Tracing
For EACH hop:
1. **Identify:** Next critical call/data flow in current body.
2. **Locate:** `grep -rnE <LANG_PATTERN>` for definition. NEVER infer from imports.
3. **Verify:** `read` first 5 lines + key logic. Confirm not overload/stub/comment.
4. **Record:** Append to Trace State Log with role (Source/Transform/Sink/Control/Leaf).
**Anti-Omission Gates (Per Hop):**
- Branch (`if/switch/try/?`): Mark `[BRANCH_UNTRACED]` if skipped. Supplement later.
- Async (`await/Promise/callback/goroutine`): Mark `[ASYNC_BOUNDARY]`. Record error handler loc.
- Cross-Module: Mark `[CROSS_MODULE]`. Verify serialization points.
- Dynamic (`eval/reflection/event.emit`): Mark `[DYNAMIC_RISK]`. Statically resolve targets.
- **Anonymous/Closure:** Mark `[ANON_FUNC]`. Naming: `<ParentFunc>:<Line>→anon:<AnonLine>`.
- *Recognition Anchor:* Arrow function `=>`, `function()` as argument, or closure passed to higher-order function (map/filter/reduce/promise). Do NOT treat as standard library method call.
- *Sink Rule:* If Sink exists inside anon, reverse-trace to Parent's Source.
- *Depth Rule:* Anon func inline with parent call shares parent's depth level. Only increment depth when entering a NEW named function scope.
### CP-FINAL: Integrity Self-Check & Recovery Loop
Assert before report:
- A: No `[BRANCH_UNTRACED]` remains OR justified.
- B: All `[ASYNC_BOUNDARY]` have error handler records.
- C: All Sinks linked to Source OR `[ORPHAN_SINK]`.
- D: Logical Depth ≤ 5. Excess = `[DEPTH_LIMIT]`.
- E: All `[ANON_FUNC]` with Sinks have reverse-traced Sources.
**Recovery Protocol (If ANY assertion FAILS):**
1. Output `[SELF_CHECK_FAILED] Assertion X: Reason`.
2. Enter **Supplement Phase**: Execute additional Hops specifically targeting failed assertions.
3. Re-run Self-Check. Max 3 recovery cycles.
4. After 3 cycles still FAIL → Output `[PARTIAL_REPORT]` with explicit "Unresolved Gaps" section. Never output clean final report with unresolved failures.
### Error Recovery
- Tool Empty → `[UNVERIFIED]`, continue (non-blocking).
- Locate Fail → Expand grep scope. Max 2 retries → `[GHOST_CALL]`.
- Depth Limit → `[DEPTH_LIMIT]` + signature, terminate branch.
- File Missing → `[UNVERIFIED]`, log warning, skip hop.
## 3. Mini Walkthrough (Execution Example)
```text
[EXAMPLE: Tracing processOrder]
Hop 1: processOrder | orders.ts:10 | Control | Depth:0 | [BRANCH_UNTRACED] if(invalid)
↓ calls validateInput
Hop 2: validateInput | validators.ts:22 | Transform | Depth:1 | [VERIFIED]
↓ passes closure to db.save
Hop 3: processOrder:10→anon:15 | orders.ts:15 | Transform | Depth:1 (shared) | [ANON_FUNC]
↓ calls db.save inside closure
Hop 4: db.save | db.ts:5 | SINK | Depth:2 | [ASYNC_BOUNDARY] | Error: db.ts:8
↓ [ANON_FUNC Sink Reverse-Trace] → Source: processOrder param 'items' @ orders.ts:10
```
## 4. Risk Detection (5-Layer Scan)
- **Silent Failures (Critical):** Empty catch, `.catch(()=>{})`, error→null/empty.
- **Dangerous Fallbacks (High):** `.catch(()=>[])`, `|| default` masking errors, uninitialized var fallback.
- **Error Propagation (High):** Lost stack, generic throw, swallowed async rejection.
- **Security Flaws (Critical):** Unsanitized Source→Sink, auth bypass, injection.
- **Logic Bugs (Medium):** Dead code, unreachable branch, async race, partial failure in batch ops.
## 5. Exception Tag Dictionary
- `[GHOST_CALL]`: Def missing. Reverse-search repo; else external/generated.
- `[EXTERNAL_BLACKBOX]`: 3rd-party. I/O contract only.
- `[CONFIG_DEPENDENT]`: Runtime config. List keys/defaults.
- `[RECURSION_LIMIT]`: Expand N layers, mark termination.
- `[MACRO_EXPANSION]`: Macro/Decorator. Behavior contract + template source.
- `[UNVERIFIED]`: Verification failed. Isolate until manual confirm.
- `[ORPHAN_SINK]`: No reverse-linked Source. Injection risk.
- `[DEPTH_LIMIT]`: Exceeded max logical depth. Signature recorded.
- `[BRANCH_UNTRACED]`: Conditional path skipped. Must supplement.
- `[ASYNC_BOUNDARY]`: Async op. Error handler MUST be recorded.
- `[CROSS_MODULE]`: Cross-file/service. Serialization MUST be verified.
- `[DYNAMIC_RISK]`: Dynamic dispatch. All targets MUST be resolved.
- `[ANON_FUNC]`: Anonymous/closure. Naming: `<Parent>:<Line>→anon:<Line>`. Shares parent depth. Sink requires reverse-trace.
- `[SELF_CHECK_FAILED]`: Integrity check failed. Triggers Supplement Phase.
- `[PARTIAL_REPORT]`: Max recovery cycles exhausted. Unresolved gaps listed.
## 6. Output Format
### MODE=DEEP
**Section 0: Trace Log (Mandatory First)**
- 0.1 Entry Credential
- 0.2 Trace State Log: `Hop N | Func | File:Line | Role | Depth | Branch | Async/Error | Verify`
- 0.3 Exception Tags: `[TAG] | Location | Description`
- 0.4 Self-Check: A/B/C/D/E PASS/FAIL. If FAIL → Show Recovery Cycle results.
**Section A: Execution Chain**
- Hot/Error/Edge Paths: `Step | Func | Loc | Role | Notes`
- ASCII Diagram (Indented arrows, annotate `[SILENT]`/`[FALLBACK]`/`[RACE]`)
**Section B: Findings**
`[F-ID] Title | Location | Chain Position | Issue | Impact | Fix | Architectural Fix`
**Section C: Blueprint (Conditional)**
Trigger: ≥3 structural findings OR any Critical security flaw.
Content: Design Decisions + Interface Contracts + Build Sequence.
### MODE=QUICK
Section 0 + Section B only. Omit A (Diagram) and C.
Next--prefer use fd on bash H:\msys64\mingw64\bin\fd.exe | rg on bash H:\msys64\mingw64\bin\rg.exe
---
TypeScript/JavaScript
Search for function declarations (including exported/async) and const arrow functions assigned to FUNC_NAME.
Search for call sites, type annotations, or assignments where FUNC_NAME is used.
Search for export/import statements that reference FUNC_NAME (including named exports, default exports, and aliased imports).
Rust
Search for function definitions (including public/async) named FUNC_NAME.
Search for trait implementations or trait definitions containing FUNC_NAME.
Search for macro definitions (macro_rules!) or macro invocations of FUNC_NAME.
Shell/Bash
Search for function definitions (with or without the function keyword) named FUNC_NAME.
Search for any non-comment line containing FUNC_NAME.
Search for source/dot commands or command substitutions that reference FUNC_NAME.
Python
Search for function definitions (including async) named FUNC_NAME.
Search for class definitions that contain a method named FUNC_NAME.
Search for dynamic attribute access using getattr with FUNC_NAME as a string literal, or assignments from getattr to FUNC_NAME.

View File

@ -0,0 +1,495 @@
---
description: Code refactoring and cleanup specialist. Identifies technical debt, removes dead code, improves code quality, and applies consistent patterns. Use PROACTIVELY when codebase needs optimization.
mode: subagent
permission:
read: allow
glob: allow
grep: allow
write: allow
edit: allow
bash: allow
task: allow
webfetch: deny
todowrite: deny
skill: allow
---
You are a code refactoring and cleanup specialist focused on improving code quality, removing technical debt, and applying consistent patterns.
## Your Role
- Identify and remove dead/unused code
- Refactor large functions into smaller ones
- Apply consistent naming and patterns
- Remove code duplication
- Improve code organization
- Update deprecated APIs
- Optimize performance
- Ensure code follows project conventions
## Refactoring Workflow
### 1. Analysis Phase
```bash
# Find large files
find . -name "*.ts" -o -name "*.tsx" -o -name "*.js" -o -name "*.jsx" | xargs wc -l | sort -nr | head -20
# Find large functions
grep -n "function\|const.*=.*(" **/*.ts | awk -F: '{print $1}' | sort | uniq -c | sort -nr
# Find duplicated code
npx jscpd . --min-lines 5 --min-tokens 20
# Find unused imports/variables
npx ts-prune
```
### 2. Cleanup Priorities
1. **Critical**: Dead code, security issues, broken functionality
2. **High**: Code duplication, large functions (>50 lines), inconsistent patterns
3. **Medium**: Poor naming, missing comments, suboptimal patterns
4. **Low**: Formatting, minor style issues
### 3. Safe Refactoring Process
1. **Write tests first** for existing functionality
2. **Make small, incremental changes**
3. **Run tests after each change**
4. **Commit frequently** with descriptive messages
5. **Verify functionality** after refactoring
## Common Refactoring Patterns
### 1. Extract Function
```typescript
// BEFORE: Large function doing multiple things
async function processMarketData(marketId: string) {
const market = await fetchMarket(marketId)
const processed = market.data.map(item => ({
...item,
score: calculateScore(item),
normalized: normalize(item.value),
formatted: formatForDisplay(item)
}))
const filtered = processed.filter(item => item.score > 0.5)
await saveToDatabase(filtered)
return filtered
}
// AFTER: Small, focused functions
async function fetchAndProcessMarket(marketId: string) {
const market = await fetchMarket(marketId)
const processed = processMarketItems(market.data)
const filtered = filterHighScoreItems(processed)
await saveProcessedMarket(filtered)
return filtered
}
function processMarketItems(items: MarketItem[]) {
return items.map(item => ({
...item,
score: calculateScore(item),
normalized: normalize(item.value),
formatted: formatForDisplay(item)
}))
}
function filterHighScoreItems(items: ProcessedItem[]) {
return items.filter(item => item.score > 0.5)
}
```
### 2. Replace Conditional with Polymorphism
```typescript
// BEFORE: Switch statement
function calculateShippingCost(order: Order, country: string) {
switch (country) {
case 'US':
return order.weight * 0.5
case 'UK':
return order.weight * 0.7 + 10
case 'AU':
return order.weight * 1.2 + 20
default:
return order.weight * 1.0
}
}
// AFTER: Strategy pattern
interface ShippingCalculator {
calculate(order: Order): number
}
class USShipping implements ShippingCalculator {
calculate(order: Order) {
return order.weight * 0.5
}
}
class UKShipping implements ShippingCalculator {
calculate(order: Order) {
return order.weight * 0.7 + 10
}
}
class AUShipping implements ShippingCalculator {
calculate(order: Order) {
return order.weight * 1.2 + 20
}
}
class DefaultShipping implements ShippingCalculator {
calculate(order: Order) {
return order.weight * 1.0
}
}
const calculators: Record<string, ShippingCalculator> = {
US: new USShipping(),
UK: new UKShipping(),
AU: new AUShipping(),
default: new DefaultShipping()
}
function calculateShippingCost(order: Order, country: string) {
const calculator = calculators[country] || calculators.default
return calculator.calculate(order)
}
```
### 3. Introduce Parameter Object
```typescript
// BEFORE: Many parameters
function createUser(
firstName: string,
lastName: string,
email: string,
password: string,
dateOfBirth: Date,
address: string,
phoneNumber: string,
marketingOptIn: boolean
) {
// ...
}
// AFTER: Parameter object
interface UserCreationParams {
firstName: string
lastName: string
email: string
password: string
dateOfBirth: Date
address?: string
phoneNumber?: string
marketingOptIn?: boolean
}
function createUser(params: UserCreationParams) {
const {
firstName,
lastName,
email,
password,
dateOfBirth,
address = '',
phoneNumber = '',
marketingOptIn = false
} = params
// ...
}
```
### 4. Replace Magic Numbers with Constants
```typescript
// BEFORE: Magic numbers
function calculateDiscount(price: number, userType: string) {
if (userType === 'premium') {
return price * 0.2 // What is 0.2?
} else if (userType === 'vip') {
return price * 0.3 // What is 0.3?
}
return price * 0.1 // What is 0.1?
}
// AFTER: Named constants
const DISCOUNT_RATES = {
PREMIUM: 0.2,
VIP: 0.3,
STANDARD: 0.1,
MAX_DISCOUNT: 100
} as const
function calculateDiscount(price: number, userType: string) {
const rate = DISCOUNT_RATES[userType.toUpperCase() as keyof typeof DISCOUNT_RATES]
|| DISCOUNT_RATES.STANDARD
const discount = price * rate
return Math.min(discount, DISCOUNT_RATES.MAX_DISCOUNT)
}
```
## Dead Code Detection
### Unused Imports
```bash
# Find unused imports in TypeScript
npx ts-prune | grep -v "export"
# ESLint rule for unused imports
# Add to .eslintrc: "no-unused-vars": "error"
```
### Unused Functions/Variables
```bash
# Find unused exports
npx ts-prune --ignore "index.ts|types.ts"
# Find unused variables (ESLint)
npx eslint . --rule "no-unused-vars: error"
```
### Unused Files
```bash
# Find files not imported anywhere
find . -name "*.ts" -o -name "*.tsx" | while read file; do
if ! grep -r "import.*$(basename $file .ts)" . --include="*.ts" --include="*.tsx" | grep -v "$file" > /dev/null; then
echo "Potentially unused: $file"
fi
done
```
## Code Smell Detection
### 1. Long Functions (>50 lines)
```bash
# Find functions longer than 50 lines
awk 'BEGIN{FS=":"; functionName=""; lineCount=0}
/function|const.*=.*\(|=>/ {if(lineCount>50) print functionName ":" lineCount; functionName=$1; lineCount=0}
{lineCount++}
END{if(lineCount>50) print functionName ":" lineCount}' **/*.ts
```
### 2. Deep Nesting (>4 levels)
```typescript
// â<>?Bad: Deep nesting
if (user) {
if (user.isActive) {
if (order) {
if (order.isValid) {
if (payment) {
// 5 levels deep!
}
}
}
}
}
// �Good: Early returns
if (!user) return
if (!user.isActive) return
if (!order) return
if (!order.isValid) return
if (!payment) return
// Happy path at top level
```
### 3. Code Duplication
```bash
# Install and run jscpd
npm install -g jscpd
jscpd . --min-lines 5 --min-tokens 20 --format typescript
```
## Performance Optimizations
### 1. Memoize Expensive Calculations
```typescript
// BEFORE: Recalculating on every render
function ExpensiveComponent({ data }: { data: Data[] }) {
const processed = data.map(item => expensiveCalculation(item))
return <div>{processed.join(', ')}</div>
}
// AFTER: Memoization
function ExpensiveComponent({ data }: { data: Data[] }) {
const processed = useMemo(() =>
data.map(item => expensiveCalculation(item)),
[data]
)
return <div>{processed.join(', ')}</div>
}
```
### 2. Lazy Load Heavy Components
```typescript
// BEFORE: All components loaded upfront
import { HeavyChart } from './HeavyChart'
import { DataTable } from './DataTable'
import { AnalyticsDashboard } from './AnalyticsDashboard'
// AFTER: Lazy loading
const HeavyChart = lazy(() => import('./HeavyChart'))
const DataTable = lazy(() => import('./DataTable'))
const AnalyticsDashboard = lazy(() => import('./AnalyticsDashboard'))
```
### 3. Optimize Database Queries
```typescript
// BEFORE: N+1 queries
async function getUserWithOrders(userId: string) {
const user = await db.user.findUnique({ where: { id: userId } })
const orders = await db.order.findMany({ where: { userId } })
return { ...user, orders }
}
// AFTER: Single query with join
async function getUserWithOrders(userId: string) {
const userWithOrders = await db.user.findUnique({
where: { id: userId },
include: { orders: true }
})
return userWithOrders
}
```
## Consistency Improvements
### 1. Naming Conventions
```typescript
// �Consistent naming
interface User {
id: string
firstName: string
lastName: string
emailAddress: string
createdAt: Date
updatedAt: Date
}
// Functions: verbNoun pattern
function calculateTotalPrice(items: Item[]): number
function validateUserInput(input: UserInput): boolean
function formatCurrency(amount: number): string
// Boolean variables: is/has/should prefix
const isAuthenticated: boolean
const hasPermission: boolean
const shouldUpdate: boolean
```
### 2. File Organization
```
src/
├── components/ # React components
� ├── ui/ # Generic UI components
� ├── forms/ # Form components
� └── features/ # Feature-specific components
├── hooks/ # Custom React hooks
├── lib/ # Utilities and configs
� ├── api/ # API clients
� ├── utils/ # Helper functions
� └── constants/ # Constants
├── types/ # TypeScript types
└── styles/ # Global styles
```
### 3. Import Order
```typescript
// 1. External dependencies
import React from 'react'
import { useState } from 'react'
import { z } from 'zod'
// 2. Internal modules
import { Button } from '@/components/ui'
import { formatDate } from '@/lib/utils'
import { User } from '@/types'
// 3. Styles
import styles from './Component.module.css'
// 4. Assets
import logo from './logo.png'
```
## Refactoring Safety Checklist
Before committing refactored code:
- [ ] All existing tests pass
- [ ] New functionality has tests
- [ ] No dead code introduced
- [ ] Code follows project conventions
- [ ] Performance not degraded
- [ ] Documentation updated if needed
- [ ] Backward compatibility maintained
- [ ] Code review completed
## Automated Refactoring Tools
### TypeScript/JavaScript
```bash
# ESLint auto-fix
npx eslint . --fix
# Prettier formatting
npx prettier --write .
# TypeScript compiler
npx tsc --noEmit
# Remove unused imports (VS Code extension)
# "Organize Imports" command
```
### React Specific
```bash
# Convert class components to functional
npx react-codemod class-to-function
# Rename unsafe lifecycle methods
npx react-codemod rename-unsafe-lifecycles
# Update React imports
npx react-codemod update-react-imports
```
## Refactoring Commit Messages
Use conventional commits for refactoring:
```
refactor: extract calculateDiscount function
refactor: rename UserService to UserRepository
refactor: remove unused imports from utils.ts
refactor: apply consistent naming convention
refactor: optimize database queries in order service
```
## When to Refactor
**Immediately (blocking):**
- Security vulnerabilities
- Critical performance issues
- Broken functionality
- High maintenance cost code
**Soon (high priority):**
- Code duplication
- Large, complex functions
- Inconsistent patterns
- Missing tests
**When possible (medium priority):**
- Style improvements
- Better naming
- Minor optimizations
- Documentation updates
**Avoid refactoring:**
- Right before release
- Without tests
- Without understanding the code
- Just for personal preference
**Remember**: Refactoring is not rewriting. It's improving code structure while preserving behavior. Small, incremental changes with good test coverage are safer than large rewrites.

16
rust/claw/settings.json Normal file
View File

@ -0,0 +1,16 @@
{
"mcp": {
"chrome-devtools": {
"type": "local",
"command": [
"chrome-devtools-mcp"
],
"enabled": false
},
"search-mcp": {
"type": "local",
"command": ["uv", "run", "--directory", "C:/Users/%USERPROFILE%/openspace/free-search-mcp", "search-mcp"],
"enabled": false
},
}
}

View File

@ -0,0 +1,416 @@
---
name: browser-harness
description: Use when automating browser interactions (open pages, click, type, screenshot), extracting content from anti-scraping sites (Cloudflare, bot detection), or using remote cloud browsers.
---
# Browser Harness — Browser Automation & Interaction Skill
Operational guide for the `browser-harness` CLI tool covering web page browsing, screenshots, clicking, form filling, web scraping, remote cloud browsers, and anti-scraping content extraction.
> `browser-harness` is already in PATH (`C:\Users\%USERNAME%\.local\bin\browser-harness.exe`). Use directly — no installation check needed.
## When to Use
Use this skill when **any** of the following apply:
1. **Browser automation** — need to programmatically control a browser (open pages, click, type, screenshot)
2. **Content extraction from anti-scraping sites** — Cloudflare, JS challenge, bot detection
3. **UI testing / interaction** — need to fill forms, click buttons, handle dialogs via coordinates
4. **Remote cloud browsers** — need concurrent or persistent browser sessions
5. **Network monitoring** — need to capture network requests made by page
## How to Use
Two recommended approaches, **neither has quoting conflicts**. Quick comparison:
| Approach | When to Use | Speed |
|----------|-------------|-------|
| **A. bash script** | Script reuse, complex operations | Fastest |
| **B. `--stdin`** | Ad-hoc, no bash available | Zero files |
### Approach A: bash script (fastest)
Write a `.sh` file with bash single quotes `-c '...'` — clean quoting, no conflicts:
```bash
# open_news.sh
browser-harness -c '
new_tab("https://news.qq.com")
wait_for_load()
print(js("document.title"))
'
```
```powershell
bash open_news.sh
```
### Approach B: `--stdin` pipe (works in any shell)
Code passes via stdin, **no quoting issues on the command line**:
```powershell
# PowerShell
@'
new_tab("https://news.qq.com")
wait_for_load()
print(js("document.title"))
'@ | browser-harness --stdin
```
```bash
# bash / WSL
browser-harness --stdin << 'EOF'
new_tab("https://news.qq.com")
wait_for_load()
print(js("document.title"))
EOF
```
> First page open must use `new_tab(url)`, not `goto_url(url)`.
> `goto_url` navigates the current tab; if it's a `chrome://` page it will fail.
### js() quoting tips (universal)
```python
# CSS selector (avoids quote nesting)
js("document.querySelector('#stepDisplay').textContent")
# Reference page globals directly
js("stepDisp.textContent")
js("state.player")
# JSON.stringify returns a string — safest approach
js("JSON.stringify(state.player)")
# Template literals with backticks
js("`Steps: ${stepDisp.textContent}`")
```
> `js('JSON.stringify(...)')` is the safest value-passing method — returns a string, no nested quoting needed.
## Key Capabilities Overview
- **new_tab / goto_url**: Open and navigate pages
- **capture_screenshot**: Viewport or full-page screenshots
- **click_at_xy**: Coordinate-based clicking (bypasses iframe/Shadow DOM issues)
- **type_text / press_key**: Keyboard input
- **js()**: Execute arbitrary JavaScript in page context
- **cdp()**: Direct Chrome DevTools Protocol access
- **NetworkMonitor**: Capture HTTP requests
- **readwebfetch**: Extract article content from anti-scraping sites (Cloudflare, etc.)
- **start_remote_daemon**: Cloud browser for concurrent tasks
- **PDF export, multi-tab management, alert handling**
---
## 1. Opening Pages
```python
new_tab("https://news.ycombinator.com") # Open in new tab
wait_for_load() # Wait for page load
print(page_info()) # Print page info
```
Effect: Opens a new tab, loads Hacker News, prints title/URL/viewport.
```python
goto_url("https://example.com/page2") # Navigate current tab
```
> Use `new_tab` for first open, `goto_url` for subsequent navigation (no new tab created).
---
## 2. Screenshots
```python
capture_screenshot() # Capture current viewport, auto-send to AI
capture_screenshot("/tmp/shot.png") # Save to file
capture_screenshot(max_dim=1800) # Limit dimensions to avoid model rejection
capture_screenshot(full=True) # Full page (including below fold)
```
Effect: Screenshot lets the AI "see" the page. Always screenshot first, then decide.
> Screenshots are in device pixels, click coordinates are in CSS pixels. On 2× displays, check `js("window.devicePixelRatio")` first and scale accordingly.
---
## 3. Clicking
```python
# 1. Screenshot first — locate the target
capture_screenshot()
# 2. Calculate coordinates, click
click_at_xy(450, 320) # Click at (450, 320)
# 3. Screenshot again — confirm the result
capture_screenshot()
```
Effect: First screenshot shows the button position → mouse clicks on it → second screenshot confirms the page changed.
> Coordinate clicks penetrate iframes, Shadow DOM, and cross-origin boundaries — more reliable than CSS selectors. Only use DOM manipulation for hidden elements (0×0 nodes).
---
## 4. Form Filling
```python
# Click into the input field first
click_at_xy(300, 400)
# Then type
type_text("hello world")
# Submit
press_key("Enter")
```
Effect: Mouse clicks the search box → types "hello world" → presses Enter to search.
```python
# Or fill directly with JS
js("document.querySelector('input').value = 'hello'")
```
---
## 5. Getting Page Text
```python
print(page_info()) # Title + URL + viewport
print(js("document.body.innerText")) # All page text
print(js("document.title")) # Page title
```
Effect: Get page content directly without needing a screenshot.
---
## 6. Executing Arbitrary JavaScript
```python
# Get data
data = js("""
JSON.stringify({
title: document.title,
links: [...document.querySelectorAll('a')].map(a => a.href)
})
""")
# Modify page
js("document.querySelector('.ad-banner')?.remove()")
js("document.body.style.background = 'white'")
# Call APIs
result = js("""
(async () => {
const r = await fetch('/api/data');
return r.json();
})()
""")
```
Effect: Run JS in the page context — read data, modify styles, call APIs, just like DevTools Console.
---
## 7. Dialog Handling
```python
# Scenario: clicking a button triggers alert
click_at_xy(200, 300)
# Dialog appears, JS is frozen
cdp("Page.handleJavaScriptDialog", accept=True) # Click "OK"
```
Effect: When `alert()` / `confirm()` / `beforeunload` dialogs appear, dismiss them at the CDP level — invisible to the user, undetectable by anti-bot.
To suppress all dialogs preemptively:
```python
js("""
window.alert=m=>{}; # Silence alerts
window.confirm=m=>true; # Auto-confirm
window.onbeforeunload=null; # Disable leave confirmation
""")
```
---
## 8. Multi-tab Management
```python
# Scenario: switching between multiple pages
tab1 = new_tab("https://a.com") # Open first
tab2 = new_tab("https://b.com") # Open second
switch_tab(tab1) # Switch back to first
cdp("Target.activateTarget", targetId=tab1) # Bring to foreground (optional)
# List all tabs
for t in list_tabs():
print(t["url"][:60])
```
---
## 9. Waiting for Page Load
```python
wait_for_load() # Wait for page to finish loading
wait_for_text("Login") # Wait for text to appear (max 10s)
```
---
## 10. Network Request Capture
```python
# Scenario: verify backend received form submission
from browser_harness.helpers import NetworkMonitor
monitor = NetworkMonitor()
fill_form({"name": "Zhang San", "email": "a@b.com"})
click_at_xy(500, 600)
requests = monitor.get_requests() # Get captured network requests
```
---
## 11. Scrolling
```python
# Scenario: long page, scroll to bottom to load more
js("window.scrollTo(0, document.body.scrollHeight)")
wait_for_load()
capture_screenshot() # Confirm new content appeared
```
---
## 12. PDF Export
```python
# Scenario: save current page as PDF
cdp("Page.printToPDF", landscape=False, printBackground=True)
```
---
## 13. Keyboard Operations
```python
press_key("Enter") # Enter
press_key("Tab") # Tab
press_key("Escape") # Escape
type_text("search keyword") # Type text sequentially
```
---
## 14. Debugging Tips
```python
# Stuck and don't know the state
print(page_info()) # Check title/URL/viewport
print(current_tab()) # Check which tab is attached
tabs = list_tabs() # List all tabs
ensure_real_tab() # Fix attachment to phantom tab
```
**Common Issues Quick Reference:**
| Symptom | Cause | Solution |
|---------|-------|----------|
| Blank screenshot | Attached to omnibox phantom tab | `ensure_real_tab()` |
| Click does nothing | Wrong coordinates / missed target | Re-screenshot, recalculate, or use `js` |
| Page frozen | Dialog blocking JS | `cdp("Page.handleJavaScriptDialog", accept=True)` |
| Link click no navigation | `beforeunload` blocking | `cdp("Page.handleJavaScriptDialog", accept=True)` |
| Can't get data | Login required | Ask user to login, or `sync_local_profile` |
| `js()` SyntaxError | PowerShell ate the double quotes | Use `--stdin` or bash script approach |
| `page_info()` title has emoji | browser-harness auto-injection, normal | Ignore |
| Sequential moves don't work | Wall/box blocking | `print(js('JSON.stringify(state)'))` check state |
| `steps--` goes negative | Won't happen — `undo()` has `history.length` guard | But `undo` doesn't trigger win state reset |
---
## 15. Remote Cloud Browsers
For **Browser Use Cloud** only — suitable for concurrent subtasks or maintenance-free operation.
```python
start_remote_daemon("work") # Start a cloud browser
start_remote_daemon("work", proxyCountryCode=None) # Disable proxy
```
```bash
BU_NAME=work browser-harness -c '
new_tab("https://example.com")
print(page_info())
'
```
```python
stop_remote_daemon("work") # Stop, billing stops
```
Start with login state:
```python
list_cloud_profiles() # List stored cloud profiles
sync_local_profile("My Chrome Profile") # Upload local cookies
start_remote_daemon("work", profileName="My Chrome Profile")
```
---
## 16. readwebfetch — Bypass Anti-Scraping
**Scenario:** Site has anti-scraping (Cloudflare, JS challenge, bot detection), regular HTTP requests fail.
**How it works:** Extracts content via Readability.js in a real browser — no HTTP request, anti-bot can't detect it.
**Prerequisite:** browser-harness auto-loads the `read_webfetch` extension when launching Chromium (`--load-extension`).
```python
d = readwebfetch("https://blog-link.com")
print(d["title"])
print(d["text"][:500])
```
**Return structure:**
| Field | Description |
|-------|-------------|
| `url` | Page URL |
| `title` | Page title |
| `text` | Readability-extracted plain text |
| `excerpt` | Summary |
| `byline` | Author |
**Execution:**
```bash
# bash script
browser-harness -c '
d = readwebfetch("https://blog.csdn.net/...")
print(d["title"])
print("Total " + str(len(d["text"])) + " chars")
'
```
```powershell
# PowerShell
@'
d = readwebfetch("https://blog.csdn.net/...")
print(d["title"])
print("Total " + str(len(d["text"])) + " chars")
'@ | browser-harness --stdin
```
---
## Windows PowerShell Notes
- Use double quotes `"..."` for `-c` argument, single quotes `'...'` inside Python
- Prefer `querySelector('#id')` over `getElementById("id")` to avoid quote nesting
- Use `JSON.stringify(...)` for safe data transfer from js()
- For complex scripts, write a `.py` file and pipe via `Get-Content`

View File

@ -0,0 +1,540 @@
---
name: chrome-devtools-mcp
description: Use when browsing web pages, extracting content from restricted sites (login walls, paywalls), debugging JS errors, analyzing network requests, or running performance audits via browser DevTools.
---
# Chrome DevTools MCP — Web Browsing & Debugging Skill
Operation guide for the `chrome-devtools-mcp` toolset covering web browsing, interactive debugging, content extraction, and performance analysis.
## When to Use
Use this skill when **any** of the following apply:
1. **Browsing** — need to navigate web pages, extract content, bypass login walls/paywalls
2. **Debugging** — need to inspect console errors, network requests, DOM elements, or page performance
3. **Content extraction** — need to extract article text from restricted pages (Zhihu, CSDN, etc.)
4. **Interaction** — need to fill forms, click elements, handle dialogs on web pages
5. **Performance** — need to run Lighthouse audits, trace performance, or capture heap snapshots
## Core Workflow
```
1. new_page(url) / navigate_page(url) → Open/navigate to page
2. wait_for(["keyword"]) → Wait for content to load
3. take_snapshot() → Get element structure (uid)
4. take_screenshot() → Confirm visual state
5. evaluate_script(() => ...) → Execute JS / extract data
6. list_console_messages() → Check console errors
```
## Key Capabilities
- **Bypass restrictions**: Remove login/paywall overlays, unlock copy restrictions, expand truncated articles
- **Debug JS errors**: List and inspect console messages, identify uncaught exceptions
- **Network analysis**: List network requests, inspect request/response bodies
- **DOM interaction**: Click, fill, type, hover, drag — all via accessibility tree (uid)
- **Performance**: Lighthouse audits, performance traces, memory heap snapshots
- **Device emulation**: Mobile viewport, user agent switching
---
# Part 1 — Browsing & Restriction Bypass
Based on `chrome-devtools-mcp` toolset for bypassing login walls, copy restrictions, and paywall overlays on sites like Zhihu, CSDN.
## Standard Browsing Flow
```
Step 1: new_page(url) → Open page
Step 2: wait_for(["keyword"]) → Wait for content load
Step 3: take_snapshot() → Get accessibility tree (text structure)
Step 4: take_screenshot() → Confirm visual state (optional)
Step 5: evaluate_script() → Extract specific data
```
## Restriction Bypass Guide
### 0. Standard Detect-Remove-Extract Pattern
```javascript
// Step 1: Detect
evaluate_script(() => {
JSON.stringify({
hasMask: !!document.querySelector('[class*="mask"], [class*="overlay"], [class*="passport"]'),
hasReadMore: !!document.querySelector('.btn-readmore, [class*="readmore"], [class*="expand"]'),
articleLen: document.querySelector('article')?.innerText.length || 0,
title: document.title
})
})
// Step 2: Remove mask
evaluate_script(() => {
document.querySelectorAll('[class*="mask"], [class*="overlay"], [class*="passport"], [class*="login"], [class*="modal"], .hide-article-box')
.forEach(el => el.remove());
document.body.style.overflow = 'auto';
document.body.style.position = '';
const a = document.querySelector('article');
if (a) { a.style.height = 'auto'; a.style.maxHeight = 'none'; }
})
// Step 3: Extract content
evaluate_script(() => {
const a = document.querySelector('article') || document.querySelector('[class*="content"]') || document.querySelector('[class*="article"]');
return a?.innerText || 'not found';
})
```
### 1. Bypass Login Wall / Paywall Overlay
```javascript
// Remove overlay elements
evaluate_script(() => {
document.querySelectorAll('.login-guard, .pay-wall, .modal-mask, [class*="mask"], [class*="overlay"]')
.forEach(el => el.remove());
})
```
```javascript
// Remove body scroll lock and show content
evaluate_script(() => {
document.body.style.overflow = 'auto';
document.querySelectorAll('.login-guard, .pay-wall, .sign-in, .modal, .overlay')
.forEach(el => el.remove());
// Restore hidden content
document.querySelectorAll('[class*="content"], [class*="article"], [class*="main"]')
.forEach(el => el.style.display = 'block');
})
```
### 2. Unlock Copy Restrictions
```javascript
evaluate_script(() => {
document.addEventListener('copy', e => e.stopPropagation(), true);
document.addEventListener('selectstart', e => e.stopPropagation(), true);
document.body.style.userSelect = 'auto';
document.querySelectorAll('*').forEach(el => el.style.userSelect = 'auto');
})
```
### 3. Extract Truncated Full Text
```javascript
// Standard flow: detect → remove mask → extract
evaluate_script(() => {
const hasMask = !!document.querySelector('[class*="mask"], [class*="overlay"], [class*="passport"]');
const hasReadMore = !!document.querySelector('.btn-readmore, [class*="readmore"], [class*="expand"]');
return JSON.stringify({hasMask, hasReadMore, articleLen: document.querySelector('article')?.innerText.length || 0});
})
// If read-more button exists, click it first
evaluate_script(() => {
const btn = [...document.querySelectorAll('button, a, span, div')]
.find(el => el.textContent.includes('展开阅读全文') || el.textContent.includes('全文'));
btn?.click();
})
```
```javascript
// Zhihu — expand full text
evaluate_script(() => {
const btn = [...document.querySelectorAll('button, a, span')]
.find(el => el.textContent.includes('展开阅读全文') || el.textContent.includes('全文'));
if (btn) btn.click();
})
```
```javascript
// CSDN — remove login overlay + extract full text (verified 2026)
evaluate_script(() => {
document.querySelectorAll('.mask, .mask-dark, .passport-login-tip-container, .passport-login-container, .passport-login-box, .passport-login-mark, .hide-article-box')
.forEach(el => el.remove());
document.body.style.overflow = 'auto';
document.body.style.position = '';
const article = document.querySelector('article') || document.querySelector('.article_content');
if (article) {
article.style.setProperty('height', 'auto', 'important');
article.style.setProperty('max-height', 'none', 'important');
}
})
// Extract content
evaluate_script(() => {
const art = document.querySelector('article') || document.querySelector('.article_content') || document.querySelector('#article_content');
return 'Title: ' + document.title + '\n\n' + art.innerText;
})
```
### 4. Extract Page Text
```javascript
// Get article plain text
evaluate_script(() => {
const article = document.querySelector('article') ||
document.querySelector('[class*="content"]') ||
document.querySelector('[class*="article"]') ||
document.querySelector('main');
return article ? article.innerText : document.body.innerText;
})
```
```javascript
// Get all page text (preserving structure)
evaluate_script(() => {
return [...document.querySelectorAll('h1, h2, h3, p, li, pre, code')]
.map(el => el.tagName + ': ' + el.innerText.trim())
.filter(s => s.length > 3)
.join('\n---\n');
})
```
### 5. Zhihu-Specific Bypass
```javascript
evaluate_script(() => {
// Close dialog
document.querySelector('.Modal-closeButton, button[class*="close"]')?.click();
document.querySelector('[class*="signIn"], [class*="Modal"]')?.remove();
// Expand all collapsed answers
document.querySelectorAll('.RichContent.is-collapsed').forEach(el => {
el.classList.remove('is-collapsed');
el.style.height = 'auto';
el.style.maxHeight = 'none';
el.style.overflow = 'visible';
});
document.body.style.overflow = 'auto';
})
```
### 6. WeChat Public Account Articles (Sogou Gateway)
WeChat public account articles are normally login-gated in browsers, but Sogou WeChat Search (the official content index) allows direct access.
```javascript
// Step 1: Search for articles
navigate_page('https://weixin.sogou.com/weixin?type=2&s_from=input&query=' + encodeURIComponent('search keyword'))
// Step 2: Get result list
evaluate_script(() => {
const items = [...document.querySelectorAll('.news-list2 .wx-rb, .news-list2 li')].filter(el => el.querySelector('h3 a'));
return items.slice(0, 10).map(el => ({
title: el.querySelector('h3 a')?.textContent?.trim(),
link: el.querySelector('h3 a')?.href,
source: el.querySelector('.account')?.textContent?.trim(),
date: el.querySelector('.time')?.textContent?.trim(),
summary: el.querySelector('.txt-info')?.textContent?.trim()?.slice(0, 80)
}));
})
// Step 3: Open article link (no login required)
navigate_page('result-link')
// Step 4: Extract content
evaluate_script(() => document.body.innerText)
```
**Verified (2026):** Sogou WeChat Search for `chrome devtools` returns 634 results. Opening the link gives full 2856-character article with no restrictions.
### 7. Mobile Emulation (some sites have fewer restrictions on mobile)
```javascript
emulate({
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1',
viewport: '375x667x2,mobile,touch'
})
```
## Quick Command Reference
| Operation | Tool | Description |
|-----------|------|-------------|
| Open page | `new_page(url)` | Open in new tab |
| Navigate | `navigate_page(url)` | Navigate current tab |
| Wait for content | `wait_for(["text"])` | Wait for text to appear |
| Screenshot | `take_screenshot()` | Full-page screenshot |
| DOM snapshot | `take_snapshot()` | Accessibility tree text structure |
| Execute JS | `evaluate_script(fn)` | Arbitrary JS operations |
| JS with args | `evaluate_script(fn, args)` | Execute with parameters |
| Extract content | `evaluate_script(() => document.body.innerText)` | Plain text extraction |
| Remove element | `evaluate_script(() => el.remove())` | Remove overlay/popup |
| Click element | `click(uid)` | Click by snapshot uid |
| Emulate device | `emulate({userAgent, viewport})` | Switch UA/viewport |
| Scroll | `press_key({key: "Space"})` | Simulate key press |
## FAQ (Practical Experience)
### 1. Popup class names don't match?
First inspect the actual overlay elements:
```javascript
evaluate_script(() => {
[...document.querySelectorAll('div[style*="fixed"], div[style*="absolute"], [class*="overlay"], [class*="modal"], [class*="mask"], [class*="popup"]')]
.map(el => ({tag: el.tagName, cls: el.className.slice(0,80), visible: el.offsetParent !== null}))
})
```
### 2. How to tell if content is complete or truncated?
```javascript
evaluate_script(() => {
const a = document.querySelector('article') || document.querySelector('.Post-RichText');
const ratio = a.scrollHeight / a.clientHeight;
JSON.stringify({
textLen: a.innerText.length,
scrollH: a.scrollHeight, clientH: a.clientHeight,
ratio: ratio.toFixed(2), // > 1.2 means overflow hidden
endText: a.innerText.slice(-100)
})
})
```
If it ends with `-- The End --`, copyright notice, or a natural ending, it's complete.
### 3. CSDN overlay class names (verified 2026)
| CSDN Class | Description |
|------------|-------------|
| `.mask` + `.mask-dark` | Background overlay |
| `.passport-login-tip-container` | Login prompt bar |
| `.passport-login-container` | Login dialog container |
| `.passport-login-box` / `.passport-login-mark` | Login box and overlay |
| `.hide-article-box` | Article collapse bar |
### 4. Zhihu overlay class names (verified 2026)
| Zhihu Class | Description |
|-------------|-------------|
| `.Modal.Modal--default.signFlowModal` | Login dialog |
| `.signFlowModal-container` | Login container |
| Content selector: `.Post-RichText` or `.RichText` | |
### 5. Short article vs truncated article
- Some articles are genuinely short (many images/code, few words) — e.g., 2081 chars but scrollHeight = 8550px
- Verification: check end for natural termination, or confirm via `document.title`
- Zhihu columns without login may redirect to search page — check `location.href`
### 6. What can vs cannot be bypassed
| Type | Principle | Bypassable? | Example |
|------|-----------|-------------|---------|
| DOM overlay | Content in DOM, hidden behind a div | Yes — just remove it | CSDN, Zhihu columns |
| Lazy load | Content loaded on scroll | Yes — trigger scroll | Most comment sections |
| API auth | Content fetched via cookie-authenticated API | No — no cookie = no data | Bilibili comments, Weibo |
| SSR hidden | Server-rendered but hidden via class | Yes — change style | Juejin paid articles |
### 7. Chrome restart / disconnect handling
MCP mode manages browser lifecycle automatically. CLI mode:
```bash
chrome-devtools stop # Stop background process
chrome-devtools status # Check status
```
---
# Part 2 — Debugging Guide
Based on `chrome-devtools-mcp` toolset for debugging web pages, inspecting errors, and analyzing performance.
## Tool Overview
```
Category Tool Purpose
────── ─── ───
Navigation new_page / navigate_page Open/navigate pages
close_page / select_page Close/switch tabs
list_pages List all tabs
wait_for Wait for text
Debugging evaluate_script Execute JS in page
take_snapshot Get accessibility tree (uid)
take_screenshot Screenshot
list_console_messages List console logs
get_console_message(msgid) View specific log details
lighthouse_audit Lighthouse audit
Interaction click(uid) Click element
fill(uid, value) Fill input field
fill_form([{uid,value}]) Batch form fill
type_text(text) Keyboard input
press_key(key) Key press (Enter/Tab/Ctrl+A)
hover(uid) Hover
drag(from_uid, to_uid) Drag
handle_dialog(action) Handle browser dialogs
upload_file(path, uid) Upload file
Network list_network_requests List network requests
get_network_request(reqid) View request details/response
Performance performance_start_trace Start performance recording
performance_stop_trace Stop + analyze
performance_analyze_insight Analyze specific metric
take_memory_snapshot Heap snapshot
Emulation emulate({userAgent, viewport}) Simulate device
resize_page(width, height) Resize window
```
## Standard Debugging Flows
### Flow 1: JS Error Investigation
```
1. navigate_page(url) → Enter page
2. list_console_messages() → View errors
3. get_console_message(msgid) → View error details
4. evaluate_script(() => { /* fix */ }) → Fix the issue
5. verify
```
### Flow 2: Network Request Analysis
```
1. navigate_page(url) → Load page
2. list_network_requests() → List all requests
3. get_network_request(reqid) → View request/response body
4. Identify 404s, CORS errors, slow requests
```
### Flow 3: DOM / Style Debugging
```
1. take_snapshot() → Get element structure (with uid)
2. click(uid) / fill(uid, value) → Interact
3. evaluate_script(() => getComputedStyle(el)) → Check styles
4. evaluate_script(() => { el.style.color = 'red' }) → Temporary modification
5. take_screenshot() → Confirm visually
```
### Flow 4: Performance Analysis
```
1. performance_start_trace({reload: true}) → Start recording + reload
2. (wait for page to load)
3. performance_stop_trace() → Stop and analyze
4. performance_analyze_insight({insightName, insightSetId}) → Deep dive
```
## Debugging Quick Reference
### Console
```javascript
// View all console messages
list_console_messages({includePreservedMessages: true})
// View specific message
get_console_message({msgid: 0})
```
### Element Inspection
```javascript
// Get interactive elements list (with uid)
take_snapshot()
// Verbose version (more properties)
take_snapshot({verbose: true})
// Inspect element styles
evaluate_script(() => {
const el = document.querySelector('h1');
return getComputedStyle(el);
})
// Get element dimensions / position
evaluate_script(() => {
const el = document.querySelector('h1');
return el.getBoundingClientRect();
})
```
### Page Interaction
```javascript
// Click (get uid via take_snapshot first)
click({uid: "element-123"})
// Fill input
fill({uid: "input-456", value: "search text"})
// Fill + Enter
fill({uid: "input-456", value: "search text"})
press_key({key: "Enter"})
// Keyboard shortcuts
press_key({key: "Control+A"})
press_key({key: "Control+C"})
// Handle browser dialogs (alert/confirm)
handle_dialog({action: "accept"})
handle_dialog({action: "dismiss"})
```
### Network
```javascript
// View all network requests
list_network_requests({pageSize: 50, resourceTypes: ["XHR", "Fetch", "Document"]})
// View request details
get_network_request({reqid: 0})
// Save response body to file
get_network_request({reqid: 0, responseFilePath: "response.json"})
```
### Memory Debugging
```javascript
// Capture heap snapshot (for memory leak analysis)
take_memory_snapshot({filePath: "heap.heapsnapshot"})
```
### Lighthouse Audit
```javascript
// Accessibility + SEO + Best Practices
lighthouse_audit({device: "desktop"})
lighthouse_audit({device: "mobile"})
lighthouse_audit({mode: "snapshot"}) // No reload, analyze current state
```
## Typical Scenarios
### Scenario A: White Screen / JS Error Fix
```
1. list_console_messages() → Check for JS errors
2. get_console_message(0) → View first error details
3. evaluate_script(() => { ... }) → Temporary fix in page
4. Fix in source code, reload, verify
```
### Scenario B: API Endpoint Debugging
```
1. navigate_page('https://example.com')
2. list_network_requests({resourceTypes: ["XHR", "Fetch"]}) → Filter API calls
3. get_network_request(0) → View request params + response data
```
### Scenario C: Form Submission Verification
```
1. take_snapshot() → Get form element uids
2. fill({uid, value}) → Fill each field
3. click({uid}) → Click submit button
4. list_network_requests() → Check if request was sent
5. list_console_messages() → Check for errors
```
### Scenario D: Responsive Layout Debugging
```
1. emulate({viewport: '375x667x2,mobile,touch'}) → Switch to mobile
2. take_screenshot() → Screenshot for review
3. emulate({viewport: '1280x720'}) → Switch back to desktop
4. take_screenshot() → Compare results
```

View File

@ -0,0 +1,180 @@
---
name: deep-systems-debugger
description: Use when debugging multi-layer or distributed systems where the root cause may reside in a different architectural layer than the symptom, or when standard debugging has not identified the root cause after initial investigation
---
# Deep Systems Debugger
## Overview
In multi-layer systems (CI/CD, distributed services, complex pipelines), the root cause almost never lives in the same layer as the symptom. Random patching wastes time. This skill provides a structured four-phase protocol for tracing failures across architectural boundaries with surgical precision.
**Core principle:** Map every layer and trace every boundary before forming any hypothesis. Be the detective, not the gambler.
## The Iron Law
```
NO FIXES WITHOUT COMPLETED ROOT-CAUSE INVESTIGATION
```
If you have not finished Phase 1, you are forbidden from proposing code changes, configuration tweaks, or operational patches.
## When to Use
- Error manifests in a different layer than where the cause likely lives
- System has 3+ architectural layers (CI/CD pipeline, API gateway → service → DB, distributed services)
- Error message is a transport-level symptom (HTTP error, timeout, decode failure, connection refused)
- Standard investigation has been attempted but root cause remains unclear
- Intermittent or environment-specific failures
- The failure involves configuration, build, or deployment scripts
- Multiple failed fix attempts have already been made
**Do NOT use for:** Simple single-layer bugs (use `systematic-debugging` instead)
## Prerequisites
This skill builds on `systematic-debugging`. If you haven't completed Phase 1-2 of that skill, start there first.
## Quick Reference
| Phase | Focus | Key Technique | Output |
|-------|-------|--------------|--------|
| **1. Root-Cause Mapping** | Observe only | Recursive diff, error routing, boundary instrumentation | Evidence log, divergence point |
| **2. Pattern Analysis** | Analyze before theorizing | Backward tracing, working reference comparison | Single clear hypothesis |
| **3. Scientific Validation** | Minimal experiment | One variable change | Confirmed or rejected hypothesis |
| **4. Permanent Fix** | Lock in root cause | Failing test, isolated fix, regression suite | Fixed bug + test |
## Phase 1: Root-Cause Mapping & Evidence Gathering
*Do not propose fixes. Only observe and trace.*
### 0. Perform Full Recursive Diff of All Layers
Before reading any code, diff the **entire** broken codebase against a known-good reference (previous version, sibling branch, stable release). Sort diff output by architectural layer, outermost to innermost:
```
[CI/Dockerfile] → [Build scripts] → [HTTP client config] → [API wiring] → [Middleware/policy] → [Feature dispatch] → [Business logic]
```
Examine **every** difference, especially in configuration files, builder chains, dependency versions, environment variable handling, and client setup code. Do not filter by suspected feature area.
### 1. Route by Error Type, Then Map from Outermost Layer
Let the **error message text** determine the starting layer:
| Error Keyword | Starting Layer |
|--------------|----------------|
| `http error`, `decode`, `timeout`, `connection refused` | HTTP client config / transport layer |
| `permission denied`, `auth`, `policy` | Middleware / enforcer / policy layer |
| `parse`, `serialize`, `invalid format` | Serialization / API boundary |
| `null pointer`, `index out of bounds`, `unreachable` | Business logic layer |
Trace outward from that layer: identify every architectural layer from outermost trigger down to deepest call. List all middleware, adapters, policy enforcers, aliases, and caching layers.
### 2. Identify All Data Boundaries
For each function, module, or service in the chain, explicitly define:
- **Input**: What enters (type, format, size, origin)
- **Output**: What exits (type, format, serialization, destination)
- **Side Effects**: State mutations, cache writes, external I/O, logging, metric emissions
### 3. Instrument with Diagnostic Tracing
At **EVERY** critical boundary, insert tracing logic (structured logs, print statements, metric counters, span attributes). Record:
- Entry/exit timestamps
- Key input metadata (ID, length, checksum, source)
- Key output metadata (status code, size, target location)
- Environment/context values (auth tokens, feature flags, config overrides)
**Post-trace sanity check:** Before analyzing, scan which layers produced output vs. produced no output. If the outermost transport layer shows the first error, do NOT dig deeper — the failure is already localized.
For large payloads, log size, hash, or truncated preview — never flood logs with raw data.
### 4. Gather Empirical Evidence
Execute the reproduction path once with instrumentation active. Compare observed outputs against expected outputs at every boundary. Note where the two first diverge — that is your initial suspect region.
## Phase 2: Pattern Analysis & Hypothesis Formation
*Analyze evidence before forming a theory.*
1. **Locate Divergence Point** — Find the **first** boundary where reality differs from expectation.
2. **Perform Backward Tracing** — If error manifests deep in stack, ask repeatedly: *"What component supplied this incorrect value?"* Follow chain upward to the original source of invalid state.
3. **Compare Against Working References** — Identify a similar known-good path. List **every** difference, no matter how trivial.
4. **Formulate a Single Clear Hypothesis** — Write explicitly: *"The root cause is likely [X], because the trace shows [Y] at [Z], and this differs from the working example where [W] happens."*
## Phase 3: Scientific Validation (Minimal Experimentation)
*Test the hypothesis with surgical restraint.*
1. **Design the smallest possible test** — Make **one** isolated change to validate your hypothesis. Change only one variable at a time.
2. **Run the reproduction** — If the change resolves the issue → proceed to Phase 4. If not → **STOP**. Discard that hypothesis. Return to Phase 2 with fresh evidence.
3. **NEVER** apply multiple fixes in one test run — you lose the ability to isolate causality.
## Phase 4: Permanent Implementation & Verification
*Fix the root cause and lock it in.*
1. **Create a failing test case** — Minimal automated test that reliably reproduces the original failure.
2. **Apply the single, root-cause fix** — Modify only what is necessary. No opportunistic refactoring.
3. **Run full verification** — New test passes. Existing regression suite passes. Original symptom is gone.
4. **If the fix fails after 3 attempts****STOP**. Escalate to architectural review. Repeated failures suggest a deeper structural flaw (improper layering, incorrect state ownership, broken abstraction).
## Command Patterns (Action Sequence)
When beginning a deep debugging session, follow this sequence:
1. **`DIFFING`** — Recursive diff broken vs working across ALL files, sorted outermost to innermost
2. **`MAPPING`** — Route by error type, search codebase, construct end-to-end call chain table
3. **`INSTRUMENTING`** — Generate tracing/logging at every identified boundary
4. **`ANALYZING`** — Execute reproduction, capture traces, pinpoint first divergence
5. **`HYPOTHESIZING`** — State single clear hypothesis with supporting evidence
6. **`VALIDATING`** — Implement minimal change to test hypothesis; report result
7. **`FIXING`** — Commit permanent isolated fix and accompanying regression test
## Universal Constraints
- **Separate data flow from presentation flow** — UI layers consume final output; they are rarely the source of logical corruption. Focus on the core transactional data pipeline.
- **Track all hidden state** — Explicitly log cache hits/misses, environment variables, config precedence, feature flags, and global singletons.
- **Reproducibility first** — If intermittent, increase observability across multiple runs. Do not guess at race conditions.
- **Environment parity** — Always verify if the bug exists only in specific environments. Compare configs, resource limits, and dependency versions.
## Red Flags (Immediate Halt)
If you catch yourself thinking any of these, STOP and return to Phase 1:
- "Let's just change this one thing and see if the test passes."
- "It's probably a race condition; let's add a sleep."
- "I'll write the test after I confirm it works manually."
- "I'll fix these two related issues together since I'm here."
- "This is trivial; I don't need to trace the whole flow."
- "I've tried two patches already — maybe a third will stick."
## Output Structure
When reporting findings, use this format:
### 1. Execution Chain Overview
`[Layer A] → [Layer B] → [Layer C] → ... → [Layer N]`
### 2. Boundary Trace Table
| Boundary | Input | Expected Output | Actual Output | Status |
|----------|-------|----------------|---------------|--------|
| ... | ... | ... | ... | ✅/❌ |
### 3. Root-Cause Hypothesis
*[Concise statement of the suspected origin, supported by trace evidence.]*
### 4. Validation Experiment
*[Description of the minimal change made and the observed result.]*
### 5. Final Resolution
*[The committed fix, the regression test added, and confirmation of success.]*
## Related Skills
- **`systematic-debugging`** — General-purpose debugging process (use this first for most bugs)
- **`test-driven-development`** — For creating failing test cases in Phase 4
- **`verification-before-completion`** — Verify fix worked before claiming success

View File

@ -0,0 +1,613 @@
# Performance Tuning Guidelines
## When to Use
When opencode performance needs optimization for:
- Faster response times and lower latency
- Reduced memory usage and better resource management
- Improved large project handling
- Better concurrent operation support
- Optimal configuration for your hardware and workflow
## How It Works
opencode's performance can be tuned across multiple dimensions: memory usage, CPU efficiency, disk I/O, network latency, and configuration optimization. This skill provides comprehensive guidelines for each area.
## System-Level Optimization
### 1. Memory Management
```json
{
"memory": {
"limits": {
"maxHeapSize": "2G",
"maxOldSpaceSize": "1G",
"maxSemiSpaceSize": "256M",
"maxNewSpaceSize": "128M"
},
"garbageCollection": {
"strategy": "balanced", // "throughput", "lowLatency", "balanced"
"incremental": true,
"parallel": true,
"concurrent": true
},
"cache": {
"fileSystem": {
"enabled": true,
"maxSize": "500MB",
"ttl": 3600
},
"parsedFiles": {
"enabled": true,
"maxCount": 1000,
"maxSize": "100MB"
},
"network": {
"enabled": true,
"maxSize": "50MB"
}
}
}
}
```
### 2. CPU Optimization
```json
{
"cpu": {
"threading": {
"workerThreads": 4,
"ioThreads": 2,
"maxConcurrentOperations": 10
},
"scheduling": {
"priority": "normal", // "low", "normal", "high", "realtime"
"affinity": "auto", // "auto" or CPU mask
"yieldStrategy": "cooperative"
},
"profiling": {
"enabled": false,
"sampleRate": 100, // samples per second
"output": "cpu-profile.json"
}
}
}
```
### 3. Disk I/O Optimization
```json
{
"disk": {
"buffering": {
"writeBufferSize": "64KB",
"readBufferSize": "64KB",
"asyncIO": true,
"directIO": false
},
"caching": {
"directoryCache": true,
"fileContentCache": true,
"metadataCache": true,
"maxCacheSize": "200MB"
},
"filesystem": {
"watchInterval": 1000, // ms
"recursiveWatch": true,
"ignorePatterns": ["node_modules", ".git", "dist", "build"]
}
}
}
```
## Network Optimization
### 1. API Request Optimization
```json
{
"network": {
"api": {
"timeout": 30000, // ms
"retries": 3,
"backoff": {
"initial": 1000,
"multiplier": 2,
"max": 10000
},
"compression": true,
"keepAlive": true,
"poolSize": 10
},
"streaming": {
"chunkSize": 1024,
"bufferSize": 8192,
"timeout": 60000
},
"cdn": {
"enabled": true,
"fallback": true,
"prefetch": true
}
}
}
```
### 2. Proxy and Connection Management
```json
{
"proxy": {
"http": "${HTTP_PROXY}",
"https": "${HTTPS_PROXY}",
"noProxy": "localhost,127.0.0.1",
"tunnel": true
},
"dns": {
"cache": true,
"ttl": 300,
"preferIPv6": false
},
"tls": {
"minVersion": "TLSv1.2",
"ciphers": "HIGH:!aNULL:!MD5",
"sessionCache": true,
"sessionTimeout": 300
}
}
```
## Configuration Optimization
### 1. Startup Performance
```json
{
"startup": {
"lazyLoading": {
"enabled": true,
"modules": ["mcp", "lsp", "plugins"],
"delay": 1000 // ms
},
"preload": {
"coreModules": true,
"frequentFiles": true,
"recentProjects": 3
},
"parallelInitialization": true,
"progressReporting": true
}
}
```
### 2. Plugin Performance
```json
{
"plugins": {
"loading": {
"parallel": true,
"timeout": 10000,
"maxConcurrent": 5
},
"isolation": {
"sandbox": true,
"memoryLimit": "256MB",
"timeout": 5000
},
"optimization": {
"treeShaking": true,
"deadCodeElimination": true,
"minification": true
}
}
}
```
## Large Project Optimization
### 1. File System Scanning
```json
{
"largeProjects": {
"fileSystem": {
"maxFiles": 10000,
"maxDepth": 10,
"ignorePatterns": [
"**/node_modules/**",
"**/.git/**",
"**/dist/**",
"**/build/**",
"**/*.min.js",
"**/*.bundle.js"
],
"scanStrategy": "incremental", // "full", "incremental", "cached"
"scanInterval": 5000
},
"indexing": {
"enabled": true,
"background": true,
"priority": "low",
"batchSize": 100
}
}
}
```
### 2. Memory-Efficient Operations
```json
{
"efficientOperations": {
"streaming": {
"fileReading": true,
"fileWriting": true,
"processing": true
},
"chunking": {
"largeFiles": true,
"threshold": 1048576, // 1MB
"chunkSize": 65536 // 64KB
},
"pagination": {
"searchResults": 50,
"fileList": 100,
"chatHistory": 100
}
}
}
```
## Monitoring and Profiling
### 1. Performance Metrics
```json
{
"metrics": {
"collection": {
"enabled": true,
"interval": 60000, // 1 minute
"retention": "7d"
},
"track": [
"memory.heapUsed",
"memory.external",
"cpu.usage",
"disk.io",
"network.latency",
"response.time",
"cache.hitRate"
],
"alerts": {
"memory": {"warning": "80%", "critical": "90%"},
"cpu": {"warning": "70%", "critical": "90%"},
"latency": {"warning": "1000ms", "critical": "5000ms"}
}
}
}
```
### 2. Profiling Tools
```bash
#!/bin/bash
# ~/.opencode/profile.sh
# Memory profiling
opencode profile-memory --output memory-profile.json
# CPU profiling
opencode profile-cpu --duration 30 --output cpu-profile.json
# I/O profiling
opencode profile-io --output io-profile.json
# Network profiling
opencode profile-network --output network-profile.json
# Generate report
opencode profile-report \
--memory memory-profile.json \
--cpu cpu-profile.json \
--io io-profile.json \
--network network-profile.json \
--output performance-report.html
```
## Hardware-Specific Tuning
### 1. Low-End Hardware
```json
{
"lowEndHardware": {
"memory": {
"maxHeapSize": "512M",
"cacheSizes": {
"fileSystem": "50MB",
"parsedFiles": "10MB",
"network": "5MB"
}
},
"cpu": {
"workerThreads": 2,
"maxConcurrentOperations": 3
},
"features": {
"syntaxHighlighting": false,
"animations": false,
"previewPanes": false,
"autoComplete": "basic"
}
}
}
```
### 2. High-End Workstation
```json
{
"highEndWorkstation": {
"memory": {
"maxHeapSize": "4G",
"cacheSizes": {
"fileSystem": "2G",
"parsedFiles": "500MB",
"network": "100MB"
}
},
"cpu": {
"workerThreads": 8,
"maxConcurrentOperations": 20
},
"features": {
"parallelProcessing": true,
"backgroundIndexing": true,
"predictiveLoading": true,
"advancedCaching": true
}
}
}
```
## Workflow-Specific Optimization
### 1. Development Workflow
```json
{
"development": {
"incrementalCompilation": true,
"hotReload": true,
"livePreview": true,
"autoSave": {
"enabled": true,
"delay": 1000
},
"testing": {
"parallel": true,
"watch": true,
"coverage": true
}
}
}
```
### 2. Code Review Workflow
```json
{
"codeReview": {
"diffOptimization": {
"unified": true,
"contextLines": 3,
"ignoreWhitespace": true
},
"analysis": {
"parallel": true,
"cacheResults": true,
"incremental": true
},
"presentation": {
"sideBySide": true,
"syntaxHighlighting": true,
"collapsibleSections": true
}
}
}
```
## Advanced Optimization Techniques
### 1. Just-In-Time Compilation
```json
{
"jit": {
"enabled": true,
"threshold": 100, // Number of executions before JIT
"optimizationLevel": 2, // 0-3
"profiling": {
"enabled": true,
"feedback": true
}
}
}
```
### 2. Predictive Loading
```json
{
"predictiveLoading": {
"enabled": true,
"strategies": {
"fileAccess": {
"patternBased": true,
"frequencyBased": true,
"recencyBased": true
},
"moduleLoading": {
"dependencyAnalysis": true,
"usagePatterns": true
}
},
"cache": {
"preloadedFiles": 10,
"preloadedModules": 5
}
}
}
```
## Benchmarking and Testing
### 1. Performance Test Suite
```bash
#!/bin/bash
# ~/.opencode/benchmark.sh
echo "Running opencode performance benchmarks..."
echo "=========================================="
# Startup time
echo -n "Startup time: "
time opencode --version > /dev/null
# Memory usage
echo -n "Memory usage: "
opencode profile-memory --quick | grep "heapUsed"
# File loading
echo -n "File loading (100KB): "
time opencode eval "fs.readFileSync('test-100kb.txt', 'utf8')" > /dev/null
# Syntax highlighting
echo -n "Syntax highlighting: "
time opencode eval "highlight('test.js')" > /dev/null
# Code analysis
echo -n "Code analysis: "
time opencode eval "analyze('test.js')" > /dev/null
echo "Benchmark complete."
```
### 2. Regression Testing
```json
{
"regressionTesting": {
"enabled": true,
"tests": [
{
"name": "startupTime",
"command": "opencode --version",
"maxTime": 2000,
"metric": "duration"
},
{
"name": "memoryUsage",
"command": "opencode profile-memory --quick",
"maxValue": 100,
"metric": "heapUsedMB"
},
{
"name": "fileLoad",
"command": "opencode eval \"fs.readFileSync('test.txt', 'utf8')\"",
"maxTime": 100,
"metric": "duration"
}
],
"schedule": "daily",
"alertOnRegression": true
}
}
```
## Troubleshooting Performance Issues
### 1. Diagnostic Commands
```bash
# Check current performance stats
opencode perf-stats
# Generate performance report
opencode perf-report --output report.html
# Identify bottlenecks
opencode perf-bottlenecks
# Compare configurations
opencode perf-compare config1.json config2.json
# Reset to defaults
opencode perf-reset
```
### 2. Common Issues and Solutions
**High Memory Usage:**
- Reduce cache sizes
- Enable garbage collection tuning
- Limit concurrent operations
- Disable memory-intensive features
**Slow Startup:**
- Enable lazy loading
- Reduce preloaded modules
- Disable unnecessary plugins
- Use faster storage (SSD)
**High CPU Usage:**
- Reduce worker threads
- Disable background indexing
- Limit syntax highlighting complexity
- Use simpler algorithms
**Network Latency:**
- Enable compression
- Use connection pooling
- Implement caching
- Reduce request size
## Best Practices
### 1. Regular Maintenance
- Monitor performance metrics regularly
- Clean up cache files periodically
- Update to latest versions
- Review and optimize configuration
- Remove unused plugins and extensions
### 2. Progressive Optimization
1. **Baseline**: Establish current performance metrics
2. **Identify**: Use profiling to find bottlenecks
3. **Prioritize**: Focus on highest-impact optimizations
4. **Implement**: Apply optimizations incrementally
5. **Verify**: Test after each change
6. **Monitor**: Continuously track performance
### 3. Configuration Management
- Keep configurations in version control
- Document optimization decisions
- Create environment-specific configurations
- Use inheritance for common settings
- Validate configurations regularly
## Resources
- [opencode Performance Guide](https://opencode.ai/docs/performance)
- [Node.js Performance Best Practices](https://nodejs.org/en/docs/guides/performance-best-practices)
- [Chrome DevTools Performance](https://developer.chrome.com/docs/devtools/performance/)
- [Memory Management Guide](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Memory_Management)
- [Profiling Tools Comparison](https://github.com/thlorenz/v8-perf)

View File

@ -0,0 +1,22 @@
{
"url_1": {
"enable": true,
"url": "https://www.bing.com/search?q={search} site:ithome.com"
},
"url_2": {
"enable": true,
"url": "https://www.google.com/search?q={search}"
},
"url_3": {
"enable": false,
"url": "https://www.sogou.com/web?query={search}"
},
"url_4": {
"enable": true,
"url": "https://search.yahoo.co.jp/search?p={search}"
},
"url_5": {
"enable": false,
"url": "https://search.naver.com/search.naver?query={search}"
}
}

View File

@ -0,0 +1,21 @@
[package]
name = "agents"
version.workspace = true
edition.workspace = true
license.workspace = true
publish.workspace = true
[features]
test-utils = []
[dependencies]
api = { path = "../api" }
plugins = { path = "../plugins" }
runtime = { path = "../runtime" }
futures = "0.3"
serde = { version = "1", features = ["derive"] }
serde_json.workspace = true
tokio = { version = "1", features = ["rt-multi-thread"] }
[lints]
workspace = true

View File

@ -0,0 +1,501 @@
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use runtime::strip_verbatim_prefix;
fn read_file_lossy(path: &Path) -> Result<String, std::io::Error> {
let bytes = std::fs::read(path)?;
Ok(String::from_utf8_lossy(&bytes).to_string())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum DefinitionSource {
ProjectClaw,
ProjectClaude,
UserClawConfigHome,
UserClaw,
UserClaude,
Plugin,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum DefinitionScope {
Project,
UserConfigHome,
UserHome,
Plugin,
}
impl DefinitionScope {
pub fn label(self) -> &'static str {
match self {
Self::Project => "Project roots",
Self::UserConfigHome => "User config roots",
Self::UserHome => "User home roots",
Self::Plugin => "Plugin agents",
}
}
}
impl DefinitionSource {
pub fn report_scope(self) -> DefinitionScope {
match self {
Self::ProjectClaw | Self::ProjectClaude => {
DefinitionScope::Project
}
Self::UserClawConfigHome => DefinitionScope::UserConfigHome,
Self::UserClaw | Self::UserClaude => DefinitionScope::UserHome,
Self::Plugin => DefinitionScope::Plugin,
}
}
pub fn label(self) -> &'static str {
self.report_scope().label()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AgentSummary {
pub name: String,
pub description: Option<String>,
pub model: Option<String>,
pub reasoning_effort: Option<String>,
pub source: DefinitionSource,
pub shadowed_by: Option<DefinitionSource>,
pub plugin: Option<String>,
pub mode: Option<String>,
}
impl AgentSummary {
pub fn name(&self) -> &str {
&self.name
}
pub fn description(&self) -> Option<&str> {
self.description.as_deref()
}
}
pub struct AgentDiscovery {
agents: Vec<AgentSummary>,
active_names: Vec<String>,
}
impl AgentDiscovery {
pub fn new(cwd: &Path) -> Self {
let mut agents = Vec::new();
let roots = discover_definition_roots(cwd, "agents");
if let Ok(mut found) = load_agents_from_roots(&roots) {
agents.append(&mut found);
}
agents.sort_by(|a, b| a.name.cmp(&b.name));
let active_names = agents
.iter()
.filter(|a| a.shadowed_by.is_none())
.map(|a| a.name.clone())
.collect();
Self { agents, active_names }
}
pub fn with_plugins(
cwd: &Path,
plugin_agent_paths: &BTreeMap<String, Vec<PathBuf>>,
) -> Self {
let mut agents = Vec::new();
let roots = discover_definition_roots(cwd, "agents");
if let Ok(mut found) = load_agents_from_roots(&roots) {
agents.append(&mut found);
}
let root_names: BTreeSet<String> = agents
.iter()
.filter(|a| a.shadowed_by.is_none())
.map(|a| a.name.to_ascii_lowercase())
.collect();
let plugin_agents = load_plugin_agents(plugin_agent_paths);
for mut agent in plugin_agents {
if root_names.contains(&agent.name.to_ascii_lowercase()) {
agent.shadowed_by = Some(DefinitionSource::ProjectClaw);
}
agents.push(agent);
}
agents.sort_by(|a, b| a.name.cmp(&b.name));
let active_names = agents
.iter()
.filter(|a| a.shadowed_by.is_none())
.map(|a| a.name.clone())
.collect();
Self { agents, active_names }
}
pub fn all(&self) -> &[AgentSummary] {
&self.agents
}
pub fn active(&self) -> Vec<&AgentSummary> {
self.agents
.iter()
.filter(|a| a.shadowed_by.is_none())
.collect()
}
pub fn active_names(&self) -> &[String] {
&self.active_names
}
pub fn active_names_list(&self) -> Vec<String> {
self.active_names.clone()
}
pub fn find(&self, name: &str) -> Option<&AgentSummary> {
let lowered = name.to_ascii_lowercase();
self.agents
.iter()
.find(|a| a.shadowed_by.is_none() && a.name.to_ascii_lowercase() == lowered)
}
}
fn discover_definition_roots(cwd: &Path, leaf: &str) -> Vec<(DefinitionSource, PathBuf)> {
let mut roots = Vec::new();
let home_boundaries: Vec<PathBuf> = std::env::var_os("HOME")
.into_iter()
.chain(std::env::var_os("USERPROFILE"))
.filter_map(|p| std::fs::canonicalize(PathBuf::from(p)).ok())
.collect();
for ancestor in cwd.ancestors() {
if home_boundaries.iter().any(|b| {
if let Ok(canon_ancestor) = std::fs::canonicalize(ancestor) {
b == &canon_ancestor
} else {
false
}
}) {
break;
}
push_unique_root(&mut roots, DefinitionSource::ProjectClaw, ancestor.join(".claw").join(leaf));
push_unique_root(&mut roots, DefinitionSource::ProjectClaude, ancestor.join(".claude").join(leaf));
}
if let Ok(claw_config_home) = std::env::var("CLAW_CONFIG_HOME") {
push_unique_root(&mut roots, DefinitionSource::UserClawConfigHome, PathBuf::from(claw_config_home).join(leaf));
}
if let Ok(claude_config_dir) = std::env::var("CLAUDE_CONFIG_DIR") {
push_unique_root(&mut roots, DefinitionSource::UserClaude, PathBuf::from(claude_config_dir).join(leaf));
}
let home = home_boundaries.first().cloned();
if let Some(ref home) = home {
let home = strip_verbatim_prefix(home.clone());
push_unique_root(&mut roots, DefinitionSource::UserClaw, home.join(".claw").join(leaf));
push_unique_root(&mut roots, DefinitionSource::UserClaude, home.join(".claude").join(leaf));
}
roots
}
/// Returns the root directories that may contain agent definitions,
/// in discovery-priority order (project → config-home → user-home).
/// Uses the same search logic as [`AgentDiscovery`].
pub fn discover_agent_roots(cwd: &Path) -> Vec<PathBuf> {
discover_definition_roots(cwd, "agents")
.into_iter()
.map(|(_, path)| path)
.collect()
}
fn push_unique_root(
roots: &mut Vec<(DefinitionSource, PathBuf)>,
source: DefinitionSource,
path: PathBuf,
) {
if path.is_dir() && !roots.iter().any(|(_, existing)| existing == &path) {
roots.push((source, path));
}
}
fn load_agents_from_roots(
roots: &[(DefinitionSource, PathBuf)],
) -> Result<Vec<AgentSummary>, String> {
let mut agents = Vec::new();
let mut active_sources = BTreeMap::<String, DefinitionSource>::new();
for (source, root) in roots {
let mut root_agents = Vec::new();
let dir = match std::fs::read_dir(root) {
Ok(d) => d,
Err(e) => {
eprintln!("[agents] warning: could not read {root:?}: {e}");
continue;
}
};
for entry in dir.flatten() {
let path = entry.path();
if path.is_dir() {
let skill_path = path.join("SKILL.md");
if skill_path.is_file() {
if let Ok(contents) = read_file_lossy(&skill_path) {
let fm = plugins::frontmatter::parse_frontmatter(&contents)
.ok()
.map(|p| p.frontmatter);
let name = fm
.as_ref()
.and_then(|f| f.name.clone())
.unwrap_or_else(|| entry.file_name().to_string_lossy().to_string());
root_agents.push(AgentSummary {
name,
description: fm.as_ref().and_then(|f| f.description.clone()),
model: fm.as_ref().and_then(|f| f.model.clone()),
reasoning_effort: fm.as_ref().and_then(|f| f.reasoning_effort.clone()),
mode: fm.as_ref().and_then(|f| f.mode.clone()),
source: *source,
shadowed_by: None,
plugin: None,
});
}
continue;
}
}
if path.extension().is_some_and(|ext| ext == "md") {
if let Ok(contents) = read_file_lossy(&path) {
let fm = plugins::frontmatter::parse_frontmatter(&contents)
.ok()
.map(|p| p.frontmatter);
let fallback_name = path
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| entry.file_name().to_string_lossy().to_string());
root_agents.push(AgentSummary {
name: fm
.as_ref()
.and_then(|f| f.name.clone())
.unwrap_or(fallback_name),
description: fm.as_ref().and_then(|f| f.description.clone()),
model: fm.as_ref().and_then(|f| f.model.clone()),
reasoning_effort: fm.as_ref().and_then(|f| f.reasoning_effort.clone()),
mode: fm.as_ref().and_then(|f| f.mode.clone()),
source: *source,
shadowed_by: None,
plugin: None,
});
}
continue;
}
if path.extension().is_none_or(|ext| ext != "toml") {
continue;
}
if let Ok(contents) = read_file_lossy(&path) {
let fallback_name = path.file_stem().map_or_else(
|| entry.file_name().to_string_lossy().to_string(),
|stem| stem.to_string_lossy().to_string(),
);
root_agents.push(AgentSummary {
name: parse_toml_string(&contents, "name").unwrap_or(fallback_name),
description: parse_toml_string(&contents, "description"),
model: parse_toml_string(&contents, "model"),
reasoning_effort: parse_toml_string(&contents, "model_reasoning_effort"),
mode: parse_toml_string(&contents, "mode"),
source: *source,
shadowed_by: None,
plugin: None,
});
}
}
root_agents.sort_by(|left, right| left.name.cmp(&right.name));
for mut agent in root_agents {
let key = agent.name.to_ascii_lowercase();
if let Some(existing) = active_sources.get(&key) {
agent.shadowed_by = Some(*existing);
} else {
active_sources.insert(key, agent.source);
}
agents.push(agent);
}
}
Ok(agents)
}
fn load_plugin_agents(
plugin_agent_paths: &BTreeMap<String, Vec<PathBuf>>,
) -> Vec<AgentSummary> {
let mut agents = Vec::new();
for (plugin_id, paths) in plugin_agent_paths {
for path in paths {
if !path.is_file() {
continue;
}
let contents = match read_file_lossy(path) {
Ok(c) => c,
Err(e) => {
eprintln!("[plugin agents] error reading {}: {e}", path.display());
continue;
}
};
let fm = plugins::frontmatter::parse_frontmatter(&contents)
.ok()
.map(|p| p.frontmatter);
let fallback_name = path
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "unknown".to_string());
agents.push(AgentSummary {
name: fm
.as_ref()
.and_then(|f| f.name.clone())
.unwrap_or(fallback_name),
description: fm.as_ref().and_then(|f| f.description.clone()),
model: fm.as_ref().and_then(|f| f.model.clone()),
reasoning_effort: fm.as_ref().and_then(|f| f.reasoning_effort.clone()),
mode: fm.as_ref().and_then(|f| f.mode.clone()),
source: DefinitionSource::Plugin,
shadowed_by: None,
plugin: Some(plugin_id.clone()),
});
}
}
agents
}
fn parse_toml_string(contents: &str, key: &str) -> Option<String> {
let prefix = format!("{key} =");
for line in contents.lines() {
let trimmed = line.trim();
if trimmed.starts_with('#') {
continue;
}
let Some(value) = trimmed.strip_prefix(&prefix) else {
continue;
};
let value = value.trim();
let Some(value) = value
.strip_prefix('"')
.and_then(|value| value.strip_suffix('"'))
else {
continue;
};
if !value.is_empty() {
return Some(value.to_string());
}
}
None
}
pub fn render_agents_report(agents: &[AgentSummary]) -> String {
if agents.is_empty() {
return "No agents found.".to_string();
}
let total_active = agents
.iter()
.filter(|agent| agent.shadowed_by.is_none())
.count();
let mut lines = vec![
"Agents".to_string(),
format!(" {total_active} active agents"),
String::new(),
];
for scope in [
DefinitionScope::Project,
DefinitionScope::UserConfigHome,
DefinitionScope::UserHome,
DefinitionScope::Plugin,
] {
let group = agents
.iter()
.filter(|agent| agent.source.report_scope() == scope)
.collect::<Vec<_>>();
if group.is_empty() {
continue;
}
lines.push(format!("{}:", scope.label()));
for agent in group {
let detail = agent_detail(agent);
match agent.shadowed_by {
Some(winner) => lines.push(format!(" (shadowed by {}) {detail}", winner.label())),
None => lines.push(format!(" {detail}")),
}
}
lines.push(String::new());
}
lines.join("\n").trim_end().to_string()
}
pub fn render_agents_report_json(
cwd: &Path,
agents: &[AgentSummary],
) -> serde_json::Value {
let active = agents
.iter()
.filter(|agent| agent.shadowed_by.is_none())
.count();
serde_json::json!({
"kind": "agents",
"action": "list",
"count": agents.len(),
"summary": {
"total": agents.len(),
"active": active,
"shadowed": agents.len().saturating_sub(active),
},
"working_directory": cwd.display().to_string(),
"agents": agents.iter().map(agent_summary_json).collect::<Vec<_>>(),
})
}
pub fn definition_source_id(source: DefinitionSource) -> &'static str {
match source {
DefinitionSource::ProjectClaw | DefinitionSource::ProjectClaude => "project_claw",
DefinitionSource::UserClawConfigHome => "user_claw_config_home",
DefinitionSource::UserClaw | DefinitionSource::UserClaude => "user_claw",
DefinitionSource::Plugin => "plugin",
}
}
pub fn definition_source_json(source: DefinitionSource) -> serde_json::Value {
serde_json::json!({
"id": definition_source_id(source),
"label": source.label(),
})
}
fn agent_detail(agent: &AgentSummary) -> String {
let mut parts = vec![agent.name.clone()];
if let Some(description) = &agent.description {
parts.push(description.clone());
}
if let Some(model) = &agent.model {
parts.push(model.clone());
}
if let Some(reasoning) = &agent.reasoning_effort {
parts.push(reasoning.clone());
}
if let Some(mode) = &agent.mode {
parts.push(format!("[{mode}]"));
}
if let Some(plugin) = &agent.plugin {
parts.push(format!("[{plugin}]"));
}
parts.join(" \u{b7} ")
}
fn agent_summary_json(agent: &AgentSummary) -> serde_json::Value {
serde_json::json!({
"name": &agent.name,
"description": &agent.description,
"model": &agent.model,
"reasoning_effort": &agent.reasoning_effort,
"mode": &agent.mode,
"source": definition_source_json(agent.source),
"active": agent.shadowed_by.is_none(),
"shadowed_by": agent.shadowed_by.map(definition_source_json),
"plugin": &agent.plugin,
})
}

View File

@ -0,0 +1,29 @@
//! Sub-agent subsystem.
//!
pub mod discovery;
mod normalize;
mod persist;
mod runtime;
mod spawn;
pub mod types;
pub use self::discovery::{
definition_source_id, definition_source_json, discover_agent_roots, render_agents_report,
render_agents_report_json, AgentDiscovery, AgentSummary, DefinitionScope, DefinitionSource,
};
pub use self::normalize::{allowed_tools_for_subagent, normalize_subagent_type, SubagentKind};
pub use self::persist::{
extract_commit_sha, make_agent_id, slugify_agent_name,
};
pub use self::runtime::{
build_agent_runtime, build_agent_runtime_inner, build_agent_system_prompt,
init_global_runtime, register_runtime_tool_provider, register_tool_executor,
registered_extra_tool_defs, resolve_agent_model, ProviderRuntimeClient, SubagentToolExecutor,
RuntimeToolExecutorFn,
};
pub use self::spawn::{spawn_agent_task, spawn_agent_task_with_progress, AgentHandle, TryAgain};
pub use self::types::{
AgentInput, AgentJob, AgentOutput, AgentProgress, AgentStatus, ProgressStore, SharedProgress,
SubagentProgressEvent, new_shared_progress, push_progress_event, set_current_activity,
};

View File

@ -0,0 +1,90 @@
use std::collections::BTreeSet;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SubagentKind {
GeneralPurpose,
Explore,
Plan,
Verification,
ClawGuide,
StatuslineSetup,
Custom(String),
}
impl SubagentKind {
pub fn from_str(s: Option<&str>) -> Self {
match canonical_tool_token(s.map(str::trim).unwrap_or_default()).as_str() {
"general" | "generalpurpose" | "generalpurposeagent" => Self::GeneralPurpose,
"explore" | "explorer" | "exploreagent" => Self::Explore,
"plan" | "planagent" => Self::Plan,
"verification" | "verificationagent" | "verify" | "verifier" => Self::Verification,
"clawguide" | "clawguideagent" | "guide" => Self::ClawGuide,
"statusline" | "statuslinesetup" => Self::StatuslineSetup,
other => Self::Custom(other.to_string()),
}
}
pub fn as_str(&self) -> &str {
match self {
Self::GeneralPurpose => "general-purpose",
Self::Explore => "Explore",
Self::Plan => "Plan",
Self::Verification => "Verification",
Self::ClawGuide => "claw-guide",
Self::StatuslineSetup => "statusline-setup",
Self::Custom(s) => s.as_str(),
}
}
pub fn allowed_tools(&self) -> BTreeSet<String> {
let tools: Vec<&str> = match self {
Self::Explore => vec![
"read_file", "glob_search", "grep_search", "WebFetch", "WebSearch",
"ToolSearch", "Skill", "StructuredOutput",
],
Self::Plan => vec![
"read_file", "glob_search", "grep_search", "WebFetch", "WebSearch",
"ToolSearch", "Skill", "StructuredOutput",
],
Self::Verification => vec![
"bash", "read_file", "glob_search", "grep_search", "WebSearch",
"ToolSearch", "StructuredOutput",
],
Self::ClawGuide => vec![
"read_file", "glob_search", "grep_search", "WebFetch", "WebSearch",
"ToolSearch", "Skill", "StructuredOutput",
],
Self::StatuslineSetup => vec![
"bash", "read_file", "new_file", "edit_file", "glob_search",
"grep_search", "ToolSearch",
],
Self::GeneralPurpose => vec![
"bash", "read_file", "new_file", "edit_file", "glob_search",
"grep_search", "WebFetch", "WebSearch", "Skill",
"StructuredOutput",
],
Self::Custom(_) => vec![],
};
tools.into_iter().map(str::to_string).collect()
}
}
pub fn normalize_subagent_type(subagent_type: Option<&str>) -> String {
SubagentKind::from_str(subagent_type).as_str().to_string()
}
pub fn allowed_tools_for_subagent(subagent_type: &str) -> BTreeSet<String> {
SubagentKind::from_str(Some(subagent_type)).allowed_tools()
}
fn canonical_tool_token(value: &str) -> String {
let mut canonical: String = value
.chars()
.filter(char::is_ascii_alphanumeric)
.flat_map(char::to_lowercase)
.collect();
if let Some(stripped) = canonical.strip_suffix("tool") {
canonical = stripped.to_string();
}
canonical
}

View File

@ -0,0 +1,57 @@
use std::sync::atomic::{AtomicU64, Ordering};
pub const DEFAULT_AGENT_MODEL: &str = "claude-opus-4-6";
pub const DEFAULT_AGENT_SYSTEM_DATE: &str = "2026-03-31";
pub const DEFAULT_AGENT_MAX_ITERATIONS: usize = 32;
pub const DEFAULT_AGENT_TIMEOUT_SECS: u64 = 300;
static AGENT_ID_COUNTER: AtomicU64 = AtomicU64::new(0);
pub fn make_agent_id() -> String {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_else(|error| {
eprintln!("[agent] system clock is before epoch ({error}); using 0 for agent ID");
std::time::Duration::ZERO
})
.as_nanos();
let n = AGENT_ID_COUNTER.fetch_add(1, Ordering::Relaxed);
format!("agent-{nanos:x}-{n:x}")
}
pub fn slugify_agent_name(description: &str) -> String {
let mut out: String = description
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() {
ch.to_ascii_lowercase()
} else {
'-'
}
})
.collect();
while out.contains("--") {
out = out.replace("--", "-");
}
out.trim_matches('-').chars().take(32).collect()
}
/// Extract a commit SHA reference from a free-form result string.
pub fn extract_commit_sha(result: &str) -> Option<String> {
for token in result.split(|c: char| !c.is_ascii_hexdigit()) {
if token.len() == 40 {
return Some(token.to_string());
}
}
let lower = result.to_ascii_lowercase();
for marker in ["commit ", "sha ", "sha:", "@"] {
if let Some(idx) = lower.find(marker) {
let after = &result[idx + marker.len()..];
let token: String = after.chars().take_while(|c| c.is_ascii_hexdigit()).collect();
if (7..=12).contains(&token.len()) {
return Some(token);
}
}
}
None
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,436 @@
use std::panic::AssertUnwindSafe;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use runtime::ConversationRuntime;
use crate::persist::{
DEFAULT_AGENT_MAX_ITERATIONS, DEFAULT_AGENT_TIMEOUT_SECS,
};
use crate::runtime::{build_agent_runtime_inner, ProviderRuntimeClient, SubagentToolExecutor};
use crate::types::{AgentJob, AgentProgress, AgentStatus, SharedProgress, SubagentProgressEvent};
pub struct AgentHandle {
pub agent_id: String,
thread_handle: Option<std::thread::JoinHandle<()>>,
rx: Option<std::sync::mpsc::Receiver<Result<String, String>>>,
pub progress: SharedProgress,
finished: Arc<AtomicBool>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TryAgain;
impl AgentHandle {
pub fn agent_id(&self) -> &str {
&self.agent_id
}
pub fn join(mut self) -> Result<String, String> {
let timeout = Duration::from_secs(DEFAULT_AGENT_TIMEOUT_SECS);
let rx = match self.rx.take() {
Some(rx) => rx,
None => return Ok(String::new()),
};
let result = match rx.recv_timeout(timeout) {
Ok(Ok(text)) => Ok(text),
Ok(Err(e)) => Err(e),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => Err("agent timed out".to_string()),
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
Err("agent disconnected".to_string())
}
};
self.finished.store(true, Ordering::SeqCst);
remove_progress_entry(&self.progress, &self.agent_id);
if result.is_ok() {
let _ = self.thread_handle.take().map(|h| h.join());
}
result
}
pub fn try_join(&mut self) -> Result<Result<String, String>, TryAgain> {
let rx = match self.rx.as_ref() {
Some(rx) => rx,
None => return Ok(Ok(String::new())),
};
match rx.try_recv() {
Ok(result) => {
self.finished.store(true, Ordering::SeqCst);
Ok(result)
}
Err(std::sync::mpsc::TryRecvError::Empty) => Err(TryAgain),
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
self.finished.store(true, Ordering::SeqCst);
Ok(Err("agent disconnected".to_string()))
}
}
}
pub fn is_finished(&self) -> bool {
self.finished.load(Ordering::SeqCst)
}
#[cfg(feature = "test-utils")]
pub fn noop(agent_id: impl Into<String>) -> Self {
Self {
agent_id: agent_id.into(),
thread_handle: None,
rx: None,
progress: crate::types::new_shared_progress(),
finished: Arc::new(AtomicBool::new(true)),
}
}
#[cfg(feature = "test-utils")]
pub fn with_parts(
agent_id: impl Into<String>,
thread_handle: std::thread::JoinHandle<()>,
rx: std::sync::mpsc::Receiver<Result<String, String>>,
) -> Self {
Self {
agent_id: agent_id.into(),
thread_handle: Some(thread_handle),
rx: Some(rx),
progress: crate::types::new_shared_progress(),
finished: Arc::new(AtomicBool::new(false)),
}
}
#[cfg(feature = "test-utils")]
pub fn join_with_timeout(self, timeout: Duration) -> Result<String, String> {
let rx = match self.rx {
Some(rx) => rx,
None => return Ok(String::new()),
};
let result = match rx.recv_timeout(timeout) {
Ok(Ok(text)) => Ok(text),
Ok(Err(e)) => Err(e),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => Err("agent timed out".to_string()),
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
Err("agent disconnected".to_string())
}
};
let _ = self.thread_handle.map(|h| h.join());
result
}
}
fn remove_progress_entry(shared: &SharedProgress, agent_id: &str) {
let mut guard = shared.agents.lock().unwrap_or_else(|e| e.into_inner());
guard.retain(|p| p.agent_id != agent_id);
}
/// Spawn an agent task on a dedicated OS thread so that the
/// `ProviderRuntimeClient::block_on()` call inside `run_agent_job`
/// does not panic with "Cannot start a runtime from within a runtime".
pub fn spawn_agent_task(job: AgentJob) -> Result<AgentHandle, String> {
spawn_agent_task_with_progress(job, crate::types::new_shared_progress())
}
pub fn spawn_agent_task_with_progress(
job: AgentJob,
progress: SharedProgress,
) -> Result<AgentHandle, String> {
let agent_id = job.manifest.agent_id.clone();
let name = job.manifest.name.clone();
let subagent_type = job.manifest.subagent_type.clone().unwrap_or_default();
let finished = Arc::new(AtomicBool::new(false));
let finished_clone = Arc::clone(&finished);
{
let mut guard = progress.agents.lock().unwrap_or_else(|e| e.into_inner());
guard.push(AgentProgress {
agent_id: agent_id.clone(),
name: name.clone(),
subagent_type: subagent_type.clone(),
status: AgentStatus::Running,
events: vec![],
started_at: std::time::Instant::now(),
iteration_count: 0,
final_event: None,
current_activity: None,
});
}
let (tx, rx) = std::sync::mpsc::channel::<Result<String, String>>();
let progress_for_job = Arc::clone(&progress);
let agent_id_for_job = agent_id.clone();
let thread_handle = std::thread::spawn(move || {
let job_progress = Arc::clone(&progress_for_job);
let job_agent_id = agent_id_for_job.clone();
let job_with_progress = AssertUnwindSafe(AgentJobWithProgress {
job,
progress: progress_for_job,
agent_id: agent_id_for_job,
});
let result = std::panic::catch_unwind(move || {
run_agent_job_sync_with_progress(&job_with_progress)
});
clear_current_activity(&job_progress, &job_agent_id);
let outcome = match result {
Ok(Ok(text)) => {
push_progress_event(
&job_progress,
&job_agent_id,
SubagentProgressEvent::Completed {
result_preview: text.clone(),
},
);
push_progress_event(
&job_progress,
&job_agent_id,
SubagentProgressEvent::StatusChange {
status: AgentStatus::Completed,
},
);
Ok(text)
}
Ok(Err(error)) => {
push_progress_event(
&job_progress,
&job_agent_id,
SubagentProgressEvent::Failed {
error: error.clone(),
},
);
Err(error)
}
Err(panic_payload) => {
let panic_msg = panic_message(&panic_payload);
push_progress_event(
&job_progress,
&job_agent_id,
SubagentProgressEvent::Failed {
error: format!("panic: {panic_msg}"),
},
);
Err(format!("panic: {panic_msg}"))
}
};
finished_clone.store(true, Ordering::SeqCst);
let _ = tx.send(outcome);
});
Ok(AgentHandle {
agent_id,
thread_handle: Some(thread_handle),
rx: Some(rx),
progress,
finished,
})
}
struct AgentJobWithProgress {
job: AgentJob,
progress: SharedProgress,
agent_id: String,
}
fn push_progress_event(shared: &SharedProgress, agent_id: &str, event: SubagentProgressEvent) {
crate::types::push_progress_event(shared, agent_id, event);
}
fn clear_current_activity(shared: &SharedProgress, agent_id: &str) {
crate::types::set_current_activity(shared, agent_id, None);
}
fn run_agent_job_sync_with_progress(job: &AgentJobWithProgress) -> Result<String, String> {
let mut runtime: ConversationRuntime<ProviderRuntimeClient, SubagentToolExecutor> =
build_agent_runtime_inner(
&job.job,
Some(Arc::clone(&job.progress)),
Some(job.agent_id.clone()),
)?
.with_max_iterations(DEFAULT_AGENT_MAX_ITERATIONS);
let summary = runtime
.run_turn(job.job.prompt.clone(), None)
.map_err(|error| error.to_string())?;
Ok(final_assistant_text(&summary))
}
fn panic_message(payload: &Box<dyn std::any::Any + Send>) -> String {
if let Some(s) = payload.downcast_ref::<&'static str>() {
s.to_string()
} else if let Some(s) = payload.downcast_ref::<String>() {
s.clone()
} else {
String::from("unknown panic payload")
}
}
fn final_assistant_text(summary: &runtime::TurnSummary) -> String {
// Walk messages newest-first so a thinking-only final turn does not
// silently erase the agent's real answer from an earlier message.
//
// Messages that carry a `ToolUse` block are skipped as text candidates:
// any text inside them is transitional narration emitted BEFORE the tool
// call ("Let me check the file first"), not the sub-agent's answer. Only
// tool-use-free messages can supply the final result.
for message in summary.assistant_messages.iter().rev() {
if message
.blocks
.iter()
.any(|block| matches!(block, runtime::ContentBlock::ToolUse { .. }))
{
continue;
}
let text = message
.blocks
.iter()
.filter_map(|block| match block {
runtime::ContentBlock::Text { text } => {
let trimmed = text.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
}
_ => None,
})
.collect::<Vec<_>>()
.join("\n\n");
if !text.is_empty() {
return text;
}
}
// No non-empty text block anywhere: surface the latest reasoning so the
// parent model sees *something* instead of a silently empty result.
for message in summary.assistant_messages.iter().rev() {
for block in message.blocks.iter().rev() {
if let runtime::ContentBlock::Thinking { thinking, .. } = block {
let trimmed = thinking.trim();
if !trimmed.is_empty() {
return trimmed.to_string();
}
}
}
}
// Truly nothing to report — never return an empty tool result.
"(agent returned no text)".to_string()
}
#[cfg(test)]
mod tests {
use runtime::{
AutoCompactionEvent, ContentBlock, ConversationMessage, PromptCacheEvent, TokenUsage,
TurnSummary,
};
use super::final_assistant_text;
fn summary_with(messages: Vec<ConversationMessage>) -> TurnSummary {
TurnSummary {
assistant_messages: messages,
tool_results: vec![],
prompt_cache_events: vec![PromptCacheEvent {
unexpected: false,
reason: String::new(),
previous_cache_read_input_tokens: 0,
current_cache_read_input_tokens: 0,
token_drop: 0,
}],
iterations: 1,
usage: TokenUsage::default(),
auto_compaction: Some(AutoCompactionEvent {
removed_message_count: 0,
savings_ratio: 0.0,
}),
}
}
fn text(s: &str) -> ContentBlock {
ContentBlock::Text { text: s.to_string() }
}
fn thinking(s: &str) -> ContentBlock {
ContentBlock::Thinking {
thinking: s.to_string(),
signature: Some("sig".to_string()),
}
}
fn tool_use() -> ContentBlock {
ContentBlock::ToolUse {
id: "toolu_test_1".to_string(),
name: "read_file".to_string(),
input: serde_json::json!({}),
}
}
fn msg(blocks: Vec<ContentBlock>) -> ConversationMessage {
ConversationMessage::assistant(blocks)
}
#[test]
fn returns_text_from_last_message() {
let summary = summary_with(vec![msg(vec![text("hello")])]);
assert_eq!(final_assistant_text(&summary), "hello");
}
#[test]
fn returns_last_non_empty_text_message_when_final_is_thinking_only() {
let summary = summary_with(vec![
msg(vec![text("earlier result")]),
msg(vec![thinking("thinking only")]),
]);
assert_eq!(final_assistant_text(&summary), "earlier result");
}
#[test]
fn returns_thinking_text_when_no_text_blocks_exist() {
let summary = summary_with(vec![msg(vec![thinking("deep reasoning")])]);
assert_eq!(final_assistant_text(&summary), "deep reasoning");
}
#[test]
fn returns_marker_when_no_blocks_at_all() {
let summary = summary_with(vec![]);
assert!(!final_assistant_text(&summary).is_empty());
}
#[test]
fn ignores_empty_text_blocks_when_falling_back() {
let summary = summary_with(vec![
msg(vec![text(" ")]),
msg(vec![text("real answer")]),
]);
assert_eq!(final_assistant_text(&summary), "real answer");
}
#[test]
fn does_not_return_transitional_text_from_tool_calling_message() {
let summary = summary_with(vec![
msg(vec![text("Let me check the file first"), tool_use()]),
msg(vec![thinking("The real answer is 42")]),
]);
assert_eq!(final_assistant_text(&summary), "The real answer is 42");
}
#[test]
fn falls_back_to_last_text_only_message_when_tool_calling_message_is_newer() {
let summary = summary_with(vec![
msg(vec![text("actual result")]),
msg(vec![text("Let me verify"), tool_use()]),
msg(vec![thinking("final reasoning only")]),
]);
assert_eq!(final_assistant_text(&summary), "actual result");
}
#[test]
fn prefers_thinking_over_transitional_text_from_tool_calling_message() {
let summary = summary_with(vec![
msg(vec![text("Let me check the file first"), tool_use()]),
msg(vec![thinking("the answer is deep reasoning")]),
]);
assert_eq!(
final_assistant_text(&summary),
"the answer is deep reasoning"
);
}
}

View File

@ -0,0 +1,165 @@
use std::collections::BTreeSet;
use std::sync::{Arc, Condvar, Mutex};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Instant;
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum AgentStatus {
Running,
Thinking,
UsingTool,
Completed,
Failed,
}
impl AgentStatus {
pub fn as_str(&self) -> &'static str {
match self {
AgentStatus::Running => "Running",
AgentStatus::Thinking => "Thinking",
AgentStatus::UsingTool => "UsingTool",
AgentStatus::Completed => "Completed",
AgentStatus::Failed => "Failed",
}
}
}
#[derive(Debug, Clone, Serialize)]
pub enum SubagentProgressEvent {
Thinking { text: String },
ToolCall { tool_name: String, input: Value },
ToolResult { tool_name: String, truncated_result: String },
StatusChange { status: AgentStatus },
Completed { result_preview: String },
Failed { error: String },
}
#[derive(Debug, Clone)]
pub struct AgentProgress {
pub agent_id: String,
pub name: String,
pub subagent_type: String,
pub status: AgentStatus,
pub events: Vec<SubagentProgressEvent>,
pub started_at: Instant,
pub iteration_count: usize,
pub final_event: Option<SubagentProgressEvent>,
pub current_activity: Option<String>,
}
pub struct ProgressStore {
pub agents: Mutex<Vec<AgentProgress>>,
pub cvar: Condvar,
pub event_seq: AtomicUsize,
}
pub type SharedProgress = Arc<ProgressStore>;
pub fn new_shared_progress() -> SharedProgress {
Arc::new(ProgressStore {
agents: Mutex::new(Vec::new()),
cvar: Condvar::new(),
event_seq: AtomicUsize::new(0),
})
}
pub fn push_progress_event(
shared: &SharedProgress,
agent_id: &str,
event: SubagentProgressEvent,
) {
let mut guard = shared.agents.lock().unwrap_or_else(|e| e.into_inner());
if let Some(entry) = guard.iter_mut().find(|p| p.agent_id == agent_id) {
if let SubagentProgressEvent::StatusChange { status } = &event {
entry.status = *status;
if *status == AgentStatus::UsingTool {
entry.iteration_count += 1;
}
}
match &event {
SubagentProgressEvent::Completed { .. }
| SubagentProgressEvent::Failed { .. } => {
entry.final_event = Some(event.clone());
}
_ => {}
}
if entry.events.len() > 50 {
entry.events.remove(0);
}
entry.events.push(event);
}
drop(guard);
shared.event_seq.fetch_add(1, Ordering::Release);
shared.cvar.notify_all();
}
pub fn set_current_activity(
shared: &SharedProgress,
agent_id: &str,
activity: Option<String>,
) {
let mut guard = shared.agents.lock().unwrap_or_else(|e| e.into_inner());
if let Some(entry) = guard.iter_mut().find(|p| p.agent_id == agent_id) {
entry.current_activity = activity;
}
drop(guard);
shared.event_seq.fetch_add(1, Ordering::Release);
shared.cvar.notify_all();
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentOutput {
#[serde(rename = "agentId")]
pub agent_id: String,
pub name: String,
pub description: String,
#[serde(rename = "subagentType")]
pub subagent_type: Option<String>,
pub model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mode: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub started_at: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub completed_at: Option<u64>,
#[serde(rename = "laneEvents", default, skip_serializing_if = "Vec::is_empty")]
pub lane_events: Vec<runtime::LaneEvent>,
}
#[derive(Debug, Clone)]
pub struct AgentJob {
pub manifest: AgentOutput,
pub prompt: String,
pub system_prompt: Vec<String>,
pub allowed_tools: BTreeSet<String>,
}
#[derive(Debug, Deserialize)]
pub struct AgentInput {
pub description: String,
pub prompt: String,
pub subagent_type: Option<String>,
pub name: Option<String>,
pub model: Option<String>,
/// Optional explicit system prompt (e.g. an `@agent` file's contents).
/// When present, `execute_agent_with_spawn` uses it instead of deriving
/// the prompt solely from `subagent_type` (which would drop the agent's
/// own persona).
#[serde(default)]
pub system_prompt: Option<Vec<String>>,
/// Optional allowed-tool allowlist. When present, overrides the tools
/// inferred from `subagent_type`.
#[serde(default)]
pub allowed_tools: Option<BTreeSet<String>>,
#[serde(default)]
pub mode: Option<String>,
}

View File

@ -0,0 +1,10 @@
use agents::make_agent_id;
#[test]
fn make_agent_id_is_unique_under_burst() {
let mut ids = std::collections::HashSet::new();
for _ in 0..1000 {
let id = make_agent_id();
assert!(ids.insert(id.clone()), "duplicate id {id}");
}
}

View File

@ -0,0 +1,46 @@
use agents::extract_commit_sha;
#[test]
fn extracts_full_sha1() {
let result = "landed in commit deadbeef1234567890abcdef1234567890abcdef cleanly";
assert_eq!(
extract_commit_sha(result).as_deref(),
Some("deadbeef1234567890abcdef1234567890abcdef"),
);
}
#[test]
fn extracts_short_sha_after_commit_word() {
let result = "landed as commit abc1234def and pushed";
assert_eq!(extract_commit_sha(result).as_deref(), Some("abc1234def"));
}
#[test]
fn extracts_short_sha_after_at_marker() {
let result = "tagged as @abc1234def5";
assert_eq!(extract_commit_sha(result).as_deref(), Some("abc1234def5"));
}
#[test]
fn rejects_uuid_fragment_without_context() {
let result = "see request id deadbeef-1234-5678-9abc-def012345678 in logs";
assert_eq!(extract_commit_sha(result), None);
}
#[test]
fn rejects_seven_char_hex_surrounded_by_digits() {
let result = "the previous build was 1234567890abcdef in sequence";
assert_eq!(extract_commit_sha(result), None);
}
#[test]
fn rejects_seven_char_hex_in_markdown_link() {
let result = "see [the diff](https://github.com/x/y/commit/abc1234) for context";
assert_eq!(extract_commit_sha(result), None);
}
#[test]
fn rejects_short_sha_below_seven_chars() {
let result = "pinned to commit abc12";
assert_eq!(extract_commit_sha(result), None);
}

View File

@ -0,0 +1,37 @@
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
pub fn unique_store_dir(label: &str) -> PathBuf {
let pid = std::process::id();
let n = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let dir = std::env::temp_dir()
.join("claw-agents-tests")
.join(format!("{label}-{pid}-{nanos}-{n}"));
std::fs::create_dir_all(&dir).expect("create temp dir");
dir
}
pub fn make_manifest(dir: &std::path::Path, name: &str) -> agents::AgentOutput {
let agent_id = format!("test-{name}-{}", TEST_COUNTER.fetch_add(1, Ordering::Relaxed));
let manifest_file = dir.join(format!("{agent_id}.json"));
agents::AgentOutput {
agent_id,
name: name.to_string(),
description: format!("test manifest {name}"),
subagent_type: Some("general-purpose".to_string()),
model: Some("claude-opus-4-6".to_string()),
mode: None,
}
}
#[allow(dead_code)]
pub fn install_store_env(dir: &std::path::Path) {
std::env::set_var("CLAW_AGENT_STORE", dir);
std::env::remove_var("CLAWD_AGENT_STORE");
}

View File

@ -0,0 +1,36 @@
use agents::SubagentKind;
#[test]
fn general_purpose_has_a_maximal_tool_set() {
let tools = SubagentKind::GeneralPurpose.allowed_tools();
assert!(!tools.is_empty(), "GeneralPurpose should keep its broad tool set");
assert!(tools.contains("bash"));
assert!(tools.contains("new_file"));
}
#[test]
fn custom_subagent_is_fail_closed() {
let tools = SubagentKind::Custom("anything-here".to_string()).allowed_tools();
assert!(
tools.is_empty(),
"Custom subagents must be fail-closed; got {tools:?}",
);
}
#[test]
fn custom_subagent_empty_regardless_of_name() {
let a = SubagentKind::Custom("foo".to_string()).allowed_tools();
let b = SubagentKind::Custom("general-purpose".to_string()).allowed_tools();
let c = SubagentKind::Custom("general".to_string()).allowed_tools();
assert!(a.is_empty());
assert!(b.is_empty());
assert!(c.is_empty());
}
#[test]
fn explore_remains_read_only() {
let tools = SubagentKind::Explore.allowed_tools();
assert!(tools.contains("read_file"));
assert!(!tools.contains("bash"));
assert!(!tools.contains("new_file"));
}

View File

@ -8,7 +8,7 @@ publish.workspace = true
[dependencies] [dependencies]
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
runtime = { path = "../runtime" } runtime = { path = "../runtime" }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive", "rc"] }
serde_json.workspace = true serde_json.workspace = true
telemetry = { path = "../telemetry" } telemetry = { path = "../telemetry" }
tokio = { version = "1", features = ["io-util", "macros", "net", "rt-multi-thread", "time"] } tokio = { version = "1", features = ["io-util", "macros", "net", "rt-multi-thread", "time"] }

View File

@ -13,6 +13,8 @@
clippy::uninlined_format_args clippy::uninlined_format_args
)] )]
use std::sync::Arc;
use api::{ use api::{
build_chat_completion_request, flatten_tool_result_content, is_reasoning_model, build_chat_completion_request, flatten_tool_result_content, is_reasoning_model,
translate_message, InputContentBlock, InputMessage, MessageRequest, OpenAiCompatConfig, translate_message, InputContentBlock, InputMessage, MessageRequest, OpenAiCompatConfig,
@ -49,13 +51,14 @@ fn create_sample_request(message_count: usize) -> MessageRequest {
text: format!("Tool result content {}", i), text: format!("Tool result content {}", i),
}], }],
is_error: false, is_error: false,
cache_reference: None,
}], }],
}), }),
_ => messages.push(InputMessage { _ => messages.push(InputMessage {
role: "assistant".to_string(), role: "assistant".to_string(),
content: vec![InputContentBlock::ToolUse { content: vec![InputContentBlock::ToolUse {
id: format!("call_{}", i), id: format!("call_{}", i),
name: "write_file".to_string(), name: "new_file".to_string(),
input: json!({"path": format!("/tmp/out{}", i), "content": "data"}), input: json!({"path": format!("/tmp/out{}", i), "content": "data"}),
}], }],
}), }),
@ -65,18 +68,11 @@ fn create_sample_request(message_count: usize) -> MessageRequest {
MessageRequest { MessageRequest {
model: "gpt-4o".to_string(), model: "gpt-4o".to_string(),
max_tokens: 1024, max_tokens: 1024,
messages, messages: messages.into(),
stream: false, stream: false,
system: Some("You are a helpful assistant.".to_string()), system: Some(Arc::from("You are a helpful assistant.")),
temperature: Some(0.7), temperature: Some(0.7),
top_p: None, ..Default::default()
tools: None,
tool_choice: None,
frequency_penalty: None,
presence_penalty: None,
stop: None,
reasoning_effort: None,
extra_body: std::collections::BTreeMap::new(),
} }
} }
@ -108,7 +104,7 @@ fn bench_translate_message(c: &mut Criterion) {
}, },
InputContentBlock::ToolUse { InputContentBlock::ToolUse {
id: "call_2".to_string(), id: "call_2".to_string(),
name: "write_file".to_string(), name: "new_file".to_string(),
input: json!({"path": "/tmp/out", "content": "data"}), input: json!({"path": "/tmp/out", "content": "data"}),
}, },
], ],
@ -130,6 +126,7 @@ fn bench_translate_message(c: &mut Criterion) {
text: "File contents here".to_string(), text: "File contents here".to_string(),
}], }],
is_error: false, is_error: false,
cache_reference: None,
}], }],
}; };
group.bench_with_input( group.bench_with_input(
@ -140,15 +137,6 @@ fn bench_translate_message(c: &mut Criterion) {
}, },
); );
// Tool result for kimi model (is_error excluded)
group.bench_with_input(
BenchmarkId::new("tool_result_kimi", "kimi-k2.5"),
&tool_result_message,
|b, msg| {
b.iter(|| translate_message(black_box(msg), black_box("kimi-k2.5")));
},
);
// Large content message // Large content message
let large_content = "x".repeat(10000); let large_content = "x".repeat(10000);
let large_message = InputMessage::user_text(large_content); let large_message = InputMessage::user_text(large_content);

View File

@ -1,7 +1,8 @@
use crate::error::ApiError; use crate::error::ApiError;
use crate::prompt_cache::{PromptCache, PromptCacheRecord, PromptCacheStats}; use crate::prompt_cache::{PromptCache, PromptCacheRecord, PromptCacheStats};
use crate::providers::anthropic::{self, AnthropicClient, AuthSource}; use crate::providers::anthropic::{self, AnthropicClient, AuthSource};
use crate::providers::openai_compat::{self, OpenAiCompatClient, OpenAiCompatConfig}; use crate::providers::openai_compat;
use crate::providers::openai_compat::{OpenAiCompatClient, OpenAiCompatConfig};
use crate::providers::{self, ProviderKind}; use crate::providers::{self, ProviderKind};
use crate::types::{MessageRequest, MessageResponse, StreamEvent}; use crate::types::{MessageRequest, MessageResponse, StreamEvent};
@ -9,7 +10,6 @@ use crate::types::{MessageRequest, MessageResponse, StreamEvent};
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum ProviderClient { pub enum ProviderClient {
Anthropic(AnthropicClient), Anthropic(AnthropicClient),
Xai(OpenAiCompatClient),
OpenAi(OpenAiCompatClient), OpenAi(OpenAiCompatClient),
} }
@ -28,30 +28,9 @@ impl ProviderClient {
Some(auth) => AnthropicClient::from_auth(auth), Some(auth) => AnthropicClient::from_auth(auth),
None => AnthropicClient::from_env()?, None => AnthropicClient::from_env()?,
})), })),
ProviderKind::Xai => Ok(Self::Xai(OpenAiCompatClient::from_env( ProviderKind::OpenAi => Ok(Self::OpenAi(OpenAiCompatClient::from_env(
OpenAiCompatConfig::xai(), OpenAiCompatConfig::openai(),
)?)), )?)),
ProviderKind::OpenAi => {
// OLLAMA_HOST takes priority: local Ollama needs no API key
// and ignores DashScope/OpenAI env-based dispatch.
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"),
))
} else {
// DashScope models (qwen-*) also return ProviderKind::OpenAi because they
// speak the OpenAI wire format, but they need the DashScope config which
// reads DASHSCOPE_API_KEY and points at dashscope.aliyuncs.com.
let config = match providers::metadata_for_model(&resolved_model) {
Some(meta) if meta.auth_env == "DASHSCOPE_API_KEY" => {
OpenAiCompatConfig::dashscope()
}
_ => OpenAiCompatConfig::openai(),
};
Ok(Self::OpenAi(OpenAiCompatClient::from_env(config)?))
}
}
} }
} }
@ -59,7 +38,6 @@ impl ProviderClient {
pub const fn provider_kind(&self) -> ProviderKind { pub const fn provider_kind(&self) -> ProviderKind {
match self { match self {
Self::Anthropic(_) => ProviderKind::Anthropic, Self::Anthropic(_) => ProviderKind::Anthropic,
Self::Xai(_) => ProviderKind::Xai,
Self::OpenAi(_) => ProviderKind::OpenAi, Self::OpenAi(_) => ProviderKind::OpenAi,
} }
} }
@ -76,7 +54,7 @@ impl ProviderClient {
pub fn prompt_cache_stats(&self) -> Option<PromptCacheStats> { pub fn prompt_cache_stats(&self) -> Option<PromptCacheStats> {
match self { match self {
Self::Anthropic(client) => client.prompt_cache_stats(), Self::Anthropic(client) => client.prompt_cache_stats(),
Self::Xai(_) | Self::OpenAi(_) => None, Self::OpenAi(_) => None,
} }
} }
@ -84,7 +62,16 @@ impl ProviderClient {
pub fn take_last_prompt_cache_record(&self) -> Option<PromptCacheRecord> { pub fn take_last_prompt_cache_record(&self) -> Option<PromptCacheRecord> {
match self { match self {
Self::Anthropic(client) => client.take_last_prompt_cache_record(), Self::Anthropic(client) => client.take_last_prompt_cache_record(),
Self::Xai(_) | Self::OpenAi(_) => None, Self::OpenAi(_) => None,
}
}
/// Enable incremental body serialisation (Anthropic only).
#[must_use]
pub fn with_incremental_body(self) -> Self {
match self {
Self::Anthropic(client) => Self::Anthropic(client.with_incremental_body()),
other => other,
} }
} }
@ -94,7 +81,7 @@ impl ProviderClient {
) -> Result<MessageResponse, ApiError> { ) -> Result<MessageResponse, ApiError> {
match self { match self {
Self::Anthropic(client) => client.send_message(request).await, Self::Anthropic(client) => client.send_message(request).await,
Self::Xai(client) | Self::OpenAi(client) => client.send_message(request).await, Self::OpenAi(client) => client.send_message(request).await,
} }
} }
@ -107,7 +94,7 @@ impl ProviderClient {
.stream_message(request) .stream_message(request)
.await .await
.map(MessageStream::Anthropic), .map(MessageStream::Anthropic),
Self::Xai(client) | Self::OpenAi(client) => client Self::OpenAi(client) => client
.stream_message(request) .stream_message(request)
.await .await
.map(MessageStream::OpenAiCompat), .map(MessageStream::OpenAiCompat),
@ -146,120 +133,20 @@ pub fn read_base_url() -> String {
anthropic::read_base_url() anthropic::read_base_url()
} }
#[must_use]
pub fn read_xai_base_url() -> String {
openai_compat::read_base_url(OpenAiCompatConfig::xai())
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::sync::{Mutex, OnceLock};
use super::ProviderClient;
use crate::providers::{detect_provider_kind, resolve_model_alias, ProviderKind}; use crate::providers::{detect_provider_kind, resolve_model_alias, ProviderKind};
/// Serializes every test in this module that mutates process-wide
/// environment variables so concurrent test threads cannot observe
/// each other's partially-applied state.
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
#[test] #[test]
fn resolves_existing_and_grok_aliases() { fn resolves_existing_aliases() {
assert_eq!(resolve_model_alias("opus"), "claude-opus-4-7"); assert_eq!(resolve_model_alias("opus"), "claude-opus-4-6");
assert_eq!(resolve_model_alias("grok"), "grok-3");
assert_eq!(resolve_model_alias("grok-mini"), "grok-3-mini");
} }
#[test] #[test]
fn provider_detection_prefers_model_family() { fn provider_detection_prefers_model_family() {
assert_eq!(detect_provider_kind("grok-3"), ProviderKind::Xai);
assert_eq!( assert_eq!(
detect_provider_kind("claude-sonnet-4-6"), detect_provider_kind("claude-sonnet-4-6"),
ProviderKind::Anthropic ProviderKind::Anthropic
); );
} }
/// Snapshot-restore guard for a single environment variable. Mirrors
/// the pattern used in `providers/mod.rs` tests: captures the original
/// value on construction, applies the override, and restores on drop so
/// tests leave the process env untouched even when they panic.
struct EnvVarGuard {
key: &'static str,
original: Option<std::ffi::OsString>,
}
impl EnvVarGuard {
fn set(key: &'static str, value: Option<&str>) -> Self {
let original = std::env::var_os(key);
match value {
Some(value) => std::env::set_var(key, value),
None => std::env::remove_var(key),
}
Self { key, original }
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
match self.original.take() {
Some(value) => std::env::set_var(self.key, value),
None => std::env::remove_var(self.key),
}
}
}
#[test]
fn dashscope_model_uses_dashscope_config_not_openai() {
// Regression: qwen-plus was being routed to OpenAiCompatConfig::openai()
// which reads OPENAI_API_KEY and points at api.openai.com, when it should
// use OpenAiCompatConfig::dashscope() which reads DASHSCOPE_API_KEY and
// points at dashscope.aliyuncs.com.
let _lock = env_lock();
let _dashscope = EnvVarGuard::set("DASHSCOPE_API_KEY", Some("test-dashscope-key"));
let _openai = EnvVarGuard::set("OPENAI_API_KEY", None);
let client = ProviderClient::from_model("qwen-plus");
// Must succeed (not fail with "missing OPENAI_API_KEY")
assert!(
client.is_ok(),
"qwen-plus with DASHSCOPE_API_KEY set should build successfully, got: {:?}",
client.err()
);
// Verify it's the OpenAi variant pointed at the DashScope base URL.
match client.unwrap() {
ProviderClient::OpenAi(openai_client) => {
assert!(
openai_client.base_url().contains("dashscope.aliyuncs.com"),
"qwen-plus should route to DashScope base URL (contains 'dashscope.aliyuncs.com'), got: {}",
openai_client.base_url()
);
}
other => panic!("Expected ProviderClient::OpenAi for qwen-plus, got: {other:?}"),
}
}
#[test]
fn local_openai_base_url_routes_authless_ollama_models() {
let _lock = env_lock();
let _base_url = EnvVarGuard::set("OPENAI_BASE_URL", Some("http://127.0.0.1:11434/v1"));
let _openai_key = EnvVarGuard::set("OPENAI_API_KEY", None);
let _anthropic_key = EnvVarGuard::set("ANTHROPIC_API_KEY", Some("test-anthropic-key"));
let _anthropic_token = EnvVarGuard::set("ANTHROPIC_AUTH_TOKEN", None);
let client = ProviderClient::from_model("qwen2.5-coder:7b")
.expect("local model should route to OpenAI-compatible client without auth");
match client {
ProviderClient::OpenAi(openai_client) => {
assert_eq!(openai_client.base_url(), "http://127.0.0.1:11434/v1")
}
other => panic!("Expected ProviderClient::OpenAi for local model, got: {other:?}"),
}
}
} }

View File

@ -0,0 +1,419 @@
use std::collections::HashMap;
use std::sync::Arc;
use runtime::image_store::ImageStore;
use runtime::{ContentBlock, ConversationMessage, MessageRole};
use crate::types::ImageSource;
use crate::{InputContentBlock, InputMessage, ToolResultContentBlock};
use serde_json::Value;
/// Core conversion logic. Returns plain `Vec` (no `Arc` wrapper) so callers
/// that maintain their own accumulator can append delta conversions without
/// an intermediate `Arc` allocation.
///
/// Delta messages (assistant replies, tool results) never contain `ImageRef`
/// blocks, so callers may pass `None` for both `image_cache` and `image_store`
/// when converting a slice that is known to contain no user-originated messages.
///
/// When `model_name` is `Some` and the model is text-only (listed in
/// `LLM_ONLY_MODEL.txt`), all Image and ImageRef blocks are filtered out and
/// replaced with text placeholders describing the attached image.
pub fn convert_messages_inner(
messages: &[ConversationMessage],
image_cache: Option<&HashMap<String, String>>,
image_store: Option<&ImageStore>,
model_name: Option<&str>,
) -> (Vec<InputMessage>, Vec<Option<Value>>) {
let is_text_only = model_name.is_some_and(runtime::text_only_models::is_text_only_model);
let mut input_messages = Vec::with_capacity(messages.len());
let mut cached_values = Vec::with_capacity(messages.len());
for message in messages {
let role = match message.role {
MessageRole::System | MessageRole::User | MessageRole::Tool => "user",
MessageRole::Assistant => "assistant",
};
let content: Vec<InputContentBlock> = message
.blocks
.iter()
.filter_map(|block| match block {
ContentBlock::Thinking { thinking, signature } => {
// Anthropic extended thinking requires thinking blocks to be
// echoed back to the API (content + signature) when the
// assistant turn is included in a follow-up request; the
// server authenticates the `signature`. Only signed blocks
// are passed back — signature-less thinking (provider
// redaction placeholders, non-Anthropic reasoning models)
// is dropped, matching the pre-fix behaviour.
signature.clone().map(|signature| InputContentBlock::Thinking {
thinking: thinking.clone(),
signature: Some(signature),
})
}
ContentBlock::RedactedThinking { data } => {
// Redacted thinking carries no signature; the ciphertext
// `data` itself is the authentication token. Echo it back
// verbatim so the Anthropic API can authenticate the
// tool-use round-trip.
Some(InputContentBlock::RedactedThinking {
data: serde_json::Value::String(data.clone()),
})
}
ContentBlock::Text { text } => {
Some(InputContentBlock::Text { text: text.clone() })
}
ContentBlock::ToolUse { id, name, input } => Some(InputContentBlock::ToolUse {
id: id.clone(),
name: name.clone(),
input: input.clone(),
}),
ContentBlock::Image {
mime_type, data, filename, ..
} => {
if is_text_only {
let label = filename.as_deref().unwrap_or(mime_type);
Some(InputContentBlock::Text {
text: format!(
"[Image attached: {label}] (not supported by this model)"
),
})
} else {
Some(InputContentBlock::Image {
source: ImageSource {
source_type: "base64".to_string(),
media_type: mime_type.clone(),
data: data.clone(),
},
})
}
}
ContentBlock::ImageRef { hash_hex, mime_type, .. } => {
if is_text_only {
Some(InputContentBlock::Text {
text: format!(
"[Image attached: {mime_type}] (not supported by this model)"
),
})
} else {
let base64_data = image_cache
.and_then(|cache| cache.get(hash_hex))
.cloned()
.or_else(|| {
image_store
.and_then(|store| store.load_base64(hash_hex, mime_type).ok())
})
.unwrap_or_default();
if base64_data.is_empty() {
eprintln!(
"[IMAGE] Failed to resolve base64 for hash {hash_hex} (mime: {mime_type})"
);
}
Some(InputContentBlock::Image {
source: ImageSource {
source_type: "base64".to_string(),
media_type: mime_type.clone(),
data: base64_data,
},
})
}
}
ContentBlock::ToolResult {
tool_use_id,
output,
is_error,
..
} => Some(InputContentBlock::ToolResult {
tool_use_id: tool_use_id.clone(),
content: vec![ToolResultContentBlock::Text {
text: output.clone(),
}],
is_error: *is_error,
cache_reference: None,
}),
})
.collect();
if content.is_empty() {
// Message has no non-Thinking content (e.g. only Thinking blocks
// that were stripped above). Include a placeholder text block so
// the message count stays aligned with `cached_message_values` —
// dropping it here would make `cached_values` shorter than the
// original message list, corrupting the IncrementalBody per-message
// byte cache used by `send_raw_request`.
let input_msg = InputMessage {
role: role.to_string(),
content: vec![InputContentBlock::Text {
text: String::new(),
}],
};
cached_values.push(None);
input_messages.push(input_msg);
continue;
}
let input_msg = InputMessage {
role: role.to_string(),
content,
};
let cached = message
.cached_input_message
.get_or_init(|| serde_json::to_value(&input_msg).unwrap_or(Value::Null));
cached_values.push(Some(cached.clone()));
input_messages.push(input_msg);
}
(input_messages, cached_values)
}
/// Convert the runtime-level `ConversationMessage` list into the
/// API-level `InputMessage` list suitable for Anthropic / OpenAI requests.
///
/// * Thinking blocks are dropped.
/// * `ImageRef` blocks are resolved to base64 via `image_cache` / `image_store`.
/// * When `model_name` is `Some` and the model is text-only, images are
/// replaced with text placeholders.
/// * Returns `Arc<Vec<InputMessage>>` so callers can cheaply share the
/// result across clones (e.g. in `MessageRequest`).
#[must_use]
pub fn convert_messages(
messages: &[ConversationMessage],
image_cache: Option<&HashMap<String, String>>,
image_store: Option<&ImageStore>,
model_name: Option<&str>,
) -> Arc<Vec<InputMessage>> {
Arc::new(convert_messages_inner(messages, image_cache, image_store, model_name).0)
}
/// Like `convert_messages` but also returns cached serialised JSON `Value`s
/// for each converted message.
///
/// The cached values are stored in `ConversationMessage.cached_input_message`
/// on the first call and reused on subsequent calls within the same
/// `filter_for_api` batch. Callers that use `IncrementalBody` should prefer
/// this variant so the body builder can skip re-serialising unchanged messages.
#[must_use]
pub fn convert_messages_cached(
messages: &[ConversationMessage],
image_cache: Option<&HashMap<String, String>>,
image_store: Option<&ImageStore>,
model_name: Option<&str>,
) -> (Arc<Vec<InputMessage>>, Vec<Option<Value>>) {
let (msgs, vals) = convert_messages_inner(messages, image_cache, image_store, model_name);
(Arc::new(msgs), vals)
}
#[cfg(test)]
mod tests {
use runtime::text_only_models;
use runtime::{ContentBlock, ConversationMessage, MessageRole};
use std::sync::{Mutex, OnceLock};
use super::*;
fn text_only_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(())).lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn make_message(blocks: Vec<ContentBlock>) -> ConversationMessage {
ConversationMessage {
role: MessageRole::User,
blocks,
usage: None,
created_at: std::time::Instant::now(),
cached_tokens: OnceLock::new(),
cached_input_message: OnceLock::new(),
}
}
#[test]
fn test_text_only_model_filters_image_blocks() {
let _lock = text_only_lock();
text_only_models::set_test_entries(vec!["llama-3-8b".to_string()]);
let messages = vec![make_message(vec![
ContentBlock::Text {
text: "Hello".to_string(),
},
ContentBlock::Image {
mime_type: "image/png".to_string(),
data: "base64data".to_string(),
filename: Some("screenshot.png".to_string()),
},
ContentBlock::Text {
text: "Look at this".to_string(),
},
])];
let (converted, _) = convert_messages_inner(&messages, None, None, Some("llama-3-8b"));
let blocks = &converted[0].content;
assert_eq!(blocks.len(), 3);
assert!(matches!(&blocks[0], InputContentBlock::Text { text } if text == "Hello"));
assert!(matches!(&blocks[1], InputContentBlock::Text { text } if text.contains("screenshot.png")));
assert!(matches!(&blocks[2], InputContentBlock::Text { text } if text == "Look at this"));
}
#[test]
fn test_text_only_model_filters_imageref_blocks() {
let _lock = text_only_lock();
text_only_models::set_test_entries(vec!["text-only-model".to_string()]);
let messages = vec![make_message(vec![
ContentBlock::Text {
text: "Text".to_string(),
},
ContentBlock::ImageRef {
hash_hex: "abc123".to_string(),
mime_type: "image/png".to_string(),
filename: Some("photo.png".to_string()),
},
])];
let (converted, _) = convert_messages_inner(&messages, None, None, Some("text-only-model"));
let blocks = &converted[0].content;
assert_eq!(blocks.len(), 2);
assert!(matches!(&blocks[0], InputContentBlock::Text { .. }));
assert!(matches!(&blocks[1], InputContentBlock::Text { text } if text.contains("image/png")));
}
#[test]
fn test_multimodal_model_preserves_image_blocks() {
let _lock = text_only_lock();
text_only_models::set_test_entries(vec![]);
let messages = vec![make_message(vec![ContentBlock::Image {
mime_type: "image/png".to_string(),
data: "base64data".to_string(),
filename: Some("test.png".to_string()),
}])];
let (converted, _) = convert_messages_inner(&messages, None, None, Some("claude-sonnet-4"));
let blocks = &converted[0].content;
assert_eq!(blocks.len(), 1);
assert!(matches!(&blocks[0], InputContentBlock::Image { .. }));
}
#[test]
fn test_none_model_defaults_to_image_capable() {
let _lock = text_only_lock();
text_only_models::set_test_entries(vec![]);
let messages = vec![make_message(vec![ContentBlock::Image {
mime_type: "image/png".to_string(),
data: "base64data".to_string(),
filename: None,
}])];
let (converted, _) = convert_messages_inner(&messages, None, None, None);
let blocks = &converted[0].content;
assert_eq!(blocks.len(), 1);
assert!(matches!(&blocks[0], InputContentBlock::Image { .. }));
}
#[test]
fn test_thinking_block_is_preserved_for_api_round_trip() {
let messages = vec![make_message(vec![
ContentBlock::Thinking {
thinking: "Let me reason carefully.".to_string(),
signature: Some("sig123".to_string()),
},
ContentBlock::ToolUse {
id: "tu1".to_string(),
name: "bash".to_string(),
input: serde_json::json!({ "command": "ls" }),
},
])];
let (converted, _) = convert_messages_inner(&messages, None, None, None);
let blocks = &converted[0].content;
assert_eq!(
blocks.len(),
2,
"thinking block must not be dropped; Anthropic requires it for round-trip"
);
assert!(matches!(
&blocks[0],
InputContentBlock::Thinking {
thinking,
signature,
} if thinking == "Let me reason carefully."
&& signature.as_deref() == Some("sig123")
));
}
#[test]
fn test_thinking_block_serializes_as_anthropic_thinking_shape() {
let messages = vec![make_message(vec![ContentBlock::Thinking {
thinking: String::new(),
signature: Some("sig_abc".to_string()),
}])];
let (converted, _) = convert_messages_inner(&messages, None, None, None);
let value = serde_json::to_value(&converted[0]).expect("message should serialize");
let block = &value["content"][0];
assert_eq!(block["type"], "thinking");
assert_eq!(block["signature"], "sig_abc");
}
#[test]
fn test_signature_less_thinking_block_is_not_sent_to_api() {
// Signature-less thinking (redaction placeholders, non-Anthropic
// reasoning models) cannot be authenticated by the Anthropic API, so
// it must be dropped rather than emitted as a malformed thinking block.
let messages = vec![make_message(vec![
ContentBlock::Thinking {
thinking: "reasoning without signature".to_string(),
signature: None,
},
ContentBlock::Text {
text: "visible answer".to_string(),
},
])];
let (converted, _) = convert_messages_inner(&messages, None, None, None);
let blocks = &converted[0].content;
assert_eq!(blocks.len(), 1);
assert!(matches!(&blocks[0], InputContentBlock::Text { text } if text == "visible answer"));
}
#[test]
fn test_redacted_thinking_block_is_echoed_back_with_data() {
// Redacted thinking carries no signature; the ciphertext `data` itself
// is the authentication token. It must be echoed verbatim.
let messages = vec![make_message(vec![
ContentBlock::RedactedThinking {
data: "ciphertext_blob_abc".to_string(),
},
ContentBlock::ToolUse {
id: "tu1".to_string(),
name: "bash".to_string(),
input: serde_json::json!({ "command": "ls" }),
},
])];
let (converted, _) = convert_messages_inner(&messages, None, None, None);
let blocks = &converted[0].content;
assert_eq!(
blocks.len(),
2,
"redacted thinking block must be echoed back for the tool-use round-trip"
);
assert!(matches!(
&blocks[0],
InputContentBlock::RedactedThinking { data }
if data.as_str() == Some("ciphertext_blob_abc")
));
}
}

View File

@ -14,13 +14,7 @@ const CONTEXT_WINDOW_ERROR_MARKERS: &[&str] = &[
"too many tokens", "too many tokens",
"prompt is too long", "prompt is too long",
"input is too long", "input is too long",
"input tokens exceed",
"configured limit",
"messages resulted in",
"completion tokens",
"prompt tokens",
"request is too large", "request is too large",
"no parseable body",
]; ];
#[derive(Debug)] #[derive(Debug)]
@ -61,9 +55,6 @@ pub enum ApiError {
retryable: bool, retryable: bool,
/// Suggested user action based on error type (e.g., "Reduce prompt size" for 413) /// Suggested user action based on error type (e.g., "Reduce prompt size" for 413)
suggested_action: Option<String>, suggested_action: Option<String>,
/// Parsed Retry-After header value (seconds) for 429 responses.
/// When present, overrides the exponential backoff delay.
retry_after: Option<Duration>,
}, },
RetriesExhausted { RetriesExhausted {
attempts: u32, attempts: u32,
@ -132,21 +123,23 @@ impl ApiError {
} }
#[must_use] #[must_use]
/// Return the `Retry-After` delay if this error came from a 429 response
/// that included a `retry-after` header. Callers should prefer this value
/// over the computed backoff delay when it exists.
pub fn retry_after(&self) -> Option<Duration> {
match self {
Self::Api { retry_after, .. } => *retry_after,
Self::RetriesExhausted { last_error, .. } => last_error.retry_after(),
_ => None,
}
}
pub fn is_retryable(&self) -> bool { pub fn is_retryable(&self) -> bool {
match self { match self {
Self::Http(error) => error.is_connect() || error.is_timeout() || error.is_request(), Self::Http(error) => error.is_connect() || error.is_timeout() || error.is_request(),
Self::Api { retryable, .. } => *retryable, Self::Api {
retryable,
error_type,
message,
body,
..
} => {
*retryable
&& !looks_like_balance_error(
error_type.as_deref(),
message.as_deref(),
body,
)
}
Self::RetriesExhausted { last_error, .. } => last_error.is_retryable(), Self::RetriesExhausted { last_error, .. } => last_error.is_retryable(),
Self::MissingCredentials { .. } Self::MissingCredentials { .. }
| Self::ContextWindowExceeded { .. } | Self::ContextWindowExceeded { .. }
@ -278,20 +271,17 @@ impl Display for ApiError {
if let Some(primary) = env_vars.first() { if let Some(primary) = env_vars.first() {
write!( write!(
f, f,
" (on Windows, environment variables set in PowerShell only persist for the current session; use `setx {primary} <value>` to make it permanent, then open a new terminal, or place a `.env` file containing `{primary}=<value>` in the current working directory)" " (on Windows, environment variables set in PowerShell only persist for the current session; use `setx {primary} <value>` to make it permanent, then open a new terminal, or place a `.env` file containing `{primary}=<value>` in the Claw config directory (`~/.claw/.env` or `$CLAW_CONFIG_HOME/.env`))"
)?; )?;
} else { } else {
write!( write!(
f, f,
" (on Windows, environment variables set in PowerShell only persist for the current session; use `setx` to make them permanent, then open a new terminal, or place a `.env` file in the current working directory)" " (on Windows, environment variables set in PowerShell only persist for the current session; use `setx` to make them permanent, then open a new terminal, or place a `.env` file in the Claw config directory (`~/.claw/.env` or `$CLAW_CONFIG_HOME/.env`))"
)?; )?;
} }
} }
if let Some(hint) = hint { if let Some(hint) = hint {
// #754: newline-delimited so split_error_hint() can extract the hint write!(f, " — hint: {hint}")?;
// into the JSON envelope's `hint` field. The em-dash form was a
// single-line string that left hint:null in --output-format json.
write!(f, "\n{hint}")?;
} }
Ok(()) Ok(())
} }
@ -326,36 +316,6 @@ impl Display for ApiError {
f, f,
"failed to parse {provider} response for model {model}: {source}; first 200 chars of body: {body_snippet}" "failed to parse {provider} response for model {model}: {source}; first 200 chars of body: {body_snippet}"
), ),
// #28: enhance 401/403 errors with actionable auth guidance
Self::Api {
status,
error_type,
message,
request_id,
body,
..
} if matches!(status.as_u16(), 401 | 403) => {
if let (Some(error_type), Some(message)) = (error_type, message) {
write!(f, "api returned {status} ({error_type})")?;
if let Some(request_id) = request_id {
write!(f, " [trace {request_id}]")?;
}
write!(f, ": {message}")?;
} else {
write!(f, "api returned {status}")?;
if let Some(request_id) = request_id {
write!(f, " [trace {request_id}]")?;
}
write!(f, ": {body}")?;
}
write!(
f,
"\nhint: check that your API key is valid and matches the target provider. \
For OpenAI-compatible providers set OPENAI_API_KEY or OPENAI_BASE_URL. \
For Anthropic set ANTHROPIC_API_KEY. \
Run `claw doctor` to verify your credential configuration."
)
}
Self::Api { Self::Api {
status, status,
error_type, error_type,
@ -447,6 +407,45 @@ fn looks_like_context_window_error(text: &str) -> bool {
.any(|marker| lowered.contains(marker)) .any(|marker| lowered.contains(marker))
} }
const BALANCE_ERROR_MARKERS: &[&str] = &[
"insufficient_quota",
"insufficient quota",
"insufficient balance",
"insufficient_balance",
"balance is insufficient",
"your account balance",
"account balance is",
"no credits",
"out of credits",
"credit balance",
"insufficient credits",
"balance is too low",
"余额不足",
"payment required",
];
/// Returns true when the provider error (error_type, message or raw body)
/// indicates the account has run out of credits/balance. Such errors are
/// deterministic: retrying cannot fix them, so they must never enter the
/// retry/backoff loop (which would otherwise stall the CLI for minutes on a
/// 429 rate-limit style response from a relay/gateway).
fn looks_like_balance_error(error_type: Option<&str>, message: Option<&str>, body: &str) -> bool {
let mut haystack = String::new();
if let Some(error_type) = error_type {
haystack.push_str(error_type);
haystack.push(' ');
}
if let Some(message) = message {
haystack.push_str(message);
haystack.push(' ');
}
haystack.push_str(body);
let lowered = haystack.to_ascii_lowercase();
BALANCE_ERROR_MARKERS
.iter()
.any(|marker| lowered.contains(marker))
}
/// Truncate `body` so the resulting snippet contains at most `max_chars` /// Truncate `body` so the resulting snippet contains at most `max_chars`
/// characters (counted by Unicode scalar values, not bytes), preserving the /// characters (counted by Unicode scalar values, not bytes), preserving the
/// leading slice of the body that the caller most often needs to inspect. /// leading slice of the body that the caller most often needs to inspect.
@ -544,7 +543,6 @@ mod tests {
body: String::new(), body: String::new(),
retryable: true, retryable: true,
suggested_action: None, suggested_action: None,
retry_after: None,
}; };
assert!(error.is_generic_fatal_wrapper()); assert!(error.is_generic_fatal_wrapper());
@ -568,7 +566,6 @@ mod tests {
body: String::new(), body: String::new(),
retryable: true, retryable: true,
suggested_action: None, suggested_action: None,
retry_after: None,
}), }),
}; };
@ -590,7 +587,6 @@ mod tests {
body: String::new(), body: String::new(),
retryable: false, retryable: false,
suggested_action: None, suggested_action: None,
retry_after: None,
}; };
assert!(error.is_context_window_failure()); assert!(error.is_context_window_failure());
@ -598,33 +594,12 @@ mod tests {
assert_eq!(error.request_id(), Some("req_ctx_123")); assert_eq!(error.request_id(), Some("req_ctx_123"));
} }
#[test]
fn classifies_openai_configured_limit_errors_as_context_window_failures() {
let error = ApiError::Api {
status: reqwest::StatusCode::BAD_REQUEST,
error_type: Some("invalid_request_error".to_string()),
message: Some(
"Input tokens exceed the configured limit of 922000 tokens. Your messages resulted in 1860900 tokens. Please reduce the length of the messages."
.to_string(),
),
request_id: Some("req_ctx_openai_123".to_string()),
body: String::new(),
retryable: false,
suggested_action: None,
retry_after: None,
};
assert!(error.is_context_window_failure());
assert_eq!(error.safe_failure_class(), "context_window");
assert_eq!(error.request_id(), Some("req_ctx_openai_123"));
}
#[test] #[test]
fn missing_credentials_without_hint_renders_the_canonical_message() { fn missing_credentials_without_hint_renders_the_canonical_message() {
// given // given
let error = ApiError::missing_credentials( let error = ApiError::missing_credentials(
"Anthropic", "Anthropic",
&["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"], &["ANTHROPIC_API_KEY"],
); );
// when // when
@ -633,7 +608,7 @@ mod tests {
// then // then
assert!( assert!(
rendered.starts_with( rendered.starts_with(
"missing Anthropic credentials; export ANTHROPIC_AUTH_TOKEN or ANTHROPIC_API_KEY before calling the Anthropic API" "missing Anthropic credentials; export ANTHROPIC_API_KEY before calling the Anthropic API"
), ),
"rendered error should lead with the canonical missing-credential message: {rendered}" "rendered error should lead with the canonical missing-credential message: {rendered}"
); );
@ -643,12 +618,82 @@ mod tests {
); );
} }
#[test]
fn api_429_insufficient_quota_is_not_retryable() {
let error = ApiError::Api {
status: reqwest::StatusCode::TOO_MANY_REQUESTS,
error_type: Some("insufficient_quota".to_string()),
message: Some("Your account balance is insufficient. Please top up.".to_string()),
request_id: Some("req_balance_123".to_string()),
body: String::new(),
retryable: true,
suggested_action: None,
};
assert!(
!error.is_retryable(),
"insufficient_quota must not trigger retry backoff"
);
}
#[test]
fn api_429_chinese_balance_insufficient_is_not_retryable() {
let error = ApiError::Api {
status: reqwest::StatusCode::TOO_MANY_REQUESTS,
error_type: Some("rate_limit_error".to_string()),
message: Some("余额不足,请充值".to_string()),
request_id: Some("req_balance_456".to_string()),
body: String::new(),
retryable: true,
suggested_action: None,
};
assert!(
!error.is_retryable(),
"余额不足 must not trigger retry backoff"
);
}
#[test]
fn api_429_plain_rate_limit_slow_down_remains_retryable() {
let error = ApiError::Api {
status: reqwest::StatusCode::TOO_MANY_REQUESTS,
error_type: Some("rate_limit_error".to_string()),
message: Some("slow down".to_string()),
request_id: Some("req_rate_789".to_string()),
body: String::new(),
retryable: true,
suggested_action: None,
};
assert!(
error.is_retryable(),
"a plain rate-limit 'slow down' must remain retryable"
);
}
#[test]
fn api_429_billing_plan_wording_is_not_mistaken_for_balance_error() {
let error = ApiError::Api {
status: reqwest::StatusCode::TOO_MANY_REQUESTS,
error_type: Some("rate_limit_error".to_string()),
message: Some(
"Your current billing plan allows 100 requests per minute".to_string(),
),
request_id: Some("req_billing_plan".to_string()),
body: String::new(),
retryable: true,
suggested_action: None,
};
assert!(
error.is_retryable(),
"billing-plan rate-limit wording must not be flagged as a balance error"
);
}
#[test] #[test]
fn missing_credentials_with_hint_appends_the_hint_after_base_message() { fn missing_credentials_with_hint_appends_the_hint_after_base_message() {
// given // given
let error = ApiError::missing_credentials_with_hint( let error = ApiError::missing_credentials_with_hint(
"Anthropic", "Anthropic",
&["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"], &["ANTHROPIC_API_KEY"],
"I see OPENAI_API_KEY is set — if you meant to use the OpenAI-compat provider, prefix your model name with `openai/` so prefix routing selects it.", "I see OPENAI_API_KEY is set — if you meant to use the OpenAI-compat provider, prefix your model name with `openai/` so prefix routing selects it.",
); );
@ -660,16 +705,11 @@ mod tests {
rendered.starts_with("missing Anthropic credentials;"), rendered.starts_with("missing Anthropic credentials;"),
"hint should be appended, not replace the base message: {rendered}" "hint should be appended, not replace the base message: {rendered}"
); );
// #754: hint is now newline-delimited so split_error_hint() can extract it let hint_marker = " — hint: I see OPENAI_API_KEY is set — if you meant to use the OpenAI-compat provider, prefix your model name with `openai/` so prefix routing selects it.";
let hint_text = "I see OPENAI_API_KEY is set — if you meant to use the OpenAI-compat provider, prefix your model name with `openai/` so prefix routing selects it.";
assert!( assert!(
rendered.ends_with(hint_text), rendered.ends_with(hint_marker),
"rendered error should end with the hint: {rendered}" "rendered error should end with the hint: {rendered}"
); );
assert!(
rendered.contains('\n'),
"rendered error must contain newline separator so split_error_hint works: {rendered}"
);
// Classification semantics are unaffected by the presence of a hint. // Classification semantics are unaffected by the presence of a hint.
assert_eq!(error.safe_failure_class(), "provider_auth"); assert_eq!(error.safe_failure_class(), "provider_auth");
assert!(!error.is_retryable()); assert!(!error.is_retryable());

View File

@ -1,69 +1,9 @@
use std::time::Duration;
use crate::error::ApiError; use crate::error::ApiError;
const HTTP_PROXY_KEYS: [&str; 2] = ["HTTP_PROXY", "http_proxy"]; const HTTP_PROXY_KEYS: [&str; 2] = ["HTTP_PROXY", "http_proxy"];
const HTTPS_PROXY_KEYS: [&str; 2] = ["HTTPS_PROXY", "https_proxy"]; const HTTPS_PROXY_KEYS: [&str; 2] = ["HTTPS_PROXY", "https_proxy"];
const NO_PROXY_KEYS: [&str; 2] = ["NO_PROXY", "no_proxy"]; const NO_PROXY_KEYS: [&str; 2] = ["NO_PROXY", "no_proxy"];
/// Timeout configuration for outbound HTTP requests.
///
/// When set, the `reqwest::Client` will abort requests that take longer
/// than the configured duration and return a timeout error (which is
/// retryable by the existing exponential backoff logic).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TimeoutConfig {
/// Maximum time to wait for a connection to be established.
/// Defaults to 30 seconds.
pub connect_timeout: Duration,
/// Maximum time for the entire request (including reading the response
/// body). For streaming responses this is the timeout for the initial
/// handshake only; the stream itself is governed by SSE parsing.
/// Defaults to 5 minutes (300 seconds).
pub request_timeout: Duration,
}
impl Default for TimeoutConfig {
fn default() -> Self {
Self {
connect_timeout: Duration::from_secs(30),
request_timeout: Duration::from_secs(300),
}
}
}
impl TimeoutConfig {
/// Read timeout settings from the process environment.
/// - `CLAW_API_CONNECT_TIMEOUT` — connect timeout in seconds
/// - `CLAW_API_REQUEST_TIMEOUT` — overall request timeout in seconds
#[must_use]
pub fn from_env() -> Self {
let connect_timeout = std::env::var("CLAW_API_CONNECT_TIMEOUT")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.map(Duration::from_secs)
.unwrap_or(Duration::from_secs(30));
let request_timeout = std::env::var("CLAW_API_REQUEST_TIMEOUT")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.map(Duration::from_secs)
.unwrap_or(Duration::from_secs(300));
Self {
connect_timeout,
request_timeout,
}
}
/// Create from explicit second values (used by config file parsing).
#[must_use]
pub fn from_seconds(connect_secs: u64, request_secs: u64) -> Self {
Self {
connect_timeout: Duration::from_secs(connect_secs),
request_timeout: Duration::from_secs(request_secs),
}
}
}
/// Snapshot of the proxy-related environment variables that influence the /// Snapshot of the proxy-related environment variables that influence the
/// outbound HTTP client. Captured up front so callers can inspect, log, and /// outbound HTTP client. Captured up front so callers can inspect, log, and
/// test the resolved configuration without re-reading the process environment. /// test the resolved configuration without re-reading the process environment.
@ -121,7 +61,7 @@ impl ProxyConfig {
/// `HTTPS_PROXY`, and `NO_PROXY` environment variables. When no proxy is /// `HTTPS_PROXY`, and `NO_PROXY` environment variables. When no proxy is
/// configured the client behaves identically to `reqwest::Client::new()`. /// configured the client behaves identically to `reqwest::Client::new()`.
pub fn build_http_client() -> Result<reqwest::Client, ApiError> { pub fn build_http_client() -> Result<reqwest::Client, ApiError> {
build_http_client_with_opts(&ProxyConfig::from_env(), &TimeoutConfig::from_env()) build_http_client_with(&ProxyConfig::from_env())
} }
/// Infallible counterpart to [`build_http_client`] for constructors that /// Infallible counterpart to [`build_http_client`] for constructors that
@ -131,13 +71,7 @@ pub fn build_http_client() -> Result<reqwest::Client, ApiError> {
/// first outbound request instead of at construction time. /// first outbound request instead of at construction time.
#[must_use] #[must_use]
pub fn build_http_client_or_default() -> reqwest::Client { pub fn build_http_client_or_default() -> reqwest::Client {
build_http_client_with_opts(&ProxyConfig::from_env(), &TimeoutConfig::from_env()) build_http_client().unwrap_or_else(|_| reqwest::Client::new())
.unwrap_or_else(|_| {
reqwest::Client::builder()
.user_agent("clawd-rust-tools/0.1")
.build()
.expect("default client with user_agent should always succeed")
})
} }
/// Build a `reqwest::Client` from an explicit [`ProxyConfig`]. Used by tests /// Build a `reqwest::Client` from an explicit [`ProxyConfig`]. Used by tests
@ -147,20 +81,7 @@ pub fn build_http_client_or_default() -> reqwest::Client {
/// and `https_proxy` fields and is registered as both an HTTP and HTTPS /// and `https_proxy` fields and is registered as both an HTTP and HTTPS
/// proxy so a single value can route every outbound request. /// proxy so a single value can route every outbound request.
pub fn build_http_client_with(config: &ProxyConfig) -> Result<reqwest::Client, ApiError> { pub fn build_http_client_with(config: &ProxyConfig) -> Result<reqwest::Client, ApiError> {
build_http_client_with_opts(config, &TimeoutConfig::from_env()) let mut builder = reqwest::Client::builder().no_proxy();
}
/// Build a `reqwest::Client` from explicit [`ProxyConfig`] and [`TimeoutConfig`].
/// Used by callers that want to control both proxy routing and request timing.
pub fn build_http_client_with_opts(
config: &ProxyConfig,
timeout: &TimeoutConfig,
) -> Result<reqwest::Client, ApiError> {
let mut builder = reqwest::Client::builder()
.no_proxy()
.user_agent("clawd-rust-tools/0.1")
.connect_timeout(timeout.connect_timeout)
.timeout(timeout.request_timeout);
let no_proxy = config let no_proxy = config
.no_proxy .no_proxy
@ -203,7 +124,7 @@ where
mod tests { mod tests {
use std::collections::HashMap; use std::collections::HashMap;
use super::{build_http_client_with, build_http_client_with_opts, ProxyConfig, TimeoutConfig}; use super::{build_http_client_with, ProxyConfig};
fn config_from_map(pairs: &[(&str, &str)]) -> ProxyConfig { fn config_from_map(pairs: &[(&str, &str)]) -> ProxyConfig {
let map: HashMap<String, String> = pairs let map: HashMap<String, String> = pairs
@ -215,19 +136,30 @@ mod tests {
#[test] #[test]
fn proxy_config_is_empty_when_no_env_vars_are_set() { fn proxy_config_is_empty_when_no_env_vars_are_set() {
// given
let config = config_from_map(&[]); let config = config_from_map(&[]);
assert!(config.is_empty());
// when
let empty = config.is_empty();
// then
assert!(empty);
assert_eq!(config, ProxyConfig::default()); assert_eq!(config, ProxyConfig::default());
} }
#[test] #[test]
fn proxy_config_reads_uppercase_http_https_and_no_proxy() { fn proxy_config_reads_uppercase_http_https_and_no_proxy() {
// given
let pairs = [ let pairs = [
("HTTP_PROXY", "http://proxy.internal:3128"), ("HTTP_PROXY", "http://proxy.internal:3128"),
("HTTPS_PROXY", "http://secure.internal:3129"), ("HTTPS_PROXY", "http://secure.internal:3129"),
("NO_PROXY", "localhost,127.0.0.1,.corp"), ("NO_PROXY", "localhost,127.0.0.1,.corp"),
]; ];
// when
let config = config_from_map(&pairs); let config = config_from_map(&pairs);
// then
assert_eq!( assert_eq!(
config.http_proxy.as_deref(), config.http_proxy.as_deref(),
Some("http://proxy.internal:3128") Some("http://proxy.internal:3128")
@ -245,12 +177,17 @@ mod tests {
#[test] #[test]
fn proxy_config_falls_back_to_lowercase_keys() { fn proxy_config_falls_back_to_lowercase_keys() {
// given
let pairs = [ let pairs = [
("http_proxy", "http://lower.internal:3128"), ("http_proxy", "http://lower.internal:3128"),
("https_proxy", "http://lower-secure.internal:3129"), ("https_proxy", "http://lower-secure.internal:3129"),
("no_proxy", ".lower"), ("no_proxy", ".lower"),
]; ];
// when
let config = config_from_map(&pairs); let config = config_from_map(&pairs);
// then
assert_eq!( assert_eq!(
config.http_proxy.as_deref(), config.http_proxy.as_deref(),
Some("http://lower.internal:3128") Some("http://lower.internal:3128")
@ -264,11 +201,16 @@ mod tests {
#[test] #[test]
fn proxy_config_prefers_uppercase_over_lowercase_when_both_set() { fn proxy_config_prefers_uppercase_over_lowercase_when_both_set() {
// given
let pairs = [ let pairs = [
("HTTP_PROXY", "http://upper.internal:3128"), ("HTTP_PROXY", "http://upper.internal:3128"),
("http_proxy", "http://lower.internal:3128"), ("http_proxy", "http://lower.internal:3128"),
]; ];
// when
let config = config_from_map(&pairs); let config = config_from_map(&pairs);
// then
assert_eq!( assert_eq!(
config.http_proxy.as_deref(), config.http_proxy.as_deref(),
Some("http://upper.internal:3128") Some("http://upper.internal:3128")
@ -277,39 +219,59 @@ mod tests {
#[test] #[test]
fn proxy_config_treats_empty_strings_as_unset() { fn proxy_config_treats_empty_strings_as_unset() {
// given
let pairs = [("HTTP_PROXY", ""), ("http_proxy", "")]; let pairs = [("HTTP_PROXY", ""), ("http_proxy", "")];
// when
let config = config_from_map(&pairs); let config = config_from_map(&pairs);
// then
assert!(config.http_proxy.is_none()); assert!(config.http_proxy.is_none());
} }
#[test] #[test]
fn build_http_client_succeeds_when_no_proxy_is_configured() { fn build_http_client_succeeds_when_no_proxy_is_configured() {
// given
let config = ProxyConfig::default(); let config = ProxyConfig::default();
// when
let result = build_http_client_with(&config); let result = build_http_client_with(&config);
// then
assert!(result.is_ok()); assert!(result.is_ok());
} }
#[test] #[test]
fn build_http_client_succeeds_with_valid_http_and_https_proxies() { fn build_http_client_succeeds_with_valid_http_and_https_proxies() {
// given
let config = ProxyConfig { let config = ProxyConfig {
http_proxy: Some("http://proxy.internal:3128".to_string()), http_proxy: Some("http://proxy.internal:3128".to_string()),
https_proxy: Some("http://secure.internal:3129".to_string()), https_proxy: Some("http://secure.internal:3129".to_string()),
no_proxy: Some("localhost,127.0.0.1".to_string()), no_proxy: Some("localhost,127.0.0.1".to_string()),
proxy_url: None, proxy_url: None,
}; };
// when
let result = build_http_client_with(&config); let result = build_http_client_with(&config);
// then
assert!(result.is_ok()); assert!(result.is_ok());
} }
#[test] #[test]
fn build_http_client_returns_http_error_for_invalid_proxy_url() { fn build_http_client_returns_http_error_for_invalid_proxy_url() {
// given
let config = ProxyConfig { let config = ProxyConfig {
http_proxy: None, http_proxy: None,
https_proxy: Some("not a url".to_string()), https_proxy: Some("not a url".to_string()),
no_proxy: None, no_proxy: None,
proxy_url: None, proxy_url: None,
}; };
// when
let result = build_http_client_with(&config); let result = build_http_client_with(&config);
// then
let error = result.expect_err("invalid proxy URL must be reported as a build failure"); let error = result.expect_err("invalid proxy URL must be reported as a build failure");
assert!( assert!(
matches!(error, crate::error::ApiError::Http(_)), matches!(error, crate::error::ApiError::Http(_)),
@ -319,7 +281,10 @@ mod tests {
#[test] #[test]
fn from_proxy_url_sets_unified_field_and_leaves_per_scheme_empty() { fn from_proxy_url_sets_unified_field_and_leaves_per_scheme_empty() {
// given / when
let config = ProxyConfig::from_proxy_url("http://unified.internal:3128"); let config = ProxyConfig::from_proxy_url("http://unified.internal:3128");
// then
assert_eq!( assert_eq!(
config.proxy_url.as_deref(), config.proxy_url.as_deref(),
Some("http://unified.internal:3128") Some("http://unified.internal:3128")
@ -331,56 +296,49 @@ mod tests {
#[test] #[test]
fn build_http_client_succeeds_with_unified_proxy_url() { fn build_http_client_succeeds_with_unified_proxy_url() {
// given
let config = ProxyConfig { let config = ProxyConfig {
proxy_url: Some("http://unified.internal:3128".to_string()), proxy_url: Some("http://unified.internal:3128".to_string()),
no_proxy: Some("localhost".to_string()), no_proxy: Some("localhost".to_string()),
..ProxyConfig::default() ..ProxyConfig::default()
}; };
// when
let result = build_http_client_with(&config); let result = build_http_client_with(&config);
// then
assert!(result.is_ok()); assert!(result.is_ok());
} }
#[test] #[test]
fn proxy_url_takes_precedence_over_per_scheme_fields() { fn proxy_url_takes_precedence_over_per_scheme_fields() {
// given both per-scheme and unified are set
let config = ProxyConfig { let config = ProxyConfig {
http_proxy: Some("http://per-scheme.internal:1111".to_string()), http_proxy: Some("http://per-scheme.internal:1111".to_string()),
https_proxy: Some("http://per-scheme.internal:2222".to_string()), https_proxy: Some("http://per-scheme.internal:2222".to_string()),
no_proxy: None, no_proxy: None,
proxy_url: Some("http://unified.internal:3128".to_string()), proxy_url: Some("http://unified.internal:3128".to_string()),
}; };
// when building succeeds (the unified URL is valid)
let result = build_http_client_with(&config); let result = build_http_client_with(&config);
// then
assert!(result.is_ok()); assert!(result.is_ok());
} }
#[test] #[test]
fn build_http_client_returns_error_for_invalid_unified_proxy_url() { fn build_http_client_returns_error_for_invalid_unified_proxy_url() {
// given
let config = ProxyConfig::from_proxy_url("not a url"); let config = ProxyConfig::from_proxy_url("not a url");
// when
let result = build_http_client_with(&config); let result = build_http_client_with(&config);
// then
assert!( assert!(
matches!(result, Err(crate::error::ApiError::Http(_))), matches!(result, Err(crate::error::ApiError::Http(_))),
"invalid unified proxy URL should fail: {result:?}" "invalid unified proxy URL should fail: {result:?}"
); );
} }
#[test]
fn timeout_config_defaults() {
let config = TimeoutConfig::default();
assert_eq!(config.connect_timeout, std::time::Duration::from_secs(30));
assert_eq!(config.request_timeout, std::time::Duration::from_secs(300));
}
#[test]
fn timeout_config_from_seconds() {
let config = TimeoutConfig::from_seconds(10, 60);
assert_eq!(config.connect_timeout, std::time::Duration::from_secs(10));
assert_eq!(config.request_timeout, std::time::Duration::from_secs(60));
}
#[test]
fn build_http_client_with_custom_timeouts() {
let config = ProxyConfig::default();
let timeout = TimeoutConfig::from_seconds(5, 120);
let result = build_http_client_with_opts(&config, &timeout);
assert!(result.is_ok());
}
} }

View File

@ -0,0 +1,493 @@
use serde_json::{json, Map, Value};
use crate::types::MessageRequest;
/// Incrementally-built JSON request body that caches per-message serialization
/// and avoids re-serializing the entire message list on every API call.
///
/// ## Typical workflow (per agentic-loop iteration)
/// 1. Build a fresh `MessageRequest` (or reuse the previous one with a new
/// message appended).
/// 2. Call `update(&request)` — only new/uncached messages are serialized.
/// 3. Call `build()` or `build_bytes()` to obtain the final body.
///
/// ## Base invalidation
/// The "base" portion (`model`, `max_tokens`, `system`, `tools`, `tool_choice`,
/// `stream`, tuning knobs) is cached until a field actually changes. Changes
/// are detected via a simplified content hash of the non-message fields.
///
/// ## Zero-alloc build\_bytes
/// Messages are cached as pre-serialized `Vec<u8>` so `build_bytes()` can
/// concatenate them directly into a single buffer without any intermediate
/// `Value` tree allocation.
#[derive(Debug, Clone)]
pub struct IncrementalBody {
/// Cached serialisation of the non-message fields (model, system, tools, …).
base: Option<Map<String, Value>>,
/// Per-message pre-serialised JSON bytes.
cached_message_bytes: Vec<Vec<u8>>,
/// Hash of the base-determining fields at the last rebuild.
base_hash: u64,
}
impl IncrementalBody {
pub fn new() -> Self {
Self {
base: None,
cached_message_bytes: Vec::new(),
base_hash: 0,
}
}
/// Update the cache with a new request.
///
/// * If the base (non-message fields) changed → rebuild base.
/// * If messages grew (delta) → serialise only the new messages.
/// * If messages shrunk (e.g. after compaction) → truncate internal cache.
///
/// When `request.cached_message_values` is non-empty, cached JSON values
/// from that vector are used for delta messages, skipping re-serialisation.
pub fn update(&mut self, request: &MessageRequest) {
let new_hash = hash_base(request);
if self.base.is_none() || new_hash != self.base_hash {
self.base = Some(serialise_base(request));
self.base_hash = new_hash;
}
let msg_count = request.messages.len();
if msg_count > self.cached_message_bytes.len() {
let base_len = self.cached_message_bytes.len();
for (i, msg) in request.messages[base_len..]
.iter()
.enumerate()
{
let abs_idx = base_len + i;
let bytes: Vec<u8> = request
.cached_message_values
.get(abs_idx)
.and_then(|v| v.clone())
.map(|val| serde_json::to_vec(&val).unwrap_or_default())
.unwrap_or_else(|| serde_json::to_vec(msg).unwrap_or_default());
self.cached_message_bytes.push(bytes);
}
} else if msg_count < self.cached_message_bytes.len() {
self.cached_message_bytes.truncate(msg_count);
}
}
/// Build the full request body as a JSON `Value`.
///
/// Post-processing (image normalisation, system-prompt cache-control,
/// tools cache-control) must be applied separately if needed.
pub fn build(&self) -> Value {
let mut body = self.base.clone().unwrap_or_default();
body.insert(
"messages".to_string(),
Value::Array(
self.cached_message_bytes
.iter()
.map(|b| serde_json::from_slice(b).unwrap_or(Value::Null))
.collect(),
),
);
Value::Object(body)
}
/// Build the full request body as serialised JSON bytes.
///
/// Concatenates pre-serialised base fields and pre-serialised messages
/// directly into a single buffer — no intermediate `Value` trees are
/// allocated beyond the base fields that are stored as `Value`.
pub fn build_bytes(&self) -> Vec<u8> {
let mut buf = Vec::new();
buf.push(b'{');
let mut written = false;
if let Some(ref base) = self.base {
for (i, (key, val)) in base.iter().enumerate() {
if i > 0 {
buf.push(b',');
}
written = true;
append_json_string(&mut buf, key);
buf.push(b':');
append_json_value(&mut buf, val);
}
}
if written {
buf.push(b',');
}
buf.extend_from_slice(b"\"messages\":[");
for (i, msg_bytes) in self.cached_message_bytes.iter().enumerate() {
if i > 0 {
buf.push(b',');
}
buf.extend_from_slice(msg_bytes);
}
buf.push(b']');
buf.push(b'}');
buf
}
/// Clear the cache entirely (forces a full rebuild on next `update`).
pub fn invalidate(&mut self) {
self.base = None;
self.cached_message_bytes.clear();
self.base_hash = 0;
}
}
// ── Helpers ───────────────────────────────────────────────────────────────
/// Build a `Map` of only the non-message fields from a `MessageRequest`.
///
/// Unlike serialising the full `MessageRequest` and removing `"messages"`,
/// this constructs the map directly from individual fields — never
/// touching (let alone serialising) the potentially-large message vector.
fn serialise_base(request: &MessageRequest) -> Map<String, Value> {
let mut map = Map::new();
map.insert("model".into(), Value::String(request.model.clone()));
map.insert("max_tokens".into(), json!(request.max_tokens));
serialise_system_cache_control(&mut map, request.system.as_deref());
if !request.skip_tools {
serialise_tools_cache_control(&mut map, &request.tools);
}
if let Some(ref tc) = request.tool_choice {
map.insert("tool_choice".into(), serde_json::to_value(tc).unwrap_or_default());
}
if request.stream {
map.insert("stream".into(), Value::Bool(true));
}
if let Some(ref v) = request.temperature {
map.insert("temperature".into(), json!(v));
}
if let Some(ref v) = request.top_p {
map.insert("top_p".into(), json!(v));
}
// frequency_penalty and presence_penalty are not supported by Anthropic's
// /v1/messages endpoint, so we intentionally omit them here.
// `stop` is renamed to `stop_sequences` for Anthropic.
if let Some(ref v) = request.stop {
if !v.is_empty() {
map.insert("stop_sequences".into(), serde_json::to_value(v).unwrap_or_default());
}
}
if let Some(ref v) = request.reasoning_effort {
map.insert("reasoning_effort".into(), Value::String(v.clone()));
}
if let Some(ref v) = request.thinking {
map.insert("thinking".into(), serde_json::to_value(v).unwrap_or_default());
}
map
}
/// Split the flat system-prompt string at the dynamic boundary and emit
/// the Anthropic block array with `cache_control: ephemeral` on the static
/// portion. Mirrors `MessageRequest::apply_system_prompt_cache_control`.
fn serialise_system_cache_control(map: &mut Map<String, Value>, system: Option<&str>) {
let Some(system_str) = system.filter(|s| !s.is_empty()) else {
return;
};
let boundary = runtime::SYSTEM_PROMPT_DYNAMIC_BOUNDARY;
let blocks = if let Some(split_pos) = system_str.find(boundary) {
let static_part = system_str[..split_pos].trim_end();
let dynamic_part = system_str[split_pos + boundary.len()..].trim_start();
let mut blocks = Vec::new();
if !static_part.is_empty() {
blocks.push(serde_json::json!({
"type": "text",
"text": static_part,
"cache_control": { "type": "ephemeral" }
}));
}
if !dynamic_part.is_empty() {
// The dynamic portion changes every request, so a cache breakpoint
// here is useless and fragments the prefix cache. Only the static
// block above keeps `cache_control`.
blocks.push(serde_json::json!({
"type": "text",
"text": dynamic_part
}));
}
blocks
} else {
vec![serde_json::json!({
"type": "text",
"text": system_str,
"cache_control": { "type": "ephemeral" }
})]
};
if !blocks.is_empty() {
map.insert("system".into(), Value::Array(blocks));
}
}
/// Add `cache_control: ephemeral` to the last tool definition.
/// Mirrors `MessageRequest::apply_tools_cache_control`.
fn serialise_tools_cache_control(map: &mut Map<String, Value>, tools: &Option<Vec<crate::types::ToolDefinition>>) {
let Some(ref tools) = tools else {
return;
};
if tools.is_empty() {
return;
}
let mut values: Vec<Value> = Vec::with_capacity(tools.len());
for (i, tool) in tools.iter().enumerate() {
let mut val = serde_json::to_value(tool).unwrap_or_default();
if i == tools.len() - 1 {
if let Some(obj) = val.as_object_mut() {
obj.insert(
"cache_control".to_string(),
serde_json::json!({ "type": "ephemeral" }),
);
}
}
values.push(val);
}
map.insert("tools".into(), Value::Array(values));
}
/// Deterministic hash of the non-message fields so we can detect changes.
fn hash_base(request: &MessageRequest) -> u64 {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
request.model.hash(&mut hasher);
request.max_tokens.hash(&mut hasher);
request.system.hash(&mut hasher);
request.stream.hash(&mut hasher);
if let Some(ref tools) = request.tools {
for t in tools {
t.name.hash(&mut hasher);
}
}
request.tool_choice.hash(&mut hasher);
request.temperature.map(|v| v.to_bits()).hash(&mut hasher);
request.top_p.map(|v| v.to_bits()).hash(&mut hasher);
request.frequency_penalty.map(|v| v.to_bits()).hash(&mut hasher);
request.presence_penalty.map(|v| v.to_bits()).hash(&mut hasher);
request.stop.hash(&mut hasher);
request.reasoning_effort.hash(&mut hasher);
request.thinking.hash(&mut hasher);
request.skip_tools.hash(&mut hasher);
hasher.finish()
}
fn append_json_string(buf: &mut Vec<u8>, s: &str) {
buf.push(b'"');
for byte in s.bytes() {
match byte {
b'"' => buf.extend_from_slice(b"\\\""),
b'\\' => buf.extend_from_slice(b"\\\\"),
b'\n' => buf.extend_from_slice(b"\\n"),
b'\r' => buf.extend_from_slice(b"\\r"),
b'\t' => buf.extend_from_slice(b"\\t"),
0x08 => buf.extend_from_slice(b"\\b"),
0x0C => buf.extend_from_slice(b"\\f"),
c if c < 0x20 => {
write_hex_escape(buf, c);
}
c => buf.push(c),
}
}
buf.push(b'"');
}
fn write_hex_escape(buf: &mut Vec<u8>, byte: u8) {
const HEX: &[u8; 16] = b"0123456789abcdef";
buf.push(b'\\');
buf.push(b'u');
buf.push(b'0');
buf.push(b'0');
buf.push(HEX[(byte >> 4) as usize]);
buf.push(HEX[(byte & 0x0F) as usize]);
}
fn append_json_value(buf: &mut Vec<u8>, val: &Value) {
match val {
Value::Null => buf.extend_from_slice(b"null"),
Value::Bool(true) => buf.extend_from_slice(b"true"),
Value::Bool(false) => buf.extend_from_slice(b"false"),
Value::Number(n) => {
buf.extend_from_slice(n.to_string().as_bytes());
}
Value::String(s) => append_json_string(buf, s),
Value::Array(arr) => {
buf.push(b'[');
for (i, v) in arr.iter().enumerate() {
if i > 0 {
buf.push(b',');
}
append_json_value(buf, v);
}
buf.push(b']');
}
Value::Object(obj) => {
buf.push(b'{');
for (i, (key, val)) in obj.iter().enumerate() {
if i > 0 {
buf.push(b',');
}
append_json_string(buf, key);
buf.push(b':');
append_json_value(buf, val);
}
buf.push(b'}');
}
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use crate::types::{InputMessage, ToolDefinition, ToolChoice};
use super::*;
fn sample_request(msg_count: usize) -> MessageRequest {
MessageRequest {
model: "claude-sonnet-4-6".to_string(),
max_tokens: 1024,
messages: Arc::new(
(0..msg_count)
.map(|i| InputMessage::user_text(format!("message {i}")))
.collect(),
),
system: Some(Arc::from("You are a helpful assistant.")),
tools: Some(vec![ToolDefinition {
name: "bash".to_string(),
description: Some("Run a shell command".to_string()),
input_schema: serde_json::json!({"type": "object"}),
}]),
tool_choice: Some(ToolChoice::Auto),
stream: true,
..Default::default()
}
}
#[test]
fn full_build_produces_valid_json() {
let request = sample_request(3);
let mut body = IncrementalBody::new();
body.update(&request);
let value = body.build();
assert_eq!(value["model"], "claude-sonnet-4-6");
assert_eq!(value["max_tokens"], 1024);
// System prompt is now wrapped in cache_control array by serialise_base.
assert_eq!(
value["system"][0]["text"],
"You are a helpful assistant."
);
assert!(value.get("tools").is_some());
assert_eq!(
value["messages"].as_array().map(Vec::len),
Some(3)
);
}
#[test]
fn incremental_update_only_serialises_delta() {
let mut body = IncrementalBody::new();
let req1 = sample_request(2);
body.update(&req1);
assert_eq!(body.cached_message_bytes.len(), 2);
let req2 = sample_request(5);
body.update(&req2);
assert_eq!(body.cached_message_bytes.len(), 5);
let value = body.build();
assert_eq!(
value["messages"].as_array().map(Vec::len),
Some(5)
);
}
#[test]
fn truncation_handles_compaction() {
let mut body = IncrementalBody::new();
body.update(&sample_request(10));
assert_eq!(body.cached_message_bytes.len(), 10);
body.update(&sample_request(4));
assert_eq!(body.cached_message_bytes.len(), 4);
let value = body.build();
assert_eq!(
value["messages"].as_array().map(Vec::len),
Some(4)
);
}
#[test]
fn base_hash_changes_on_model_switch() {
let mut body = IncrementalBody::new();
let req1 = sample_request(1);
body.update(&req1);
let hash1 = body.base_hash;
let mut req2 = sample_request(1);
req2.model = "claude-opus-4-6".to_string();
body.update(&req2);
assert_ne!(body.base_hash, hash1, "model change should alter base hash");
}
#[test]
fn build_bytes_round_trips() {
let request = sample_request(3);
let mut body = IncrementalBody::new();
body.update(&request);
let bytes = body.build_bytes();
let parsed: serde_json::Value =
serde_json::from_slice(&bytes).expect("build_bytes should be valid JSON");
assert_eq!(parsed["model"], "claude-sonnet-4-6");
assert_eq!(parsed["max_tokens"], 1024);
// System prompt is now wrapped in cache_control array by serialise_base.
assert_eq!(
parsed["system"][0]["text"],
"You are a helpful assistant."
);
assert_eq!(
parsed["messages"].as_array().map(Vec::len),
Some(3)
);
assert_eq!(
parsed["messages"][0]["content"][0]["text"],
"message 0"
);
}
#[test]
fn serialise_base_omits_messages() {
let request = sample_request(100);
let map = serialise_base(&request);
assert!(
!map.contains_key("messages"),
"serialise_base must not include the messages field"
);
assert_eq!(map.get("model").and_then(|v| v.as_str()), Some("claude-sonnet-4-6"));
assert_eq!(map.get("max_tokens").and_then(|v| v.as_u64()), Some(1024));
// System is now wrapped in cache_control array rather than flat string.
assert!(
map.get("system").and_then(|v| v.as_array()).is_some(),
"system should be a cache-controlled array"
);
}
}

View File

@ -1,19 +1,22 @@
mod client; mod client;
mod convert;
mod error; mod error;
mod http_client; mod http_client;
pub mod incremental_body;
mod prompt_cache; mod prompt_cache;
mod providers; mod providers;
mod sse; mod sse;
mod types; mod types;
pub use convert::{convert_messages, convert_messages_cached, convert_messages_inner};
pub use client::{ pub use client::{
oauth_token_is_expired, read_base_url, read_xai_base_url, resolve_saved_oauth_token, oauth_token_is_expired, read_base_url, resolve_saved_oauth_token,
resolve_startup_auth_source, MessageStream, OAuthTokenSet, ProviderClient, resolve_startup_auth_source, MessageStream, OAuthTokenSet, ProviderClient,
}; };
pub use error::ApiError; pub use error::ApiError;
pub use http_client::{ pub use http_client::{
build_http_client, build_http_client_or_default, build_http_client_with, build_http_client, build_http_client_or_default, build_http_client_with, ProxyConfig,
build_http_client_with_opts, ProxyConfig, TimeoutConfig,
}; };
pub use prompt_cache::{ pub use prompt_cache::{
CacheBreakEvent, PromptCache, PromptCacheConfig, PromptCachePaths, PromptCacheRecord, CacheBreakEvent, PromptCache, PromptCacheConfig, PromptCachePaths, PromptCacheRecord,
@ -21,23 +24,21 @@ pub use prompt_cache::{
}; };
pub use providers::anthropic::{AnthropicClient, AnthropicClient as ApiClient, AuthSource}; pub use providers::anthropic::{AnthropicClient, AnthropicClient as ApiClient, AuthSource};
pub use providers::openai_compat::{ pub use providers::openai_compat::{
build_chat_completion_request, check_request_body_size, estimate_request_body_size, build_chat_completion_request, flatten_tool_result_content, is_reasoning_model,
flatten_tool_result_content, is_reasoning_model, model_rejects_is_error_field, model_rejects_is_error_field, translate_message, OpenAiCompatClient, OpenAiCompatConfig,
model_requires_reasoning_content_in_history, translate_message, OpenAiCompatClient,
OpenAiCompatConfig,
}; };
pub use providers::{ pub use providers::{
detect_provider_kind, max_tokens_for_model, max_tokens_for_model_with_override, detect_provider_kind, is_local_inference, load_env_file_to_process, max_tokens_for_model,
model_family_identity_for, model_family_identity_for_kind, provider_diagnostics_for_model, max_tokens_for_model_with_override, resolve_model_alias, ProviderKind,
resolve_model_alias, ProviderDiagnostics, ProviderKind,
}; };
pub use sse::{parse_frame, SseParser}; pub use sse::{parse_frame, SseParser};
pub use types::{ pub use types::{
ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent, ContentBlockStopEvent, ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent, ContentBlockStopEvent,
InputContentBlock, InputMessage, MessageDelta, MessageDeltaEvent, MessageRequest, InputContentBlock, InputMessage, MessageDelta, MessageDeltaEvent, MessageRequest,
MessageResponse, MessageStartEvent, MessageStopEvent, OutputContentBlock, StreamEvent, MessageResponse, MessageStartEvent, MessageStopEvent, OutputContentBlock, StreamEvent,
ToolChoice, ToolDefinition, ToolResultContentBlock, Usage, ThinkingConfig, ToolChoice, ToolDefinition, ToolResultContentBlock, Usage,
}; };
pub use types::render_tools_block;
pub use telemetry::{ pub use telemetry::{
AnalyticsEvent, AnthropicRequestProfile, ClientIdentity, JsonlTelemetrySink, AnalyticsEvent, AnthropicRequestProfile, ClientIdentity, JsonlTelemetrySink,

View File

@ -1,3 +1,4 @@
use std::collections::VecDeque;
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
@ -13,6 +14,7 @@ const DEFAULT_BREAK_MIN_DROP: u32 = 2_000;
const MAX_SANITIZED_LENGTH: usize = 80; const MAX_SANITIZED_LENGTH: usize = 80;
const REQUEST_FINGERPRINT_VERSION: u32 = 1; const REQUEST_FINGERPRINT_VERSION: u32 = 1;
const REQUEST_FINGERPRINT_PREFIX: &str = "v1"; const REQUEST_FINGERPRINT_PREFIX: &str = "v1";
const PREVIOUS_WINDOW_SIZE: usize = 3;
const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325; const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
const FNV_PRIME: u64 = 0x0000_0100_0000_01b3; const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
@ -120,7 +122,13 @@ impl PromptCache {
pub fn with_config(config: PromptCacheConfig) -> Self { pub fn with_config(config: PromptCacheConfig) -> Self {
let paths = PromptCachePaths::for_session(&config.session_id); let paths = PromptCachePaths::for_session(&config.session_id);
let stats = read_json::<PromptCacheStats>(&paths.stats_path).unwrap_or_default(); let stats = read_json::<PromptCacheStats>(&paths.stats_path).unwrap_or_default();
let previous = read_json::<TrackedPromptState>(&paths.session_state_path); let previous = read_json::<TrackedPromptState>(&paths.session_state_path)
.map(|state| {
let mut deque = VecDeque::with_capacity(PREVIOUS_WINDOW_SIZE);
deque.push_back(state);
deque
})
.unwrap_or_default();
Self { Self {
inner: Arc::new(Mutex::new(PromptCacheInner { inner: Arc::new(Mutex::new(PromptCacheInner {
config, config,
@ -144,14 +152,10 @@ impl PromptCache {
#[must_use] #[must_use]
pub fn lookup_completion(&self, request: &MessageRequest) -> Option<MessageResponse> { pub fn lookup_completion(&self, request: &MessageRequest) -> Option<MessageResponse> {
let request_hash = request_hash_hex(request); let request_hash = request_hash_hex(request);
let (paths, ttl) = { let mut inner = self.lock();
let inner = self.lock(); let entry_path = inner.paths.completion_entry_path(&request_hash);
(inner.paths.clone(), inner.config.completion_ttl)
};
let entry_path = paths.completion_entry_path(&request_hash);
let entry = read_json::<CompletionCacheEntry>(&entry_path); let entry = read_json::<CompletionCacheEntry>(&entry_path);
let Some(entry) = entry else { let Some(entry) = entry else {
let mut inner = self.lock();
inner.stats.completion_cache_misses += 1; inner.stats.completion_cache_misses += 1;
inner.stats.last_completion_cache_key = Some(request_hash); inner.stats.last_completion_cache_key = Some(request_hash);
persist_state(&inner); persist_state(&inner);
@ -159,20 +163,18 @@ impl PromptCache {
}; };
if entry.fingerprint_version != current_fingerprint_version() { if entry.fingerprint_version != current_fingerprint_version() {
let mut inner = self.lock();
inner.stats.completion_cache_misses += 1; inner.stats.completion_cache_misses += 1;
inner.stats.last_completion_cache_key = Some(request_hash.clone()); inner.stats.last_completion_cache_key = Some(request_hash.clone());
let _ = fs::remove_file(entry_path); let _ = fs::remove_file(&entry_path);
persist_state(&inner); persist_state(&inner);
return None; return None;
} }
let expired = now_unix_secs().saturating_sub(entry.cached_at_unix_secs) >= ttl.as_secs(); let expired = now_unix_secs().saturating_sub(entry.cached_at_unix_secs)
let mut inner = self.lock(); >= inner.config.completion_ttl.as_secs();
inner.stats.last_completion_cache_key = Some(request_hash.clone());
if expired { if expired {
inner.stats.completion_cache_misses += 1; inner.stats.completion_cache_misses += 1;
let _ = fs::remove_file(entry_path); let _ = fs::remove_file(&entry_path);
persist_state(&inner); persist_state(&inner);
return None; return None;
} }
@ -184,10 +186,12 @@ impl PromptCache {
&request_hash, &request_hash,
"completion-cache", "completion-cache",
); );
inner.previous = Some(TrackedPromptState::from_usage( inner
request, .previous
&entry.response.usage, .push_back(TrackedPromptState::from_usage(request, &entry.response.usage));
)); if inner.previous.len() > PREVIOUS_WINDOW_SIZE {
inner.previous.pop_front();
}
persist_state(&inner); persist_state(&inner);
Some(entry.response) Some(entry.response)
} }
@ -214,7 +218,7 @@ impl PromptCache {
) -> PromptCacheRecord { ) -> PromptCacheRecord {
let request_hash = request_hash_hex(request); let request_hash = request_hash_hex(request);
let mut inner = self.lock(); let mut inner = self.lock();
let previous = inner.previous.clone(); let previous = inner.previous.back().cloned();
let current = TrackedPromptState::from_usage(request, usage); let current = TrackedPromptState::from_usage(request, usage);
let cache_break = detect_cache_break(&inner.config, previous.as_ref(), &current); let cache_break = detect_cache_break(&inner.config, previous.as_ref(), &current);
@ -229,7 +233,10 @@ impl PromptCache {
inner.stats.last_break_reason = Some(event.reason.clone()); inner.stats.last_break_reason = Some(event.reason.clone());
} }
inner.previous = Some(current); inner.previous.push_back(current);
if inner.previous.len() > PREVIOUS_WINDOW_SIZE {
inner.previous.pop_front();
}
if let Some(response) = response { if let Some(response) = response {
write_completion_entry(&inner.paths, &request_hash, response); write_completion_entry(&inner.paths, &request_hash, response);
inner.stats.completion_cache_writes += 1; inner.stats.completion_cache_writes += 1;
@ -254,7 +261,7 @@ struct PromptCacheInner {
config: PromptCacheConfig, config: PromptCacheConfig,
paths: PromptCachePaths, paths: PromptCachePaths,
stats: PromptCacheStats, stats: PromptCacheStats,
previous: Option<TrackedPromptState>, previous: VecDeque<TrackedPromptState>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@ -398,7 +405,7 @@ fn apply_usage_to_stats(
fn persist_state(inner: &PromptCacheInner) { fn persist_state(inner: &PromptCacheInner) {
let _ = ensure_cache_dirs(&inner.paths); let _ = ensure_cache_dirs(&inner.paths);
let _ = write_json(&inner.paths.stats_path, &inner.stats); let _ = write_json(&inner.paths.stats_path, &inner.stats);
if let Some(previous) = &inner.previous { if let Some(previous) = inner.previous.back() {
let _ = write_json(&inner.paths.session_state_path, previous); let _ = write_json(&inner.paths.session_state_path, previous);
} }
} }
@ -440,7 +447,7 @@ fn request_hash_hex(request: &MessageRequest) -> String {
} }
fn hash_serializable<T: Serialize>(value: &T) -> u64 { fn hash_serializable<T: Serialize>(value: &T) -> u64 {
let json = serde_json::to_vec(value).unwrap_or_default(); let json = serde_json::to_vec(value).expect("hash_serializable: serialization failed");
stable_hash_bytes(&json) stable_hash_bytes(&json)
} }
@ -500,7 +507,7 @@ fn stable_hash_bytes(bytes: &[u8]) -> u64 {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::sync::{Mutex, OnceLock}; use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Duration, SystemTime, UNIX_EPOCH}; use std::time::{Duration, SystemTime, UNIX_EPOCH};
use super::{ use super::{
@ -699,8 +706,8 @@ mod tests {
MessageRequest { MessageRequest {
model: "claude-3-7-sonnet-latest".to_string(), model: "claude-3-7-sonnet-latest".to_string(),
max_tokens: 64, max_tokens: 64,
messages: vec![InputMessage::user_text(text)], messages: Arc::new(vec![InputMessage::user_text(text)]),
system: Some("system".to_string()), system: Some(Arc::from("system")),
tools: None, tools: None,
tool_choice: None, tool_choice: None,
stream: false, stream: false,

View File

@ -3,6 +3,8 @@ use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH}; use std::time::{Duration, SystemTime, UNIX_EPOCH};
use crate::incremental_body::IncrementalBody;
use runtime::format_usd; use runtime::format_usd;
use runtime::{ use runtime::{
load_oauth_credentials, save_oauth_credentials, OAuthConfig, OAuthRefreshRequest, load_oauth_credentials, save_oauth_credentials, OAuthConfig, OAuthRefreshRequest,
@ -17,10 +19,13 @@ use crate::http_client::build_http_client_or_default;
use crate::prompt_cache::{PromptCache, PromptCacheRecord, PromptCacheStats}; use crate::prompt_cache::{PromptCache, PromptCacheRecord, PromptCacheStats};
use super::{ use super::{
anthropic_missing_credentials, model_token_limit, resolve_model_alias, Provider, ProviderFuture, anthropic_missing_credentials, is_local_inference, model_token_limit, resolve_model_alias,
Provider, ProviderFuture,
}; };
use crate::sse::SseParser; use crate::sse::SseParser;
use crate::types::{MessageDeltaEvent, MessageRequest, MessageResponse, StreamEvent, Usage}; use crate::types::{
InputContentBlock, MessageDeltaEvent, MessageRequest, MessageResponse, StreamEvent, Usage,
};
pub const DEFAULT_BASE_URL: &str = "https://api.anthropic.com"; pub const DEFAULT_BASE_URL: &str = "https://api.anthropic.com";
const REQUEST_ID_HEADER: &str = "request-id"; const REQUEST_ID_HEADER: &str = "request-id";
@ -33,64 +38,38 @@ const DEFAULT_MAX_RETRIES: u32 = 8;
pub enum AuthSource { pub enum AuthSource {
None, None,
ApiKey(String), ApiKey(String),
BearerToken(String),
ApiKeyAndBearer {
api_key: String,
bearer_token: String,
},
} }
impl AuthSource { impl AuthSource {
pub fn from_env() -> Result<Self, ApiError> { pub fn from_env() -> Result<Self, ApiError> {
let api_key = read_env_non_empty("ANTHROPIC_API_KEY")?; match read_env_non_empty("ANTHROPIC_API_KEY")? {
let auth_token = read_env_non_empty("ANTHROPIC_AUTH_TOKEN")?; Some(api_key) => Ok(Self::ApiKey(api_key)),
match (api_key, auth_token) { None => Err(anthropic_missing_credentials()),
(Some(api_key), Some(bearer_token)) => Ok(Self::ApiKeyAndBearer {
api_key,
bearer_token,
}),
(Some(api_key), None) => Ok(Self::ApiKey(api_key)),
(None, Some(bearer_token)) => Ok(Self::BearerToken(bearer_token)),
(None, None) => Err(anthropic_missing_credentials()),
} }
} }
#[must_use] #[must_use]
pub fn api_key(&self) -> Option<&str> { pub fn api_key(&self) -> Option<&str> {
match self { match self {
Self::ApiKey(api_key) | Self::ApiKeyAndBearer { api_key, .. } => Some(api_key), Self::ApiKey(api_key) => Some(api_key),
Self::None | Self::BearerToken(_) => None, Self::None => None,
} }
} }
#[must_use] #[must_use]
pub fn bearer_token(&self) -> Option<&str> { pub fn bearer_token(&self) -> Option<&str> {
match self { None
Self::BearerToken(token)
| Self::ApiKeyAndBearer {
bearer_token: token,
..
} => Some(token),
Self::None | Self::ApiKey(_) => None,
}
} }
#[must_use] #[must_use]
pub fn masked_authorization_header(&self) -> &'static str { pub fn masked_authorization_header(&self) -> &'static str {
if self.bearer_token().is_some() { "<absent>"
"Bearer [REDACTED]"
} else {
"<absent>"
}
} }
pub fn apply(&self, mut request_builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder { pub fn apply(&self, mut request_builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
if let Some(api_key) = self.api_key() { if let Some(api_key) = self.api_key() {
request_builder = request_builder.header("x-api-key", api_key); request_builder = request_builder.header("x-api-key", api_key);
} }
if let Some(token) = self.bearer_token() {
request_builder = request_builder.bearer_auth(token);
}
request_builder request_builder
} }
} }
@ -105,8 +84,8 @@ pub struct OAuthTokenSet {
} }
impl From<OAuthTokenSet> for AuthSource { impl From<OAuthTokenSet> for AuthSource {
fn from(value: OAuthTokenSet) -> Self { fn from(_value: OAuthTokenSet) -> Self {
Self::BearerToken(value.access_token) Self::None
} }
} }
@ -122,6 +101,7 @@ pub struct AnthropicClient {
session_tracer: Option<SessionTracer>, session_tracer: Option<SessionTracer>,
prompt_cache: Option<PromptCache>, prompt_cache: Option<PromptCache>,
last_prompt_cache_record: Arc<Mutex<Option<PromptCacheRecord>>>, last_prompt_cache_record: Arc<Mutex<Option<PromptCacheRecord>>>,
incremental_body: Arc<std::sync::Mutex<Option<IncrementalBody>>>,
} }
impl AnthropicClient { impl AnthropicClient {
@ -138,6 +118,7 @@ impl AnthropicClient {
session_tracer: None, session_tracer: None,
prompt_cache: None, prompt_cache: None,
last_prompt_cache_record: Arc::new(Mutex::new(None)), last_prompt_cache_record: Arc::new(Mutex::new(None)),
incremental_body: Arc::new(std::sync::Mutex::new(None)),
} }
} }
@ -154,6 +135,7 @@ impl AnthropicClient {
session_tracer: None, session_tracer: None,
prompt_cache: None, prompt_cache: None,
last_prompt_cache_record: Arc::new(Mutex::new(None)), last_prompt_cache_record: Arc::new(Mutex::new(None)),
incremental_body: Arc::new(std::sync::Mutex::new(None)),
} }
} }
@ -168,27 +150,11 @@ impl AnthropicClient {
} }
#[must_use] #[must_use]
pub fn with_auth_token(mut self, auth_token: Option<String>) -> Self { pub fn with_auth_token(mut self, _auth_token: Option<String>) -> Self {
match ( self.auth = match self.auth.api_key().map(ToOwned::to_owned) {
self.auth.api_key().map(ToOwned::to_owned), Some(api_key) => AuthSource::ApiKey(api_key),
auth_token.filter(|token| !token.is_empty()), None => AuthSource::None,
) { };
(Some(api_key), Some(bearer_token)) => {
self.auth = AuthSource::ApiKeyAndBearer {
api_key,
bearer_token,
};
}
(Some(api_key), None) => {
self.auth = AuthSource::ApiKey(api_key);
}
(None, Some(bearer_token)) => {
self.auth = AuthSource::BearerToken(bearer_token);
}
(None, None) => {
self.auth = AuthSource::None;
}
}
self self
} }
@ -211,19 +177,6 @@ impl AnthropicClient {
self self
} }
/// Replace the internal HTTP client with one that respects the given
/// timeout configuration. This controls connect and request-level
/// timeouts for all outbound API calls.
#[must_use]
pub fn with_timeout(mut self, timeout: &crate::http_client::TimeoutConfig) -> Self {
self.http = crate::http_client::build_http_client_with_opts(
&crate::http_client::ProxyConfig::from_env(),
timeout,
)
.unwrap_or_else(|_| reqwest::Client::new());
self
}
#[must_use] #[must_use]
pub fn with_session_tracer(mut self, session_tracer: SessionTracer) -> Self { pub fn with_session_tracer(mut self, session_tracer: SessionTracer) -> Self {
self.session_tracer = Some(session_tracer); self.session_tracer = Some(session_tracer);
@ -248,6 +201,12 @@ impl AnthropicClient {
self self
} }
#[must_use]
pub fn with_incremental_body(mut self) -> Self {
self.incremental_body = Arc::new(std::sync::Mutex::new(Some(IncrementalBody::new())));
self
}
#[must_use] #[must_use]
pub fn with_prompt_cache(mut self, prompt_cache: PromptCache) -> Self { pub fn with_prompt_cache(mut self, prompt_cache: PromptCache) -> Self {
self.prompt_cache = Some(prompt_cache); self.prompt_cache = Some(prompt_cache);
@ -354,9 +313,28 @@ impl AnthropicClient {
request: &MessageRequest, request: &MessageRequest,
) -> Result<MessageStream, ApiError> { ) -> Result<MessageStream, ApiError> {
self.preflight_message_request(request).await?; self.preflight_message_request(request).await?;
let response = self // Structural update: only flip `stream` flag; all Arc fields
.send_with_retry(&request.clone().with_streaming()) // (messages, system, cached_message_values) are O(1) clones.
.await?; let streaming_request = MessageRequest {
stream: true,
model: request.model.clone(),
max_tokens: request.max_tokens,
messages: Arc::clone(&request.messages),
system: request.system.clone(),
tools: request.tools.clone(),
tool_choice: request.tool_choice.clone(),
temperature: request.temperature,
top_p: request.top_p,
frequency_penalty: request.frequency_penalty,
presence_penalty: request.presence_penalty,
stop: request.stop.clone(),
reasoning_effort: request.reasoning_effort.clone(),
thinking: request.thinking.clone(),
cached_message_values: Arc::clone(&request.cached_message_values),
skip_tools: request.skip_tools,
tools_in_system_prompt: request.tools_in_system_prompt,
};
let response = self.send_with_retry(&streaming_request).await?;
Ok(MessageStream { Ok(MessageStream {
request_id: request_id_from_headers(response.headers()), request_id: request_id_from_headers(response.headers()),
response, response,
@ -467,13 +445,7 @@ impl AnthropicClient {
break; break;
} }
let delay = if let Some(retry_after) = last_error.as_ref().and_then(|e| e.retry_after()) tokio::time::sleep(self.jittered_backoff_for_attempt(attempts)?).await;
{
retry_after
} else {
self.jittered_backoff_for_attempt(attempts)?
};
tokio::time::sleep(delay).await;
} }
Err(ApiError::RetriesExhausted { Err(ApiError::RetriesExhausted {
@ -487,8 +459,56 @@ impl AnthropicClient {
request: &MessageRequest, request: &MessageRequest,
) -> Result<reqwest::Response, ApiError> { ) -> Result<reqwest::Response, ApiError> {
let request_url = format!("{}/v1/messages", self.base_url.trim_end_matches('/')); let request_url = format!("{}/v1/messages", self.base_url.trim_end_matches('/'));
let request_body = render_standard_messages_body(&self.request_profile, request)?;
let request_builder = self.build_request(&request_url).json(&request_body); let request_builder = self
.build_request(&request_url)
.header("content-type", "application/json");
let has_tool_results = request.messages.iter().any(|m| {
m.content.iter().any(|b| matches!(b, InputContentBlock::ToolResult { .. }))
});
let request_builder = match self
.incremental_body
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.as_mut()
{
Some(cache) => {
cache.update(request);
if has_tool_results {
// When tool_results exist in the cached prefix, add a
// message-level cache_control marker so the server can
// reuse the cached prefix, then inject cache_reference
// on individual tool_result blocks.
// Falls back to the Value path since build_bytes() can't
// inject fields into pre-serialised message bytes.
let mut body = cache.build();
MessageRequest::apply_messages_cache_control(&mut body);
MessageRequest::apply_cache_reference(&mut body);
if let Some(object) = body.as_object_mut() {
for (key, value) in &self.request_profile.extra_body {
object.insert(key.clone(), value.clone());
}
}
request_builder.body(serde_json::to_vec(&body)?)
} else {
// Zero-alloc path: no tool_results, no cache_reference needed.
request_builder.body(cache.build_bytes())
}
}
None => {
let mut body = request.render_anthropic_body()?;
if let Some(object) = body.as_object_mut() {
for (key, value) in &self.request_profile.extra_body {
object.insert(key.clone(), value.clone());
}
}
strip_unsupported_beta_body_fields(&mut body);
request_builder.body(serde_json::to_vec(&body)?)
}
};
request_builder.send().await.map_err(ApiError::from) request_builder.send().await.map_err(ApiError::from)
} }
@ -505,18 +525,27 @@ impl AnthropicClient {
} }
async fn preflight_message_request(&self, request: &MessageRequest) -> Result<(), ApiError> { async fn preflight_message_request(&self, request: &MessageRequest) -> Result<(), ApiError> {
// Always run the local byte-estimate guard first. This catches // Run the local byte-estimate guard for non-local servers.
// oversized requests even if the remote count_tokens endpoint is // Local inference endpoints (Ollama, LM Studio, mock services etc.)
// unreachable, misconfigured, or unimplemented (e.g., third-party // are exempt from the heuristic context-window check because they
// Anthropic-compatible gateways). If byte estimation already flags // typically have different limits or none at all, and the heuristic
// the request as oversized, reject immediately without a network // can overcount tool definitions that appear both in `system` and
// round trip. // the tools array.
super::preflight_message_request(request)?; if !is_local_inference() {
super::preflight_message_request(request)?;
}
let Some(limit) = model_token_limit(&request.model) else { let Some(limit) = model_token_limit(&request.model) else {
return Ok(()); return Ok(());
}; };
// Local inference endpoints (loopback mocks, Ollama, LM Studio) do not
// implement `/v1/messages/count_tokens`. Skip the second round-trip
// entirely under local inference to avoid an unsupported call.
if is_local_inference() {
return Ok(());
}
// Best-effort refinement using the Anthropic count_tokens endpoint. // Best-effort refinement using the Anthropic count_tokens endpoint.
// On any failure (network, parse, auth), fall back to the local // On any failure (network, parse, auth), fall back to the local
// byte-estimate result which already passed above. // byte-estimate result which already passed above.
@ -547,7 +576,8 @@ impl AnthropicClient {
"{}/v1/messages/count_tokens", "{}/v1/messages/count_tokens",
self.base_url.trim_end_matches('/') self.base_url.trim_end_matches('/')
); );
let request_body = render_standard_messages_body(&self.request_profile, request)?; let mut request_body = self.request_profile.render_json_body(request)?;
strip_unsupported_beta_body_fields(&mut request_body);
let response = self let response = self
.build_request(&request_url) .build_request(&request_url)
.json(&request_body) .json(&request_body)
@ -617,9 +647,8 @@ fn jitter_for_base(base: Duration) -> Duration {
} }
let raw_nanos = SystemTime::now() let raw_nanos = SystemTime::now()
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)
.map_or(0, |elapsed| { .map(|elapsed| u64::try_from(elapsed.as_nanos()).unwrap_or(u64::MAX))
u64::try_from(elapsed.as_nanos()).unwrap_or(u64::MAX) .unwrap_or(0);
});
let tick = JITTER_COUNTER.fetch_add(1, Ordering::Relaxed); let tick = JITTER_COUNTER.fetch_add(1, Ordering::Relaxed);
// splitmix64 finalizer — mixes the low bits so large bases still see // splitmix64 finalizer — mixes the low bits so large bases still see
// jitter across their full range instead of being clamped to subsec nanos. // jitter across their full range instead of being clamped to subsec nanos.
@ -636,19 +665,10 @@ fn jitter_for_base(base: Duration) -> Duration {
impl AuthSource { impl AuthSource {
pub fn from_env_or_saved() -> Result<Self, ApiError> { pub fn from_env_or_saved() -> Result<Self, ApiError> {
if let Some(api_key) = read_env_non_empty("ANTHROPIC_API_KEY")? { match read_env_non_empty("ANTHROPIC_API_KEY")? {
return match read_env_non_empty("ANTHROPIC_AUTH_TOKEN")? { Some(api_key) => Ok(Self::ApiKey(api_key)),
Some(bearer_token) => Ok(Self::ApiKeyAndBearer { None => Err(anthropic_missing_credentials()),
api_key,
bearer_token,
}),
None => Ok(Self::ApiKey(api_key)),
};
} }
if let Some(bearer_token) = read_env_non_empty("ANTHROPIC_AUTH_TOKEN")? {
return Ok(Self::BearerToken(bearer_token));
}
Err(anthropic_missing_credentials())
} }
} }
@ -667,28 +687,17 @@ pub fn resolve_saved_oauth_token(config: &OAuthConfig) -> Result<Option<OAuthTok
} }
pub fn has_auth_from_env_or_saved() -> Result<bool, ApiError> { pub fn has_auth_from_env_or_saved() -> Result<bool, ApiError> {
Ok(read_env_non_empty("ANTHROPIC_API_KEY")?.is_some() Ok(read_env_non_empty("ANTHROPIC_API_KEY")?.is_some())
|| read_env_non_empty("ANTHROPIC_AUTH_TOKEN")?.is_some())
} }
pub fn resolve_startup_auth_source<F>(load_oauth_config: F) -> Result<AuthSource, ApiError> pub fn resolve_startup_auth_source<F>(_load_oauth_config: F) -> Result<AuthSource, ApiError>
where where
F: FnOnce() -> Result<Option<OAuthConfig>, ApiError>, F: FnOnce() -> Result<Option<OAuthConfig>, ApiError>,
{ {
let _ = load_oauth_config; match read_env_non_empty("ANTHROPIC_API_KEY")? {
if let Some(api_key) = read_env_non_empty("ANTHROPIC_API_KEY")? { Some(api_key) => Ok(AuthSource::ApiKey(api_key)),
return match read_env_non_empty("ANTHROPIC_AUTH_TOKEN")? { None => Err(anthropic_missing_credentials()),
Some(bearer_token) => Ok(AuthSource::ApiKeyAndBearer {
api_key,
bearer_token,
}),
None => Ok(AuthSource::ApiKey(api_key)),
};
} }
if let Some(bearer_token) = read_env_non_empty("ANTHROPIC_AUTH_TOKEN")? {
return Ok(AuthSource::BearerToken(bearer_token));
}
Err(anthropic_missing_credentials())
} }
fn resolve_saved_oauth_token_set( fn resolve_saved_oauth_token_set(
@ -767,21 +776,17 @@ fn read_env_non_empty(key: &str) -> Result<Option<String>, ApiError> {
fn read_api_key() -> Result<String, ApiError> { fn read_api_key() -> Result<String, ApiError> {
let auth = AuthSource::from_env_or_saved()?; let auth = AuthSource::from_env_or_saved()?;
auth.api_key() auth.api_key()
.or_else(|| auth.bearer_token())
.map(ToOwned::to_owned) .map(ToOwned::to_owned)
.ok_or_else(anthropic_missing_credentials) .ok_or_else(anthropic_missing_credentials)
} }
#[cfg(test)]
fn read_auth_token() -> Option<String> {
read_env_non_empty("ANTHROPIC_AUTH_TOKEN")
.ok()
.and_then(std::convert::identity)
}
#[must_use] #[must_use]
pub fn read_base_url() -> String { pub fn read_base_url() -> String {
std::env::var("ANTHROPIC_BASE_URL").unwrap_or_else(|_| DEFAULT_BASE_URL.to_string()) std::env::var("ANTHROPIC_BASE_URL")
.ok()
.filter(|v| !v.is_empty())
.or_else(|| super::dotenv_value("ANTHROPIC_BASE_URL"))
.unwrap_or_else(|| DEFAULT_BASE_URL.to_string())
} }
fn request_id_from_headers(headers: &reqwest::header::HeaderMap) -> Option<String> { fn request_id_from_headers(headers: &reqwest::header::HeaderMap) -> Option<String> {
@ -862,17 +867,19 @@ impl MessageStream {
StreamEvent::MessageDelta(MessageDeltaEvent { usage, .. }) => { StreamEvent::MessageDelta(MessageDeltaEvent { usage, .. }) => {
self.latest_usage = Some(usage.clone()); self.latest_usage = Some(usage.clone());
} }
StreamEvent::MessageStop(_) if !self.usage_recorded => { StreamEvent::MessageStop(_) => {
if let (Some(prompt_cache), Some(usage)) = if !self.usage_recorded {
(&self.prompt_cache, self.latest_usage.as_ref()) if let (Some(prompt_cache), Some(usage)) =
{ (&self.prompt_cache, self.latest_usage.as_ref())
let record = prompt_cache.record_usage(&self.request, usage); {
*self let record = prompt_cache.record_usage(&self.request, usage);
.last_prompt_cache_record *self
.lock() .last_prompt_cache_record
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(record); .lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(record);
}
self.usage_recorded = true;
} }
self.usage_recorded = true;
} }
_ => {} _ => {}
} }
@ -885,12 +892,23 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
return Ok(response); return Ok(response);
} }
let headers = response.headers().clone(); let request_id = request_id_from_headers(response.headers());
let request_id = request_id_from_headers(&headers);
let body = response.text().await.unwrap_or_else(|_| String::new()); let body = response.text().await.unwrap_or_else(|_| String::new());
let parsed_error = serde_json::from_str::<AnthropicErrorEnvelope>(&body).ok(); let parsed_error = serde_json::from_str::<AnthropicErrorEnvelope>(&body)
.ok()
// Fallback: also try flat format {"code":...,"message":...,"type":...} used by
// some API gateways/proxies when Anthropic returns a non-standard error body.
.or_else(|| {
serde_json::from_str::<FlatErrorBody>(&body)
.ok()
.map(|flat| AnthropicErrorEnvelope {
error: AnthropicErrorBody {
error_type: flat.type_,
message: flat.message,
},
})
});
let retryable = is_retryable_status(status); let retryable = is_retryable_status(status);
let retry_after = parse_retry_after(&headers, status);
Err(ApiError::Api { Err(ApiError::Api {
status, status,
@ -904,149 +922,19 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
body, body,
retryable, retryable,
suggested_action: None, suggested_action: None,
retry_after,
}) })
} }
fn parse_retry_after(
headers: &reqwest::header::HeaderMap,
status: reqwest::StatusCode,
) -> Option<std::time::Duration> {
if status != reqwest::StatusCode::TOO_MANY_REQUESTS {
return None;
}
headers
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok())
.map(std::time::Duration::from_secs)
}
const fn is_retryable_status(status: reqwest::StatusCode) -> bool { const fn is_retryable_status(status: reqwest::StatusCode) -> bool {
matches!(status.as_u16(), 408 | 409 | 429 | 500 | 502 | 503 | 504) matches!(status.as_u16(), 408 | 409 | 429 | 500 | 502 | 503 | 504)
} }
/// Some providers return HTTP 400 with an unparseable body when a gateway /// `enrich_bearer_auth_error` is retained for the call site in the auth flow,
/// or proxy flakes (e.g. "HTTP 400 from backend (no parseable body)"). /// but the `ANTHROPIC_AUTH_TOKEN` Bearer path was removed: `AuthSource` can now
/// These are transient network blips, not actual bad requests, and should /// only be `ApiKey`/`None`, so `bearer_token()` is always `None` and this is a
/// be retried. We detect them by checking the body for known gateway error /// straight pass-through.
/// phrases. fn enrich_bearer_auth_error(error: ApiError, _auth: &AuthSource) -> ApiError {
fn is_retryable_400(status: reqwest::StatusCode, body: &str) -> bool { error
if status != reqwest::StatusCode::BAD_REQUEST {
return false;
}
let lowered = body.to_ascii_lowercase();
lowered.contains("no parseable body")
|| lowered.contains("connection reset")
|| lowered.contains("broken pipe")
|| lowered.contains("empty reply from server")
}
/// Anthropic API keys (`sk-ant-*`) are accepted over the `x-api-key` header
/// and rejected with HTTP 401 "Invalid bearer token" when sent as a Bearer
/// token via `ANTHROPIC_AUTH_TOKEN`. This happens often enough in the wild
/// (users copy-paste an `sk-ant-...` key into `ANTHROPIC_AUTH_TOKEN` because
/// the env var name sounds auth-related) that a bare 401 error is useless.
/// When we detect this exact shape, append a hint to the error message that
/// points the user at the one-line fix.
const SK_ANT_BEARER_HINT: &str = "sk-ant-* keys go in ANTHROPIC_API_KEY (x-api-key header), not ANTHROPIC_AUTH_TOKEN (Bearer header). Move your key to ANTHROPIC_API_KEY.";
fn enrich_bearer_auth_error(error: ApiError, auth: &AuthSource) -> ApiError {
let ApiError::Api {
status,
error_type,
message,
request_id,
body,
retryable,
suggested_action,
retry_after,
..
} = error
else {
return error;
};
if status.as_u16() != 401 {
return ApiError::Api {
status,
error_type,
message,
request_id,
body,
retryable,
suggested_action,
retry_after,
};
}
let Some(bearer_token) = auth.bearer_token() else {
return ApiError::Api {
status,
error_type,
message,
request_id,
body,
retryable,
suggested_action,
retry_after,
};
};
if !bearer_token.starts_with("sk-ant-") {
return ApiError::Api {
status,
error_type,
message,
request_id,
body,
retryable,
suggested_action,
retry_after,
};
}
// Only append the hint when the AuthSource is pure BearerToken. If both
// api_key and bearer_token are present (`ApiKeyAndBearer`), the x-api-key
// header is already being sent alongside the Bearer header and the 401
// is coming from a different cause — adding the hint would be misleading.
if auth.api_key().is_some() {
return ApiError::Api {
status,
error_type,
message,
request_id,
body,
retryable,
suggested_action,
retry_after,
};
}
let enriched_message = match message {
Some(existing) => Some(format!("{existing} — hint: {SK_ANT_BEARER_HINT}")),
None => Some(format!("hint: {SK_ANT_BEARER_HINT}")),
};
ApiError::Api {
status,
error_type,
message: enriched_message,
request_id,
body,
retryable,
suggested_action,
retry_after,
}
}
fn anthropic_wire_model(model: &str) -> &str {
model.strip_prefix("anthropic/").unwrap_or(model)
}
fn render_standard_messages_body(
request_profile: &AnthropicRequestProfile,
request: &MessageRequest,
) -> Result<Value, serde_json::Error> {
let mut wire_request = request.clone();
wire_request.model = anthropic_wire_model(&request.model).to_string();
let mut body = request_profile.render_json_body(&wire_request)?;
strip_unsupported_beta_body_fields(&mut body);
Ok(body)
} }
/// Remove beta-only body fields that the standard `/v1/messages` and /// Remove beta-only body fields that the standard `/v1/messages` and
@ -1080,12 +968,22 @@ struct AnthropicErrorBody {
message: String, message: String,
} }
/// Flat error format: `{"code":500,"message":"...","type":"server_error"}`.
/// Used by some API gateways/proxies as a fallback when the standard
/// `{"error":{"type":...,"message":...}}` envelope is not available.
#[derive(Debug, Deserialize)]
struct FlatErrorBody {
#[serde(rename = "type")]
type_: String,
message: String,
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ALT_REQUEST_ID_HEADER, REQUEST_ID_HEADER}; use super::{ALT_REQUEST_ID_HEADER, REQUEST_ID_HEADER};
use std::io::{Read, Write}; use std::io::{Read, Write};
use std::net::TcpListener; use std::net::TcpListener;
use std::sync::{Mutex, OnceLock}; use std::sync::{Arc, Mutex, OnceLock};
use std::thread; use std::thread;
use std::time::{Duration, SystemTime, UNIX_EPOCH}; use std::time::{Duration, SystemTime, UNIX_EPOCH};
@ -1156,19 +1054,26 @@ mod tests {
#[test] #[test]
fn read_api_key_requires_presence() { fn read_api_key_requires_presence() {
let _guard = env_lock(); let _guard = env_lock();
let config_home = temp_config_home();
std::fs::create_dir_all(&config_home).expect("create config home");
std::env::set_var("CLAW_CONFIG_HOME", &config_home);
std::env::remove_var("ANTHROPIC_AUTH_TOKEN"); std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
std::env::remove_var("ANTHROPIC_API_KEY"); std::env::remove_var("ANTHROPIC_API_KEY");
std::env::remove_var("CLAW_CONFIG_HOME");
let error = super::read_api_key().expect_err("missing key should error"); let error = super::read_api_key().expect_err("missing key should error");
assert!(matches!( assert!(matches!(
error, error,
crate::error::ApiError::MissingCredentials { .. } crate::error::ApiError::MissingCredentials { .. }
)); ));
std::env::remove_var("CLAW_CONFIG_HOME");
cleanup_temp_config_home(&config_home);
} }
#[test] #[test]
fn read_api_key_requires_non_empty_value() { fn read_api_key_requires_non_empty_value() {
let _guard = env_lock(); let _guard = env_lock();
let config_home = temp_config_home();
std::fs::create_dir_all(&config_home).expect("create config home");
std::env::set_var("CLAW_CONFIG_HOME", &config_home);
std::env::set_var("ANTHROPIC_AUTH_TOKEN", ""); std::env::set_var("ANTHROPIC_AUTH_TOKEN", "");
std::env::remove_var("ANTHROPIC_API_KEY"); std::env::remove_var("ANTHROPIC_API_KEY");
let error = super::read_api_key().expect_err("empty key should error"); let error = super::read_api_key().expect_err("empty key should error");
@ -1177,6 +1082,8 @@ mod tests {
crate::error::ApiError::MissingCredentials { .. } crate::error::ApiError::MissingCredentials { .. }
)); ));
std::env::remove_var("ANTHROPIC_AUTH_TOKEN"); std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
std::env::remove_var("CLAW_CONFIG_HOME");
cleanup_temp_config_home(&config_home);
} }
#[test] #[test]
@ -1193,34 +1100,28 @@ mod tests {
} }
#[test] #[test]
fn read_auth_token_reads_auth_token_env() { fn oauth_token_maps_to_no_auth_source() {
let _guard = env_lock(); // OAuth is no longer a source of API auth; the token set maps to None.
std::env::set_var("ANTHROPIC_AUTH_TOKEN", "auth-token");
assert_eq!(super::read_auth_token().as_deref(), Some("auth-token"));
std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
}
#[test]
fn oauth_token_maps_to_bearer_auth_source() {
let auth = AuthSource::from(OAuthTokenSet { let auth = AuthSource::from(OAuthTokenSet {
access_token: "access-token".to_string(), access_token: "access-token".to_string(),
refresh_token: Some("refresh".to_string()), refresh_token: Some("refresh".to_string()),
expires_at: Some(123), expires_at: Some(123),
scopes: vec!["scope:a".to_string()], scopes: vec!["scope:a".to_string()],
}); });
assert_eq!(auth.bearer_token(), Some("access-token"));
assert_eq!(auth.api_key(), None); assert_eq!(auth.api_key(), None);
assert_eq!(auth, AuthSource::None);
} }
#[test] #[test]
fn auth_source_from_env_combines_api_key_and_bearer_token() { fn auth_source_from_env_uses_only_api_key() {
// given
let _guard = env_lock(); let _guard = env_lock();
std::env::set_var("ANTHROPIC_AUTH_TOKEN", "auth-token");
std::env::set_var("ANTHROPIC_API_KEY", "legacy-key"); std::env::set_var("ANTHROPIC_API_KEY", "legacy-key");
// when
let auth = AuthSource::from_env().expect("env auth"); let auth = AuthSource::from_env().expect("env auth");
// then: only the API key path exists now.
assert_eq!(auth.api_key(), Some("legacy-key")); assert_eq!(auth.api_key(), Some("legacy-key"));
assert_eq!(auth.bearer_token(), Some("auth-token")); assert_eq!(auth, AuthSource::ApiKey("legacy-key".to_string()));
std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
std::env::remove_var("ANTHROPIC_API_KEY"); std::env::remove_var("ANTHROPIC_API_KEY");
} }
@ -1357,7 +1258,7 @@ mod tests {
let request = MessageRequest { let request = MessageRequest {
model: "claude-opus-4-6".to_string(), model: "claude-opus-4-6".to_string(),
max_tokens: 64, max_tokens: 64,
messages: vec![], messages: Arc::new(vec![]),
system: None, system: None,
tools: None, tools: None,
tool_choice: None, tool_choice: None,
@ -1485,27 +1386,6 @@ mod tests {
); );
} }
#[test]
fn auth_source_applies_headers() {
let auth = AuthSource::ApiKeyAndBearer {
api_key: "test-key".to_string(),
bearer_token: "proxy-token".to_string(),
};
let request = auth
.apply(reqwest::Client::new().post("https://example.test"))
.build()
.expect("request build");
let headers = request.headers();
assert_eq!(
headers.get("x-api-key").and_then(|v| v.to_str().ok()),
Some("test-key")
);
assert_eq!(
headers.get("authorization").and_then(|v| v.to_str().ok()),
Some("Bearer proxy-token")
);
}
#[test] #[test]
fn strip_unsupported_beta_body_fields_removes_betas_array() { fn strip_unsupported_beta_body_fields_removes_betas_array() {
let mut body = serde_json::json!({ let mut body = serde_json::json!({
@ -1594,7 +1474,7 @@ mod tests {
let request = MessageRequest { let request = MessageRequest {
model: "claude-sonnet-4-6".to_string(), model: "claude-sonnet-4-6".to_string(),
max_tokens: 64, max_tokens: 64,
messages: vec![], messages: Arc::new(vec![]),
system: None, system: None,
tools: None, tools: None,
tool_choice: None, tool_choice: None,
@ -1623,192 +1503,28 @@ mod tests {
} }
#[test] #[test]
fn standard_messages_body_strips_anthropic_routing_prefix() { fn enrich_bearer_auth_error_is_noop_without_bearer_token() {
let client = AnthropicClient::new("test-key"); // given: with the removed ANTHROPIC_AUTH_TOKEN Bearer path, AuthSource
let request = MessageRequest { // can only be ApiKey/None, so enrich_bearer_auth_error is a pass-through.
model: "anthropic/claude-opus-4-6".to_string(),
max_tokens: 64,
messages: vec![],
system: None,
tools: None,
tool_choice: None,
stream: false,
..Default::default()
};
let rendered = super::render_standard_messages_body(client.request_profile(), &request)
.expect("body should render");
assert_eq!(rendered["model"], serde_json::json!("claude-opus-4-6"));
assert!(rendered.get("betas").is_none());
}
#[test]
fn enrich_bearer_auth_error_appends_sk_ant_hint_on_401_with_pure_bearer_token() {
// given
let auth = AuthSource::BearerToken("sk-ant-api03-deadbeef".to_string());
let error = crate::error::ApiError::Api {
status: reqwest::StatusCode::UNAUTHORIZED,
error_type: Some("authentication_error".to_string()),
message: Some("Invalid bearer token".to_string()),
request_id: Some("req_varleg_001".to_string()),
body: String::new(),
retryable: false,
suggested_action: None,
retry_after: None,
};
// when
let enriched = super::enrich_bearer_auth_error(error, &auth);
// then
let rendered = enriched.to_string();
assert!(
rendered.contains("Invalid bearer token"),
"existing provider message should be preserved: {rendered}"
);
assert!(
rendered.contains(
"sk-ant-* keys go in ANTHROPIC_API_KEY (x-api-key header), not ANTHROPIC_AUTH_TOKEN (Bearer header). Move your key to ANTHROPIC_API_KEY."
),
"rendered error should include the sk-ant-* hint: {rendered}"
);
assert!(
rendered.contains("[trace req_varleg_001]"),
"request id should still flow through the enriched error: {rendered}"
);
match enriched {
crate::error::ApiError::Api { status, .. } => {
assert_eq!(status, reqwest::StatusCode::UNAUTHORIZED);
}
other => panic!("expected Api variant, got {other:?}"),
}
}
#[test]
fn enrich_bearer_auth_error_leaves_non_401_errors_unchanged() {
// given
let auth = AuthSource::BearerToken("sk-ant-api03-deadbeef".to_string());
let error = crate::error::ApiError::Api {
status: reqwest::StatusCode::INTERNAL_SERVER_ERROR,
error_type: Some("api_error".to_string()),
message: Some("internal server error".to_string()),
request_id: None,
body: String::new(),
retryable: true,
suggested_action: None,
retry_after: None,
};
// when
let enriched = super::enrich_bearer_auth_error(error, &auth);
// then
let rendered = enriched.to_string();
assert!(
!rendered.contains("sk-ant-*"),
"non-401 errors must not be annotated with the bearer hint: {rendered}"
);
assert!(
rendered.contains("internal server error"),
"original message must be preserved verbatim: {rendered}"
);
}
#[test]
fn enrich_bearer_auth_error_ignores_401_when_bearer_token_is_not_sk_ant() {
// given
let auth = AuthSource::BearerToken("oauth-access-token-opaque".to_string());
let error = crate::error::ApiError::Api {
status: reqwest::StatusCode::UNAUTHORIZED,
error_type: Some("authentication_error".to_string()),
message: Some("Invalid bearer token".to_string()),
request_id: None,
body: String::new(),
retryable: false,
suggested_action: None,
retry_after: None,
};
// when
let enriched = super::enrich_bearer_auth_error(error, &auth);
// then
let rendered = enriched.to_string();
assert!(
!rendered.contains("sk-ant-*"),
"oauth-style bearer tokens must not trigger the sk-ant-* hint: {rendered}"
);
}
#[test]
fn enrich_bearer_auth_error_skips_hint_when_api_key_header_is_also_present() {
// given
let auth = AuthSource::ApiKeyAndBearer {
api_key: "sk-ant-api03-legitimate".to_string(),
bearer_token: "sk-ant-api03-deadbeef".to_string(),
};
let error = crate::error::ApiError::Api {
status: reqwest::StatusCode::UNAUTHORIZED,
error_type: Some("authentication_error".to_string()),
message: Some("Invalid bearer token".to_string()),
request_id: None,
body: String::new(),
retryable: false,
suggested_action: None,
retry_after: None,
};
// when
let enriched = super::enrich_bearer_auth_error(error, &auth);
// then
let rendered = enriched.to_string();
assert!(
!rendered.contains("sk-ant-*"),
"hint should be suppressed when x-api-key header is already being sent: {rendered}"
);
}
#[test]
fn enrich_bearer_auth_error_ignores_401_when_auth_source_has_no_bearer() {
// given
let auth = AuthSource::ApiKey("sk-ant-api03-legitimate".to_string()); let auth = AuthSource::ApiKey("sk-ant-api03-legitimate".to_string());
let error = crate::error::ApiError::Api { let error = crate::error::ApiError::Api {
status: reqwest::StatusCode::UNAUTHORIZED, status: reqwest::StatusCode::UNAUTHORIZED,
error_type: Some("authentication_error".to_string()), error_type: Some("authentication_error".to_string()),
message: Some("Invalid x-api-key".to_string()), message: Some("Invalid x-api-key".to_string()),
request_id: None, request_id: Some("req_varleg_001".to_string()),
body: String::new(), body: String::new(),
retryable: false, retryable: false,
suggested_action: None, suggested_action: None,
retry_after: None,
}; };
// when // when
let enriched = super::enrich_bearer_auth_error(error, &auth); let enriched = super::enrich_bearer_auth_error(error, &auth);
// then // then
let rendered = enriched.to_string();
assert!( assert!(
!rendered.contains("sk-ant-*"), !enriched.to_string().contains("sk-ant-*"),
"bearer hint must not apply when AuthSource is ApiKey-only: {rendered}" "bearer hint must never apply now that the Bearer path is removed: {}",
enriched
); );
} }
#[test]
fn enrich_bearer_auth_error_passes_non_api_errors_through_unchanged() {
// given
let auth = AuthSource::BearerToken("sk-ant-api03-deadbeef".to_string());
let error = crate::error::ApiError::InvalidSseFrame("unterminated event");
// when
let enriched = super::enrich_bearer_auth_error(error, &auth);
// then
assert!(matches!(
enriched,
crate::error::ApiError::InvalidSseFrame(_)
));
}
} }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -122,9 +122,47 @@ pub(crate) fn parse_frame_with_provider(
return Ok(None); return Ok(None);
} }
serde_json::from_str::<StreamEvent>(&payload) match serde_json::from_str::<StreamEvent>(&payload) {
.map(Some) Ok(event) => Ok(Some(event)),
.map_err(|error| ApiError::json_deserialize(provider, model, &payload, error)) Err(error) => {
// Unknown event type (e.g. "server_error", "error") - try to
// extract diagnostic info and return an ApiError, so the caller
// can handle it instead of crashing.
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&payload) {
if let Some(typ) = val.get("type").and_then(|v| v.as_str()) {
if typ == "error" || typ.ends_with("_error") {
let status = val
.get("code")
.and_then(|v| v.as_u64())
.map(|c| reqwest::StatusCode::try_from(c as u16).ok())
.flatten()
.unwrap_or(reqwest::StatusCode::INTERNAL_SERVER_ERROR);
let msg = val
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("unknown streaming error")
.to_string();
return Err(ApiError::Api {
status,
error_type: Some(typ.to_string()),
message: Some(msg),
request_id: None,
body: payload,
retryable: true,
suggested_action: None,
});
}
}
}
// Unrecognisable payload — skip the frame rather than failing
// the entire stream. A future API extension may have introduced
// a new event type we don't understand.
eprintln!(
"[sse] skipping unparseable event from {provider}/{model}: {error}"
);
Ok(None)
}
}
} }
#[cfg(test)] #[cfg(test)]

View File

@ -1,16 +1,26 @@
use std::collections::BTreeMap;
use runtime::{pricing_for_model, TokenUsage, UsageCostEstimate}; use runtime::{pricing_for_model, TokenUsage, UsageCostEstimate};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use std::sync::Arc;
/// Anthropic extended thinking configuration.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ThinkingConfig {
#[serde(rename = "type")]
pub config_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub budget_tokens: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct MessageRequest { pub struct MessageRequest {
pub model: String, pub model: String,
pub max_tokens: u32, pub max_tokens: u32,
pub messages: Vec<InputMessage>, /// Shared message list wrapped in `Arc` so that `MessageRequest::clone()`
/// is O(1) for the (typically large) messages vector.
pub messages: Arc<Vec<InputMessage>>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub system: Option<String>, pub system: Option<Arc<str>>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<Vec<ToolDefinition>>, pub tools: Option<Vec<ToolDefinition>>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
@ -33,14 +43,30 @@ pub struct MessageRequest {
/// Silently ignored by backends that do not support it. /// Silently ignored by backends that do not support it.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub reasoning_effort: Option<String>, pub reasoning_effort: Option<String>,
/// Provider-specific OpenAI-compatible request body parameters. These are /// Anthropic extended thinking configuration. Omitted when `None`.
/// copied into the final JSON payload after core fields are populated so #[serde(skip_serializing_if = "Option::is_none")]
/// users can opt into gateway features such as `web_search_options`, pub thinking: Option<ThinkingConfig>,
/// `parallel_tool_calls`, or custom local-server switches without waiting /// Pre-cached serialised JSON `Value`s for each message, typically produced
/// for first-class typed fields. Core protocol keys are protected and cannot /// by `convert_messages_cached`. The `IncrementalBody` will use these to
/// be overridden through this map. /// skip re-serialisation of unchanged messages.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")] /// Empty when not using the cache.
pub extra_body: BTreeMap<String, Value>, /// Wrapped in `Arc` so that `MessageRequest::clone()` is O(1).
#[serde(skip)]
pub cached_message_values: Arc<Vec<Option<Value>>>,
/// If `true`, omit the `tools` field when serialising the request body.
/// Set on requests 2+ when tool definitions haven't changed, saving ~24KB
/// per turn for Anthropic server-side prompt cache.
/// NOTE: only respected by the Anthropic provider — OpenAI-compat and xAI
/// always send full tool definitions.
#[serde(skip)]
pub skip_tools: bool,
/// If `true`, tool definitions have been embedded in the system prompt
/// text as a deterministic JSON block. The `tools` field should be omitted
/// from the wire format to avoid duplication.
/// Used for local inference (llama.cpp, LM Studio, Ollama) where KV cache
/// prefix stability depends on stable token sequences.
#[serde(skip)]
pub tools_in_system_prompt: bool,
} }
impl MessageRequest { impl MessageRequest {
@ -49,6 +75,185 @@ impl MessageRequest {
self.stream = true; self.stream = true;
self self
} }
/// Render the request body in Anthropic API JSON format.
///
/// Post-processing steps:
/// 1. Strip tools when `skip_tools` is set (tools unchanged since prior
/// request — saves ~24KB per turn via Anthropic server-side cache).
/// 2. Split system prompt at `SYSTEM_PROMPT_DYNAMIC_BOUNDARY` into blocks
/// with `cache_control: ephemeral` on the static portion.
/// 3. Add `cache_control: ephemeral` to the last tool definition.
#[inline]
pub fn render_anthropic_body(&self) -> Result<Value, serde_json::Error> {
let mut body = serde_json::to_value(self)?;
if self.skip_tools {
if let Value::Object(ref mut obj) = body {
obj.remove("tools");
}
} else {
Self::apply_tools_cache_control(&mut body);
}
Self::apply_system_prompt_cache_control(&mut body);
Self::apply_messages_cache_control(&mut body);
Self::apply_cache_reference(&mut body);
Ok(body)
}
/// Post-process the serialised body to add `cache_reference` to tool_result
/// blocks that fall within the cached prefix (before the last message-level
/// `cache_control` marker). This lets the server reuse cached tool results.
pub(crate) fn apply_cache_reference(body: &mut Value) {
let Some(messages) = body
.get_mut("messages")
.and_then(|v| v.as_array_mut())
else {
return;
};
// Find the last message index that has any cache_control marker
let mut last_cc_idx = None;
for (i, msg) in messages.iter().enumerate() {
if let Some(content) = msg.get("content").and_then(|v| v.as_array()) {
if content.iter().any(|b| b.get("cache_control").is_some()) {
last_cc_idx = Some(i);
}
}
}
let Some(end) = last_cc_idx else { return };
// Only messages strictly before the last cache_control marker qualify
for msg in messages[..end].iter_mut() {
if msg.get("role").and_then(|v| v.as_str()) != Some("user") {
continue;
}
let Some(content) = msg.get_mut("content").and_then(|v| v.as_array_mut()) else {
continue;
};
for block in content.iter_mut() {
if block.get("type").and_then(|v| v.as_str()) != Some("tool_result") {
continue;
}
let Some(tuid) = block
.get("tool_use_id")
.and_then(|v| v.as_str())
.map(String::from)
else {
continue;
};
block["cache_reference"] = Value::String(tuid);
}
}
}
/// Add `cache_control: ephemeral` to the **last** message's last suitable
/// content block, creating a cached prefix boundary that allows
/// `apply_cache_reference` to determine which tool_results are in the
/// cached portion. This mirrors claude-code's `addCacheBreakpoints`.
///
/// Skipped when the last block is a `tool_result` (Anthropic does not
/// support `cache_control` on tool_result blocks) or when it already
/// has a `cache_control`.
pub(crate) fn apply_messages_cache_control(body: &mut Value) {
let Some(messages) = body
.get_mut("messages")
.and_then(|v| v.as_array_mut())
else {
return;
};
let Some(last_msg) = messages.last_mut() else {
return;
};
let Some(content) = last_msg
.get_mut("content")
.and_then(|v| v.as_array_mut())
else {
return;
};
let Some(last_block) = content.last_mut() else {
return;
};
// Anthropic does not support cache_control on tool_result blocks
if last_block
.get("type")
.and_then(|v| v.as_str())
== Some("tool_result")
{
return;
}
if last_block.get("cache_control").is_some() {
return;
}
last_block["cache_control"] = serde_json::json!({"type": "ephemeral"});
}
/// Split the flat system prompt string at `SYSTEM_PROMPT_DYNAMIC_BOUNDARY`
/// into Anthropic's block format with `cache_control` on the static part.
///
/// Before: `"system": "static...\n\n__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__\n\ndynamic..."`
/// After: `"system": [{"type":"text","text":"static...","cache_control":{"type":"ephemeral"}},
/// {"type":"text","text":"dynamic..."}]`
pub(crate) fn apply_system_prompt_cache_control(body: &mut Value) {
let Some(system_str) = body
.get("system")
.and_then(|v| v.as_str())
.map(str::to_owned)
else {
return;
};
let boundary = runtime::SYSTEM_PROMPT_DYNAMIC_BOUNDARY;
let Some(split_pos) = system_str.find(boundary) else {
// No boundary marker — wrap entire system as cached
if !system_str.is_empty() {
body["system"] = serde_json::json!([{
"type": "text",
"text": system_str,
"cache_control": { "type": "ephemeral" }
}]);
}
return;
};
let static_part = system_str[..split_pos].trim_end().to_string();
let dynamic_part = system_str[split_pos + boundary.len()..]
.trim_start()
.to_string();
let mut blocks = Vec::new();
if !static_part.is_empty() {
blocks.push(serde_json::json!({
"type": "text",
"text": static_part,
"cache_control": { "type": "ephemeral" }
}));
}
if !dynamic_part.is_empty() {
blocks.push(serde_json::json!({
"type": "text",
"text": dynamic_part,
"cache_control": { "type": "ephemeral" }
}));
}
if !blocks.is_empty() {
body["system"] = Value::Array(blocks);
}
}
/// Add `cache_control: ephemeral` to the last tool definition so Anthropic
/// caches the tool schema across requests within the same turn.
pub(crate) fn apply_tools_cache_control(body: &mut Value) {
let Some(tools) = body
.get_mut("tools")
.and_then(|v| v.as_array_mut())
else {
return;
};
if let Some(last_tool) = tools.last_mut() {
if let Some(obj) = last_tool.as_object_mut() {
obj.insert(
"cache_control".to_string(),
serde_json::json!({ "type": "ephemeral" }),
);
}
}
}
} }
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@ -80,22 +285,34 @@ impl InputMessage {
text: content.into(), text: content.into(),
}], }],
is_error, is_error,
cache_reference: None,
}], }],
} }
} }
} }
/// Nested source block for Anthropic's `{"type":"image","source":{...}}` format.
///
/// Serde serialises this directly into the shape that Anthropic's API expects,
/// eliminating the need for a post-processing pass that walks the entire
/// body tree looking for `Image` blocks to normalise.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ImageSource {
/// Always `"base64"`.
#[serde(rename = "type")]
pub source_type: String,
/// MIME type of the image (e.g. `"image/png"`, `"image/jpeg"`).
pub media_type: String,
/// Base64-encoded image data.
pub data: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")] #[serde(tag = "type", rename_all = "snake_case")]
pub enum InputContentBlock { pub enum InputContentBlock {
Text { Text {
text: String, text: String,
}, },
Thinking {
thinking: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
signature: Option<String>,
},
ToolUse { ToolUse {
id: String, id: String,
name: String, name: String,
@ -106,6 +323,34 @@ pub enum InputContentBlock {
content: Vec<ToolResultContentBlock>, content: Vec<ToolResultContentBlock>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")] #[serde(default, skip_serializing_if = "std::ops::Not::not")]
is_error: bool, is_error: bool,
/// When in the cached prefix, reference the tool_use_id so the
/// server can reuse the cached tool_result instead of re-processing.
#[serde(skip_serializing_if = "Option::is_none")]
cache_reference: Option<String>,
},
Image {
/// Nested `source` block in Anthropic's expected format, produced
/// directly at construction time so no JSON-level post-processing
/// is needed.
#[serde(rename = "source")]
source: ImageSource,
},
Thinking {
/// The reasoning content returned by the model. Must be echoed back
/// verbatim (with `signature`) when the assistant turn is included in
/// a follow-up request under Anthropic extended thinking.
thinking: String,
/// Opaque signature that the Anthropic API uses to authenticate the
/// thinking block. Mandatory for round-tripping thinking blocks.
#[serde(default, skip_serializing_if = "Option::is_none")]
signature: Option<String>,
},
RedactedThinking {
/// The encrypted redacted-thinking payload returned by the provider.
/// Must be echoed back verbatim for the tool-use round-trip; unlike a
/// normal thinking block it carries no signature, so the data itself
/// is the authentication token.
data: Value,
}, },
} }
@ -124,7 +369,39 @@ pub struct ToolDefinition {
pub input_schema: Value, pub input_schema: Value,
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] /// Serialize tool definitions to a deterministic JSON text block for embedding
/// in the system prompt. Same input → identical byte sequence.
/// This ensures KV cache prefix stability for local inference servers.
///
/// Output format:
/// ```text
/// # Tools
/// [{"name":"...","description":"...","parameters":{...}},...]
/// ```
#[must_use]
pub fn render_tools_block(tools: &[ToolDefinition]) -> String {
use std::fmt::Write;
let mut block = String::from("# Tools\n[");
for (i, tool) in tools.iter().enumerate() {
if i > 0 {
block.push(',');
}
block.push('{');
write!(&mut block, "\"name\":{}", serde_json::to_string(&tool.name).unwrap_or_default()).ok();
block.push(',');
if let Some(ref desc) = tool.description {
write!(&mut block, "\"description\":{}", serde_json::to_string(desc).unwrap_or_default()).ok();
block.push(',');
}
block.push_str("\"parameters\":");
block.push_str(&serde_json::to_string(&tool.input_schema).unwrap_or_default());
block.push('}');
}
block.push(']');
block
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")] #[serde(tag = "type", rename_all = "snake_case")]
pub enum ToolChoice { pub enum ToolChoice {
Auto, Auto,
@ -166,6 +443,7 @@ pub enum OutputContentBlock {
ToolUse { ToolUse {
id: String, id: String,
name: String, name: String,
#[serde(default = "serde_json::Value::default")]
input: Value, input: Value,
}, },
Thinking { Thinking {
@ -177,6 +455,12 @@ pub enum OutputContentBlock {
RedactedThinking { RedactedThinking {
data: Value, data: Value,
}, },
// Added image output block
Image {
data: String,
mime_type: String,
filename: Option<String>,
},
} }
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
@ -283,9 +567,8 @@ pub enum StreamEvent {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use runtime::format_usd; use runtime::format_usd;
use serde_json::json;
use super::{InputContentBlock, MessageResponse, Usage}; use super::{MessageResponse, Usage};
#[test] #[test]
fn usage_total_tokens_includes_cache_tokens() { fn usage_total_tokens_includes_cache_tokens() {
@ -325,31 +608,157 @@ mod tests {
} }
#[test] #[test]
fn input_content_block_thinking_serializes_with_snake_case_type() { fn apply_cache_reference_injects_tool_use_id_on_cached_prefix_tool_results() {
// given let mut body = serde_json::json!({
let block = InputContentBlock::Thinking { "model": "claude-sonnet-4-20250514",
thinking: "pondering".to_string(), "max_tokens": 100,
signature: Some("sig_123".to_string()), "system": "Be helpful.",
}; "messages": [
{"role": "user", "content": [
{"type": "tool_result", "tool_use_id": "tu_abc", "content": "result"}
]},
{"role": "assistant", "content": [
{"type": "tool_use", "id": "tu_abc", "name": "test", "input": {}}
]},
{"role": "user", "content": [
{"type": "text", "text": "continue", "cache_control": {"type": "ephemeral"}}
]}
]
});
super::MessageRequest::apply_cache_reference(&mut body);
// when let messages = body["messages"].as_array().unwrap();
let serialized = serde_json::to_value(&block).unwrap(); let blocks = messages[0]["content"].as_array().unwrap();
let deserialized: InputContentBlock = serde_json::from_value(json!({ assert_eq!(blocks[0]["cache_reference"], "tu_abc");
"type": "thinking", for i in 1..messages.len() {
"thinking": "pondering", if let Some(content) = messages[i]["content"].as_array() {
"signature": "sig_123" for block in content {
})) assert!(
.unwrap(); block.get("cache_reference").is_none(),
"message {i} should not have cache_reference"
);
}
}
}
}
// then #[test]
fn apply_cache_reference_skips_when_no_cache_control_marker() {
let mut body = serde_json::json!({
"messages": [
{"role": "user", "content": [
{"type": "tool_result", "tool_use_id": "tu_xyz", "content": "ok"}
]}
]
});
super::MessageRequest::apply_cache_reference(&mut body);
let blocks = body["messages"][0]["content"].as_array().unwrap();
assert!(blocks[0].get("cache_reference").is_none());
}
#[test]
fn apply_cache_reference_skips_non_user_messages_in_prefix() {
let mut body = serde_json::json!({
"messages": [
{"role": "assistant", "content": [
{"type": "tool_result", "tool_use_id": "tu_xyz", "content": "ok"}
]},
{"role": "user", "content": [
{"type": "text", "text": "go", "cache_control": {"type": "ephemeral"}}
]}
]
});
super::MessageRequest::apply_cache_reference(&mut body);
// assistant tool_result should NOT get cache_reference
let blocks = body["messages"][0]["content"].as_array().unwrap();
assert!(blocks[0].get("cache_reference").is_none());
}
#[test]
fn apply_messages_cache_control_adds_to_last_text_block() {
let mut body = serde_json::json!({
"messages": [
{"role": "user", "content": [
{"type": "text", "text": "hello"}
]},
{"role": "assistant", "content": [
{"type": "text", "text": "hi"}
]},
{"role": "user", "content": [
{"type": "text", "text": "continue"}
]}
]
});
super::MessageRequest::apply_messages_cache_control(&mut body);
let last = body["messages"][2]["content"].as_array().unwrap();
assert_eq!( assert_eq!(
serialized, last[0]["cache_control"],
json!({ serde_json::json!({"type": "ephemeral"})
"type": "thinking",
"thinking": "pondering",
"signature": "sig_123"
})
); );
assert_eq!(deserialized, block); }
#[test]
fn apply_messages_cache_control_skips_tool_result_last_block() {
let mut body = serde_json::json!({
"messages": [
{"role": "user", "content": [
{"type": "tool_result", "tool_use_id": "tu_1", "content": "result"}
]}
]
});
super::MessageRequest::apply_messages_cache_control(&mut body);
let blocks = body["messages"][0]["content"].as_array().unwrap();
assert!(blocks[0].get("cache_control").is_none());
}
#[test]
fn apply_messages_cache_control_skips_existing_cache_control() {
let mut body = serde_json::json!({
"messages": [
{"role": "user", "content": [
{"type": "text", "text": "done", "cache_control": {"type": "ephemeral"}}
]}
]
});
super::MessageRequest::apply_messages_cache_control(&mut body);
let blocks = body["messages"][0]["content"].as_array().unwrap();
assert_eq!(
blocks[0]["cache_control"],
serde_json::json!({"type": "ephemeral"})
);
}
#[test]
fn apply_messages_cache_control_empty_messages_does_not_panic() {
let mut body = serde_json::json!({"messages": []});
super::MessageRequest::apply_messages_cache_control(&mut body);
// no panic = pass
}
#[test]
fn apply_messages_cache_control_no_messages_key_does_not_panic() {
let mut body = serde_json::json!({"model": "test"});
super::MessageRequest::apply_messages_cache_control(&mut body);
// no panic = pass
}
#[test]
fn apply_messages_cache_control_content_not_array_does_not_panic() {
let mut body = serde_json::json!({
"messages": [{"role": "user", "content": "string content"}]
});
super::MessageRequest::apply_messages_cache_control(&mut body);
// no panic = pass
}
#[test]
fn redacted_thinking_input_block_serializes_with_data() {
use super::InputContentBlock;
let block = InputContentBlock::RedactedThinking {
data: serde_json::json!("ciphertext_blob_abc"),
};
let value = serde_json::to_value(&block).expect("block should serialize");
assert_eq!(value["type"], "redacted_thinking");
assert_eq!(value["data"], "ciphertext_blob_abc");
} }
} }

View File

@ -45,7 +45,6 @@ async fn send_message_posts_json_and_parses_response() {
.await; .await;
let client = ApiClient::new("test-key") let client = ApiClient::new("test-key")
.with_auth_token(Some("proxy-token".to_string()))
.with_base_url(server.base_url()); .with_base_url(server.base_url());
let response = client let response = client
.send_message(&sample_request(false)) .send_message(&sample_request(false))
@ -72,21 +71,19 @@ async fn send_message_posts_json_and_parses_response() {
request.headers.get("x-api-key").map(String::as_str), request.headers.get("x-api-key").map(String::as_str),
Some("test-key") Some("test-key")
); );
assert_eq!( assert!(request.headers.get("authorization").is_none());
request.headers.get("authorization").map(String::as_str),
Some("Bearer proxy-token")
);
assert_eq!( assert_eq!(
request.headers.get("anthropic-version").map(String::as_str), request.headers.get("anthropic-version").map(String::as_str),
Some("2023-06-01") Some("2023-06-01")
); );
let expected_user_agent = format!("claude-code/{}", env!("CARGO_PKG_VERSION"));
assert_eq!( assert_eq!(
request.headers.get("user-agent").map(String::as_str), request.headers.get("user-agent").map(String::as_str),
Some("claude-code/0.1.3") Some(expected_user_agent.as_str())
); );
assert_eq!( assert_eq!(
request.headers.get("anthropic-beta").map(String::as_str), request.headers.get("anthropic-beta").map(String::as_str),
Some("claude-code-20250219,prompt-caching-scope-2026-01-05") Some("claude-code-20250219,prompt-caching-scope-2026-01-05,effort-2025-11-24")
); );
let body: serde_json::Value = let body: serde_json::Value =
serde_json::from_str(&request.body).expect("request body should be json"); serde_json::from_str(&request.body).expect("request body should be json");
@ -103,58 +100,6 @@ async fn send_message_posts_json_and_parses_response() {
); );
} }
#[tokio::test]
async fn send_message_strips_anthropic_routing_prefix_on_wire() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let server = spawn_server(
state.clone(),
vec![
http_response("200 OK", "application/json", "{\"input_tokens\":1}"),
http_response(
"200 OK",
"application/json",
concat!(
"{",
"\"id\":\"msg_prefixed\",",
"\"type\":\"message\",",
"\"role\":\"assistant\",",
"\"content\":[{\"type\":\"text\",\"text\":\"ok\"}],",
"\"model\":\"claude-opus-4-6\",",
"\"stop_reason\":\"end_turn\",",
"\"stop_sequence\":null,",
"\"usage\":{\"input_tokens\":1,\"output_tokens\":1}",
"}"
),
),
],
)
.await;
let client = AnthropicClient::new("test-key").with_base_url(server.base_url());
client
.send_message(&MessageRequest {
model: "anthropic/claude-opus-4-6".to_string(),
..sample_request(false)
})
.await
.expect("request should succeed");
let captured = state.lock().await;
assert_eq!(
captured.len(),
2,
"count_tokens and messages requests should be captured"
);
let count_tokens_body: serde_json::Value =
serde_json::from_str(&captured[0].body).expect("count_tokens body should be json");
let messages_body: serde_json::Value =
serde_json::from_str(&captured[1].body).expect("request body should be json");
assert_eq!(captured[0].path, "/v1/messages/count_tokens");
assert_eq!(captured[1].path, "/v1/messages");
assert_eq!(count_tokens_body["model"], json!("claude-opus-4-6"));
assert_eq!(messages_body["model"], json!("claude-opus-4-6"));
}
#[tokio::test] #[tokio::test]
async fn send_message_blocks_oversized_requests_before_the_http_call() { async fn send_message_blocks_oversized_requests_before_the_http_call() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new())); let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
@ -169,13 +114,13 @@ async fn send_message_blocks_oversized_requests_before_the_http_call() {
.send_message(&MessageRequest { .send_message(&MessageRequest {
model: "claude-sonnet-4-6".to_string(), model: "claude-sonnet-4-6".to_string(),
max_tokens: 64_000, max_tokens: 64_000,
messages: vec![InputMessage { messages: Arc::new(vec![InputMessage {
role: "user".to_string(), role: "user".to_string(),
content: vec![InputContentBlock::Text { content: vec![InputContentBlock::Text {
text: "x".repeat(600_000), text: "x".repeat(600_000),
}], }],
}], }]),
system: Some("Keep the answer short.".to_string()), system: Some(Arc::from("Keep the answer short.")),
tools: None, tools: None,
tool_choice: None, tool_choice: None,
stream: false, stream: false,
@ -235,7 +180,7 @@ async fn send_message_applies_request_profile_and_records_telemetry() {
let request = captured.first().expect("server should capture request"); let request = captured.first().expect("server should capture request");
assert_eq!( assert_eq!(
request.headers.get("anthropic-beta").map(String::as_str), request.headers.get("anthropic-beta").map(String::as_str),
Some("claude-code-20250219,prompt-caching-scope-2026-01-05,tools-2026-04-01") Some("claude-code-20250219,prompt-caching-scope-2026-01-05,effort-2025-11-24,tools-2026-04-01")
); );
assert_eq!( assert_eq!(
request.headers.get("user-agent").map(String::as_str), request.headers.get("user-agent").map(String::as_str),
@ -404,7 +349,6 @@ async fn stream_message_parses_sse_events_with_tool_use() {
.await; .await;
let client = ApiClient::new("test-key") let client = ApiClient::new("test-key")
.with_auth_token(Some("proxy-token".to_string()))
.with_base_url(server.base_url()) .with_base_url(server.base_url())
.with_prompt_cache(PromptCache::new("stream-session")); .with_prompt_cache(PromptCache::new("stream-session"));
let mut stream = client let mut stream = client
@ -787,9 +731,9 @@ async fn live_stream_smoke_test() {
model: std::env::var("ANTHROPIC_MODEL") model: std::env::var("ANTHROPIC_MODEL")
.unwrap_or_else(|_| "claude-3-7-sonnet-latest".to_string()), .unwrap_or_else(|_| "claude-3-7-sonnet-latest".to_string()),
max_tokens: 32, max_tokens: 32,
messages: vec![InputMessage::user_text( messages: Arc::new(vec![InputMessage::user_text(
"Reply with exactly: hello from rust", "Reply with exactly: hello from rust",
)], )]),
system: None, system: None,
tools: None, tools: None,
tool_choice: None, tool_choice: None,
@ -948,7 +892,7 @@ fn sample_request(stream: bool) -> MessageRequest {
MessageRequest { MessageRequest {
model: "claude-3-7-sonnet-latest".to_string(), model: "claude-3-7-sonnet-latest".to_string(),
max_tokens: 64, max_tokens: 64,
messages: vec![InputMessage { messages: Arc::new(vec![InputMessage {
role: "user".to_string(), role: "user".to_string(),
content: vec![ content: vec![
InputContentBlock::Text { InputContentBlock::Text {
@ -960,10 +904,11 @@ fn sample_request(stream: bool) -> MessageRequest {
value: json!({"forecast": "sunny"}), value: json!({"forecast": "sunny"}),
}], }],
is_error: false, is_error: false,
cache_reference: None,
}, },
], ],
}], }]),
system: Some("Use tools when needed".to_string()), system: Some(Arc::from("Use tools when needed")),
tools: Some(vec![ToolDefinition { tools: Some(vec![ToolDefinition {
name: "get_weather".to_string(), name: "get_weather".to_string(),
description: Some("Fetches the weather".to_string()), description: Some("Fetches the weather".to_string()),

View File

@ -1,579 +1,16 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::ffi::OsString;
use std::sync::Arc; use std::sync::Arc;
use std::sync::{Mutex as StdMutex, OnceLock};
use std::time::Duration;
use api::{ use api::{
build_http_client_with, ApiError, ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent, ContentBlockStopEvent,
ContentBlockStartEvent, ContentBlockStopEvent, InputContentBlock, InputMessage, InputContentBlock, InputMessage, MessageDeltaEvent, MessageRequest, OpenAiCompatClient,
MessageDeltaEvent, MessageRequest, OpenAiCompatClient, OpenAiCompatConfig, OutputContentBlock, OpenAiCompatConfig, OutputContentBlock, StreamEvent, ToolChoice, ToolDefinition,
ProviderClient, ProxyConfig, StreamEvent, ToolChoice, ToolDefinition,
}; };
use serde_json::json; use serde_json::json;
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tokio::sync::Mutex; use tokio::sync::Mutex;
#[tokio::test]
async fn send_message_uses_openai_compatible_endpoint_and_auth() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let body = concat!(
"{",
"\"id\":\"chatcmpl_test\",",
"\"model\":\"grok-3\",",
"\"choices\":[{",
"\"message\":{\"role\":\"assistant\",\"content\":\"Hello from Grok\",\"tool_calls\":[]},",
"\"finish_reason\":\"stop\"",
"}],",
"\"usage\":{\"prompt_tokens\":11,\"completion_tokens\":5,\"prompt_tokens_details\":{\"cached_tokens\":3}}",
"}"
);
let server = spawn_server(
state.clone(),
vec![http_response("200 OK", "application/json", body)],
)
.await;
let client = OpenAiCompatClient::new("xai-test-key", OpenAiCompatConfig::xai())
.with_base_url(server.base_url());
let response = client
.send_message(&sample_request(false))
.await
.expect("request should succeed");
assert_eq!(response.model, "grok-3");
assert_eq!(response.usage.input_tokens, 8);
assert_eq!(response.usage.cache_read_input_tokens, 3);
assert_eq!(response.usage.output_tokens, 5);
assert_eq!(response.total_tokens(), 16);
assert_eq!(
response.content,
vec![OutputContentBlock::Text {
text: "Hello from Grok".to_string(),
}]
);
let captured = state.lock().await;
let request = captured.first().expect("server should capture request");
assert_eq!(request.path, "/chat/completions");
assert_eq!(
request.headers.get("authorization").map(String::as_str),
Some("Bearer xai-test-key")
);
let body: serde_json::Value = serde_json::from_str(&request.body).expect("json body");
assert_eq!(body["model"], json!("grok-3"));
assert_eq!(body["messages"][0]["role"], json!("system"));
assert_eq!(body["tools"][0]["type"], json!("function"));
}
#[tokio::test]
async fn send_message_passes_optional_openai_compatible_parameters_on_wire() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let body = concat!(
"{",
"\"id\":\"chatcmpl_params\",",
"\"model\":\"gpt-4o\",",
"\"choices\":[{",
"\"message\":{\"role\":\"assistant\",\"content\":\"Parameters preserved\",\"tool_calls\":[]},",
"\"finish_reason\":\"stop\"",
"}],",
"\"usage\":{\"prompt_tokens\":3,\"completion_tokens\":2}",
"}"
);
let server = spawn_server(
state.clone(),
vec![http_response("200 OK", "application/json", body)],
)
.await;
let client = OpenAiCompatClient::new("openai-test-key", OpenAiCompatConfig::openai())
.with_base_url(server.base_url());
let response = client
.send_message(&MessageRequest {
model: "gpt-4o".to_string(),
temperature: Some(0.2),
top_p: Some(0.8),
frequency_penalty: Some(0.15),
presence_penalty: Some(0.25),
stop: Some(vec!["END".to_string()]),
reasoning_effort: Some("low".to_string()),
..sample_request(false)
})
.await
.expect("request should succeed");
assert_eq!(response.total_tokens(), 5);
let captured = state.lock().await;
let request = captured.first().expect("server should capture request");
let body: serde_json::Value = serde_json::from_str(&request.body).expect("json body");
assert_eq!(body["model"], json!("gpt-4o"));
assert_eq!(body["temperature"], json!(0.2));
assert_eq!(body["top_p"], json!(0.8));
assert_eq!(body["frequency_penalty"], json!(0.15));
assert_eq!(body["presence_penalty"], json!(0.25));
assert_eq!(body["stop"], json!(["END"]));
assert_eq!(body["reasoning_effort"], json!("low"));
}
#[tokio::test]
async fn send_message_preserves_deepseek_reasoning_content_before_text() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let body = concat!(
"{",
"\"id\":\"chatcmpl_deepseek_reasoning\",",
"\"model\":\"deepseek-v4-pro\",",
"\"choices\":[{",
"\"message\":{\"role\":\"assistant\",\"reasoning_content\":\"Think first\",\"content\":\"Answer second\",\"tool_calls\":[]},",
"\"finish_reason\":\"stop\"",
"}],",
"\"usage\":{\"prompt_tokens\":11,\"completion_tokens\":5}",
"}"
);
let server = spawn_server(
state.clone(),
vec![http_response("200 OK", "application/json", body)],
)
.await;
let client = OpenAiCompatClient::new("openai-test-key", OpenAiCompatConfig::openai())
.with_base_url(server.base_url());
let response = client
.send_message(&MessageRequest {
model: "openai/deepseek-v4-pro".to_string(),
..sample_request(false)
})
.await
.expect("request should succeed");
assert_eq!(
response.content,
vec![
OutputContentBlock::Thinking {
thinking: "Think first".to_string(),
signature: None,
},
OutputContentBlock::Text {
text: "Answer second".to_string(),
},
]
);
let captured = state.lock().await;
let request = captured.first().expect("server should capture request");
let body: serde_json::Value = serde_json::from_str(&request.body).expect("json body");
assert_eq!(body["thinking"], json!({"type": "enabled"}));
}
#[tokio::test]
async fn send_message_preserves_ollama_reasoning_before_text() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let body = concat!(
"{",
"\"id\":\"chatcmpl_ollama_reasoning\",",
"\"model\":\"qwen3:latest\",",
"\"choices\":[{",
"\"message\":{\"role\":\"assistant\",\"reasoning\":\"Think locally\",\"content\":\"Answer locally\",\"tool_calls\":[]},",
"\"finish_reason\":\"stop\"",
"}],",
"\"usage\":{\"prompt_tokens\":11,\"completion_tokens\":5}",
"}"
);
let server = spawn_server(
state.clone(),
vec![http_response("200 OK", "application/json", body)],
)
.await;
let client = OpenAiCompatClient::new("ollama-test-key", OpenAiCompatConfig::openai())
.with_base_url(server.base_url());
let response = client
.send_message(&MessageRequest {
model: "openai/qwen3:latest".to_string(),
..sample_request(false)
})
.await
.expect("request should succeed");
assert_eq!(
response.content,
vec![
OutputContentBlock::Thinking {
thinking: "Think locally".to_string(),
signature: None,
},
OutputContentBlock::Text {
text: "Answer locally".to_string(),
},
]
);
let captured = state.lock().await;
let request = captured.first().expect("server should capture request");
let body: serde_json::Value = serde_json::from_str(&request.body).expect("json body");
assert_eq!(body["model"], json!("qwen3:latest"));
}
#[tokio::test]
async fn local_openai_gateway_strips_routing_prefix_and_preserves_extra_body_params() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let body = concat!(
"{",
"\"id\":\"chatcmpl_slash_model\",",
"\"model\":\"openai/gpt-4.1-mini\",",
"\"choices\":[{",
"\"message\":{\"role\":\"assistant\",\"content\":\"Gateway accepted slug\",\"tool_calls\":[]},",
"\"finish_reason\":\"stop\"",
"}],",
"\"usage\":{\"prompt_tokens\":3,\"completion_tokens\":2}",
"}"
);
let server = spawn_server(
state.clone(),
vec![http_response("200 OK", "application/json", body)],
)
.await;
let mut extra_body = std::collections::BTreeMap::new();
extra_body.insert(
"web_search_options".to_string(),
json!({"search_context_size": "low"}),
);
extra_body.insert("parallel_tool_calls".to_string(), json!(false));
extra_body.insert("model".to_string(), json!("malicious-override"));
let client = OpenAiCompatClient::new("openai-test-key", OpenAiCompatConfig::openai())
.with_base_url(server.base_url());
let response = client
.send_message(&MessageRequest {
model: "openai/gpt-4.1-mini".to_string(),
extra_body,
..sample_request(false)
})
.await
.expect("gateway request should succeed");
assert_eq!(response.model, "openai/gpt-4.1-mini");
assert_eq!(response.total_tokens(), 5);
let captured = state.lock().await;
let request = captured.first().expect("captured request");
let body: serde_json::Value = serde_json::from_str(&request.body).expect("json body");
assert_eq!(body["model"], json!("gpt-4.1-mini"));
assert_eq!(
body["web_search_options"],
json!({"search_context_size": "low"})
);
assert_eq!(body["parallel_tool_calls"], json!(false));
}
#[tokio::test]
async fn send_message_blocks_oversized_xai_requests_before_the_http_call() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let server = spawn_server(
state.clone(),
vec![http_response("200 OK", "application/json", "{}")],
)
.await;
let client = OpenAiCompatClient::new("xai-test-key", OpenAiCompatConfig::xai())
.with_base_url(server.base_url());
let error = client
.send_message(&MessageRequest {
model: "grok-3".to_string(),
max_tokens: 64_000,
messages: vec![InputMessage {
role: "user".to_string(),
content: vec![InputContentBlock::Text {
text: "x".repeat(300_000),
}],
}],
system: Some("Keep the answer short.".to_string()),
tools: None,
tool_choice: None,
stream: false,
..Default::default()
})
.await
.expect_err("oversized request should fail local context-window preflight");
assert!(matches!(error, ApiError::ContextWindowExceeded { .. }));
assert!(
state.lock().await.is_empty(),
"preflight failure should avoid any upstream HTTP request"
);
}
#[tokio::test]
async fn send_message_accepts_full_chat_completions_endpoint_override() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let body = concat!(
"{",
"\"id\":\"chatcmpl_full_endpoint\",",
"\"model\":\"grok-3\",",
"\"choices\":[{",
"\"message\":{\"role\":\"assistant\",\"content\":\"Endpoint override works\",\"tool_calls\":[]},",
"\"finish_reason\":\"stop\"",
"}],",
"\"usage\":{\"prompt_tokens\":7,\"completion_tokens\":3}",
"}"
);
let server = spawn_server(
state.clone(),
vec![http_response("200 OK", "application/json", body)],
)
.await;
let endpoint_url = format!("{}/chat/completions", server.base_url());
let client = OpenAiCompatClient::new("xai-test-key", OpenAiCompatConfig::xai())
.with_base_url(endpoint_url);
let response = client
.send_message(&sample_request(false))
.await
.expect("request should succeed");
assert_eq!(response.total_tokens(), 10);
let captured = state.lock().await;
let request = captured.first().expect("server should capture request");
assert_eq!(request.path, "/chat/completions");
}
#[tokio::test]
async fn stream_message_normalizes_text_and_multiple_tool_calls() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let sse = concat!(
"data: {\"id\":\"chatcmpl_stream\",\"model\":\"grok-3\",\"choices\":[{\"delta\":{\"content\":\"Hello\"}}]}\n\n",
"data: {\"id\":\"chatcmpl_stream\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"function\":{\"name\":\"weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}},{\"index\":1,\"id\":\"call_2\",\"function\":{\"name\":\"clock\",\"arguments\":\"{\\\"zone\\\":\\\"UTC\\\"}\"}}]}}]}\n\n",
"data: {\"id\":\"chatcmpl_stream\",\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n\n",
"data: [DONE]\n\n"
);
let server = spawn_server(
state.clone(),
vec![http_response_with_headers(
"200 OK",
"text/event-stream",
sse,
&[("x-request-id", "req_grok_stream")],
)],
)
.await;
let client = OpenAiCompatClient::new("xai-test-key", OpenAiCompatConfig::xai())
.with_base_url(server.base_url());
let mut stream = client
.stream_message(&sample_request(false))
.await
.expect("stream should start");
assert_eq!(stream.request_id(), Some("req_grok_stream"));
let mut events = Vec::new();
while let Some(event) = stream.next_event().await.expect("event should parse") {
events.push(event);
}
assert!(matches!(events[0], StreamEvent::MessageStart(_)));
assert!(matches!(
events[1],
StreamEvent::ContentBlockStart(ContentBlockStartEvent {
content_block: OutputContentBlock::Text { .. },
..
})
));
assert!(matches!(
events[2],
StreamEvent::ContentBlockDelta(ContentBlockDeltaEvent {
delta: ContentBlockDelta::TextDelta { .. },
..
})
));
assert!(matches!(
events[3],
StreamEvent::ContentBlockStart(ContentBlockStartEvent {
index: 1,
content_block: OutputContentBlock::ToolUse { .. },
})
));
assert!(matches!(
events[4],
StreamEvent::ContentBlockDelta(ContentBlockDeltaEvent {
index: 1,
delta: ContentBlockDelta::InputJsonDelta { .. },
})
));
assert!(matches!(
events[5],
StreamEvent::ContentBlockStart(ContentBlockStartEvent {
index: 2,
content_block: OutputContentBlock::ToolUse { .. },
})
));
assert!(matches!(
events[6],
StreamEvent::ContentBlockDelta(ContentBlockDeltaEvent {
index: 2,
delta: ContentBlockDelta::InputJsonDelta { .. },
})
));
assert!(matches!(
events[7],
StreamEvent::ContentBlockStop(ContentBlockStopEvent { index: 1 })
));
assert!(matches!(
events[8],
StreamEvent::ContentBlockStop(ContentBlockStopEvent { index: 2 })
));
assert!(matches!(
events[9],
StreamEvent::ContentBlockStop(ContentBlockStopEvent { index: 0 })
));
assert!(matches!(events[10], StreamEvent::MessageDelta(_)));
assert!(matches!(events[11], StreamEvent::MessageStop(_)));
let captured = state.lock().await;
let request = captured.first().expect("captured request");
assert_eq!(request.path, "/chat/completions");
assert!(request.body.contains("\"stream\":true"));
}
#[tokio::test]
async fn stream_message_preserves_ollama_reasoning_before_text() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let sse = concat!(
"data: {\"id\":\"chatcmpl_stream_ollama_reasoning\",\"model\":\"qwen3:latest\",\"choices\":[{\"delta\":{\"reasoning\":\"Think\"}}]}\n\n",
"data: {\"id\":\"chatcmpl_stream_ollama_reasoning\",\"choices\":[{\"delta\":{\"content\":\" answer\"},\"finish_reason\":\"stop\"}]}\n\n",
"data: [DONE]\n\n"
);
let server = spawn_server(
state.clone(),
vec![http_response_with_headers(
"200 OK",
"text/event-stream",
sse,
&[("x-request-id", "req_ollama_reasoning_stream")],
)],
)
.await;
let client = OpenAiCompatClient::new("ollama-test-key", OpenAiCompatConfig::openai())
.with_base_url(server.base_url());
let mut stream = client
.stream_message(&MessageRequest {
model: "openai/qwen3:latest".to_string(),
..sample_request(false)
})
.await
.expect("stream should start");
assert_eq!(stream.request_id(), Some("req_ollama_reasoning_stream"));
let mut events = Vec::new();
while let Some(event) = stream.next_event().await.expect("event should parse") {
events.push(event);
}
assert!(matches!(events[0], StreamEvent::MessageStart(_)));
assert!(matches!(
events[1],
StreamEvent::ContentBlockStart(ContentBlockStartEvent {
index: 0,
content_block: OutputContentBlock::Thinking { .. },
})
));
assert!(matches!(
events[2],
StreamEvent::ContentBlockDelta(ContentBlockDeltaEvent {
index: 0,
delta: ContentBlockDelta::ThinkingDelta { .. },
})
));
assert!(matches!(
events[3],
StreamEvent::ContentBlockStop(ContentBlockStopEvent { index: 0 })
));
assert!(matches!(
events[4],
StreamEvent::ContentBlockStart(ContentBlockStartEvent {
index: 1,
content_block: OutputContentBlock::Text { .. },
})
));
assert!(matches!(
events[5],
StreamEvent::ContentBlockDelta(ContentBlockDeltaEvent {
index: 1,
delta: ContentBlockDelta::TextDelta { .. },
})
));
let captured = state.lock().await;
let request = captured.first().expect("captured request");
let body: serde_json::Value = serde_json::from_str(&request.body).expect("json body");
assert_eq!(body["model"], json!("qwen3:latest"));
assert_eq!(body["stream"], json!(true));
}
#[allow(clippy::await_holding_lock)]
#[tokio::test]
async fn stream_message_retries_retryable_sse_handshake_failures() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let sse = concat!(
"data: {\"id\":\"chatcmpl_stream_retry\",\"model\":\"gpt-4o\",\"choices\":[{\"delta\":{\"content\":\"Recovered\"}}]}\n\n",
"data: {\"id\":\"chatcmpl_stream_retry\",\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n",
"data: [DONE]\n\n"
);
let server = spawn_server(
state.clone(),
vec![
http_response(
"500 Internal Server Error",
"application/json",
"{\"error\":{\"message\":\"try again\",\"type\":\"server_error\",\"code\":500}}",
),
http_response_with_headers(
"200 OK",
"text/event-stream",
sse,
&[("x-request-id", "req_stream_retry")],
),
],
)
.await;
let client = OpenAiCompatClient::new("openai-test-key", OpenAiCompatConfig::openai())
.with_base_url(server.base_url())
.with_retry_policy(1, Duration::ZERO, Duration::ZERO);
let mut stream = client
.stream_message(&MessageRequest {
model: "gpt-4o".to_string(),
..sample_request(false)
})
.await
.expect("stream should retry once then start");
assert_eq!(stream.request_id(), Some("req_stream_retry"));
let mut events = Vec::new();
while let Some(event) = stream.next_event().await.expect("event should parse") {
events.push(event);
}
assert!(events.iter().any(|event| matches!(
event,
StreamEvent::ContentBlockDelta(ContentBlockDeltaEvent {
delta: ContentBlockDelta::TextDelta { text },
..
}) if text == "Recovered"
)));
let captured = state.lock().await;
assert_eq!(captured.len(), 2, "one original request plus one retry");
for request in captured.iter() {
let body: serde_json::Value = serde_json::from_str(&request.body).expect("json body");
assert_eq!(body["stream"], json!(true));
}
}
#[allow(clippy::await_holding_lock)] #[allow(clippy::await_holding_lock)]
#[tokio::test] #[tokio::test]
async fn openai_streaming_requests_opt_into_usage_chunks() { async fn openai_streaming_requests_opt_into_usage_chunks() {
@ -581,7 +18,7 @@ async fn openai_streaming_requests_opt_into_usage_chunks() {
let sse = concat!( let sse = concat!(
"data: {\"id\":\"chatcmpl_openai_stream\",\"model\":\"gpt-5\",\"choices\":[{\"delta\":{\"content\":\"Hi\"}}]}\n\n", "data: {\"id\":\"chatcmpl_openai_stream\",\"model\":\"gpt-5\",\"choices\":[{\"delta\":{\"content\":\"Hi\"}}]}\n\n",
"data: {\"id\":\"chatcmpl_openai_stream\",\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n", "data: {\"id\":\"chatcmpl_openai_stream\",\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n",
"data: {\"id\":\"chatcmpl_openai_stream\",\"choices\":[],\"usage\":{\"prompt_tokens\":9,\"completion_tokens\":4,\"prompt_tokens_details\":{\"cached_tokens\":2}}}\n\n", "data: {\"id\":\"chatcmpl_openai_stream\",\"choices\":[],\"usage\":{\"prompt_tokens\":9,\"completion_tokens\":4}}\n\n",
"data: [DONE]\n\n" "data: [DONE]\n\n"
); );
let server = spawn_server( let server = spawn_server(
@ -636,10 +73,8 @@ async fn openai_streaming_requests_opt_into_usage_chunks() {
match &events[4] { match &events[4] {
StreamEvent::MessageDelta(MessageDeltaEvent { usage, .. }) => { StreamEvent::MessageDelta(MessageDeltaEvent { usage, .. }) => {
assert_eq!(usage.input_tokens, 7); assert_eq!(usage.input_tokens, 9);
assert_eq!(usage.cache_read_input_tokens, 2);
assert_eq!(usage.output_tokens, 4); assert_eq!(usage.output_tokens, 4);
assert_eq!(usage.total_tokens(), 13);
} }
other => panic!("expected message delta, got {other:?}"), other => panic!("expected message delta, got {other:?}"),
} }
@ -652,85 +87,6 @@ async fn openai_streaming_requests_opt_into_usage_chunks() {
assert_eq!(body["stream_options"], json!({"include_usage": true})); assert_eq!(body["stream_options"], json!({"include_usage": true}));
} }
#[allow(clippy::await_holding_lock)]
#[tokio::test]
async fn openai_compatible_client_honors_http_proxy_for_requests() {
let _lock = env_lock();
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let proxy = spawn_server(
state.clone(),
vec![http_response(
"200 OK",
"application/json",
"{\"id\":\"chatcmpl_proxy\",\"model\":\"gpt-4o\",\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"Via proxy\",\"tool_calls\":[]},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":4,\"completion_tokens\":3}}",
)],
)
.await;
let proxied_http = build_http_client_with(&ProxyConfig::from_proxy_url(proxy.base_url()))
.expect("proxy client should build");
let client = OpenAiCompatClient::new("openai-test-key", OpenAiCompatConfig::openai())
.with_http_client(proxied_http)
.with_base_url("http://origin.invalid/v1");
let response = client
.send_message(&MessageRequest {
model: "openai/gpt-4.1-mini".to_string(),
..sample_request(false)
})
.await
.expect("proxy should return the OpenAI-compatible response");
assert_eq!(response.model, "openai/gpt-4.1-mini");
assert_eq!(response.total_tokens(), 7);
let captured = state.lock().await;
let request = captured.first().expect("proxy should capture request");
assert_eq!(request.path, "http://origin.invalid/v1/chat/completions");
assert_eq!(
request.headers.get("authorization").map(String::as_str),
Some("Bearer openai-test-key")
);
let body: serde_json::Value = serde_json::from_str(&request.body).expect("json body");
assert_eq!(body["model"], json!("openai/gpt-4.1-mini"));
}
#[allow(clippy::await_holding_lock)]
#[tokio::test]
async fn provider_client_dispatches_xai_requests_from_env() {
let _lock = env_lock();
let _api_key = ScopedEnvVar::set("XAI_API_KEY", "xai-test-key");
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let server = spawn_server(
state.clone(),
vec![http_response(
"200 OK",
"application/json",
"{\"id\":\"chatcmpl_provider\",\"model\":\"grok-3\",\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"Through provider client\",\"tool_calls\":[]},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":9,\"completion_tokens\":4}}",
)],
)
.await;
let _base_url = ScopedEnvVar::set("XAI_BASE_URL", server.base_url());
let client =
ProviderClient::from_model("grok").expect("xAI provider client should be constructed");
assert!(matches!(client, ProviderClient::Xai(_)));
let response = client
.send_message(&sample_request(false))
.await
.expect("provider-dispatched request should succeed");
assert_eq!(response.total_tokens(), 13);
let captured = state.lock().await;
let request = captured.first().expect("captured request");
assert_eq!(request.path, "/chat/completions");
assert_eq!(
request.headers.get("authorization").map(String::as_str),
Some("Bearer xai-test-key")
);
}
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
struct CapturedRequest { struct CapturedRequest {
path: String, path: String,
@ -838,10 +194,6 @@ fn find_header_end(bytes: &[u8]) -> Option<usize> {
bytes.windows(4).position(|window| window == b"\r\n\r\n") bytes.windows(4).position(|window| window == b"\r\n\r\n")
} }
fn http_response(status: &str, content_type: &str, body: &str) -> String {
http_response_with_headers(status, content_type, body, &[])
}
fn http_response_with_headers( fn http_response_with_headers(
status: &str, status: &str,
content_type: &str, content_type: &str,
@ -863,13 +215,13 @@ fn sample_request(stream: bool) -> MessageRequest {
MessageRequest { MessageRequest {
model: "grok-3".to_string(), model: "grok-3".to_string(),
max_tokens: 64, max_tokens: 64,
messages: vec![InputMessage { messages: Arc::new(vec![InputMessage {
role: "user".to_string(), role: "user".to_string(),
content: vec![InputContentBlock::Text { content: vec![InputContentBlock::Text {
text: "Say hello".to_string(), text: "Say hello".to_string(),
}], }],
}], }]),
system: Some("Use tools when needed".to_string()), system: Some(Arc::from("Use tools when needed")),
tools: Some(vec![ToolDefinition { tools: Some(vec![ToolDefinition {
name: "weather".to_string(), name: "weather".to_string(),
description: Some("Fetches weather".to_string()), description: Some("Fetches weather".to_string()),
@ -884,32 +236,3 @@ fn sample_request(stream: bool) -> MessageRequest {
..Default::default() ..Default::default()
} }
} }
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: OnceLock<StdMutex<()>> = OnceLock::new();
LOCK.get_or_init(|| StdMutex::new(()))
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
struct ScopedEnvVar {
key: &'static str,
previous: Option<OsString>,
}
impl ScopedEnvVar {
fn set(key: &'static str, value: impl AsRef<std::ffi::OsStr>) -> Self {
let previous = std::env::var_os(key);
std::env::set_var(key, value);
Self { key, previous }
}
}
impl Drop for ScopedEnvVar {
fn drop(&mut self) {
match &self.previous {
Some(value) => std::env::set_var(self.key, value),
None => std::env::remove_var(self.key),
}
}
}

View File

@ -1,42 +1,12 @@
use std::ffi::OsString; use std::ffi::OsString;
use std::sync::{Mutex, OnceLock}; use std::sync::{Mutex, OnceLock};
use api::{read_xai_base_url, ApiError, AuthSource, ProviderClient, ProviderKind}; use api::{AuthSource, ProviderClient, ProviderKind};
#[test]
fn provider_client_routes_grok_aliases_through_xai() {
let _lock = env_lock();
let _xai_api_key = EnvVarGuard::set("XAI_API_KEY", Some("xai-test-key"));
let client = ProviderClient::from_model("grok-mini").expect("grok alias should resolve");
assert_eq!(client.provider_kind(), ProviderKind::Xai);
}
#[test]
fn provider_client_reports_missing_xai_credentials_for_grok_models() {
let _lock = env_lock();
let _xai_api_key = EnvVarGuard::set("XAI_API_KEY", None);
let error = ProviderClient::from_model("grok-3")
.expect_err("grok requests without XAI_API_KEY should fail fast");
match error {
ApiError::MissingCredentials {
provider, env_vars, ..
} => {
assert_eq!(provider, "xAI");
assert_eq!(env_vars, &["XAI_API_KEY"]);
}
other => panic!("expected missing xAI credentials, got {other:?}"),
}
}
#[test] #[test]
fn provider_client_uses_explicit_anthropic_auth_without_env_lookup() { fn provider_client_uses_explicit_anthropic_auth_without_env_lookup() {
let _lock = env_lock(); let _lock = env_lock();
let _anthropic_api_key = EnvVarGuard::set("ANTHROPIC_API_KEY", None); let _anthropic_api_key = EnvVarGuard::set("ANTHROPIC_API_KEY", None);
let _anthropic_auth_token = EnvVarGuard::set("ANTHROPIC_AUTH_TOKEN", None);
let client = ProviderClient::from_model_with_anthropic_auth( let client = ProviderClient::from_model_with_anthropic_auth(
"claude-sonnet-4-6", "claude-sonnet-4-6",
@ -47,14 +17,6 @@ fn provider_client_uses_explicit_anthropic_auth_without_env_lookup() {
assert_eq!(client.provider_kind(), ProviderKind::Anthropic); assert_eq!(client.provider_kind(), ProviderKind::Anthropic);
} }
#[test]
fn read_xai_base_url_prefers_env_override() {
let _lock = env_lock();
let _xai_base_url = EnvVarGuard::set("XAI_BASE_URL", Some("https://example.xai.test/v1"));
assert_eq!(read_xai_base_url(), "https://example.xai.test/v1");
}
fn env_lock() -> std::sync::MutexGuard<'static, ()> { fn env_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new(); static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(())) LOCK.get_or_init(|| Mutex::new(()))

View File

@ -35,6 +35,7 @@ impl Drop for EnvVarGuard {
} }
} }
#[cfg(not(target_os = "windows"))]
#[test] #[test]
fn proxy_config_from_env_reads_uppercase_proxy_vars() { fn proxy_config_from_env_reads_uppercase_proxy_vars() {
// given // given
@ -123,6 +124,42 @@ fn proxy_config_from_env_treats_empty_values_as_unset() {
assert!(config.is_empty()); assert!(config.is_empty());
} }
/// On Windows, environment variable names are case-insensitive, so `HTTP_PROXY`
/// and `http_proxy` are the same slot. Verify the single value is read correctly.
#[cfg(target_os = "windows")]
#[test]
fn proxy_config_from_env_reads_proxy_vars_windows_upper() {
let _lock = env_lock();
let _http = EnvVarGuard::set("HTTP_PROXY", Some("http://proxy.corp:3128"));
let _https = EnvVarGuard::set("HTTPS_PROXY", Some("http://secure.corp:3129"));
let _no = EnvVarGuard::set("NO_PROXY", Some("localhost,127.0.0.1"));
let config = ProxyConfig::from_env();
assert_eq!(config.http_proxy.as_deref(), Some("http://proxy.corp:3128"));
assert_eq!(config.https_proxy.as_deref(), Some("http://secure.corp:3129"));
assert_eq!(config.no_proxy.as_deref(), Some("localhost,127.0.0.1"));
assert!(!config.is_empty());
}
/// On Windows, setting the lowercase variant overwrites the uppercase due to
/// case-insensitive env var names. Verify the last-written value is read.
#[cfg(target_os = "windows")]
#[test]
fn proxy_config_from_env_reads_proxy_vars_windows_lower() {
let _lock = env_lock();
let _http = EnvVarGuard::set("http_proxy", Some("http://lower.corp:3128"));
let _https = EnvVarGuard::set("https_proxy", Some("http://lower-secure.corp:3129"));
let _no = EnvVarGuard::set("no_proxy", Some(".internal"));
let config = ProxyConfig::from_env();
assert_eq!(config.http_proxy.as_deref(), Some("http://lower.corp:3128"));
assert_eq!(config.https_proxy.as_deref(), Some("http://lower-secure.corp:3129"));
assert_eq!(config.no_proxy.as_deref(), Some(".internal"));
assert!(!config.is_empty());
}
#[test] #[test]
fn build_client_with_env_proxy_config_succeeds() { fn build_client_with_env_proxy_config_succeeds() {
// given // given
@ -154,6 +191,7 @@ fn build_client_with_proxy_url_config_succeeds() {
assert!(result.is_ok()); assert!(result.is_ok());
} }
#[cfg(not(target_os = "windows"))]
#[test] #[test]
fn proxy_config_from_env_prefers_uppercase_over_lowercase() { fn proxy_config_from_env_prefers_uppercase_over_lowercase() {
// given // given

View File

@ -1,33 +0,0 @@
[package]
name = "claw-analog"
version.workspace = true
edition.workspace = true
license.workspace = true
publish.workspace = true
description = "Minimal agent harness: tool loop with explicit permissions and workspace jail."
[lib]
name = "claw_analog"
path = "src/lib.rs"
[[bin]]
name = "claw-analog"
path = "src/main.rs"
[dependencies]
api = { path = "../api" }
clap = { version = "4", features = ["derive"] }
clap_complete = "4"
globset = "0.4"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
runtime = { path = "../runtime" }
serde = { version = "1", features = ["derive"] }
serde_json.workspace = true
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
toml = "0.8"
walkdir = "2"
ignore = "0.4"
[dev-dependencies]
mock-anthropic-service = { path = "../mock-anthropic-service" }
tempfile = "3"

View File

@ -1,489 +0,0 @@
//! `claw-analog agents` — run multiple specialized sub-agents sequentially.
use std::path::{Path, PathBuf};
use api::InputMessage;
use clap::{Parser, ValueEnum};
use claw_analog::{
enforce_non_interactive_permission_rules, load_analog_toml, resolve_analog_options,
resolve_analog_profile_path, resolve_rag_base_url, AnalogConfig, AnalogDoctorOverrides,
AnalogFileConfig, OutputFormat, PermissionMode, Preset, StreamOverride,
};
const DEF_MAX_READ: u64 = 256 * 1024;
const DEF_MAX_TURNS: u32 = 24;
const DEF_MAX_LIST: usize = 500;
const DEF_GREP_MAX: usize = 200;
const DEF_GLOB_PATHS: usize = 2000;
const DEF_GLOB_DEPTH: usize = 32;
const DEF_RAG_TIMEOUT_SECS: u64 = 30;
const DEF_RAG_TOP_K_MAX: u32 = 32;
const RAG_TOP_K_ABS_CAP: u32 = 256;
#[derive(Copy, Clone, Debug, ValueEnum)]
pub enum AgentsPresetArg {
Audit,
Explain,
Implement,
}
impl From<AgentsPresetArg> for Preset {
fn from(p: AgentsPresetArg) -> Self {
match p {
AgentsPresetArg::Audit => Preset::Audit,
AgentsPresetArg::Explain => Preset::Explain,
AgentsPresetArg::Implement => Preset::Implement,
}
}
}
#[derive(Copy, Clone, Debug, ValueEnum)]
pub enum AgentsPermissionArg {
ReadOnly,
WorkspaceWrite,
Prompt,
#[value(name = "danger-full-access")]
DangerFullAccess,
Allow,
}
impl From<AgentsPermissionArg> for PermissionMode {
fn from(p: AgentsPermissionArg) -> Self {
match p {
AgentsPermissionArg::ReadOnly => PermissionMode::ReadOnly,
AgentsPermissionArg::WorkspaceWrite => PermissionMode::WorkspaceWrite,
AgentsPermissionArg::Prompt => PermissionMode::Prompt,
AgentsPermissionArg::DangerFullAccess => PermissionMode::DangerFullAccess,
AgentsPermissionArg::Allow => PermissionMode::Allow,
}
}
}
#[derive(Debug, Clone)]
pub struct AgentSpec {
pub name: String,
pub preset: Preset,
pub permission: PermissionMode,
pub model: Option<String>,
pub prompt: Option<String>,
}
fn default_permission_for_preset(p: Preset) -> PermissionMode {
match p {
Preset::Audit | Preset::Explain => PermissionMode::ReadOnly,
Preset::Implement => PermissionMode::WorkspaceWrite,
Preset::None => PermissionMode::ReadOnly,
}
}
fn parse_agent_spec(s: &str) -> Result<AgentSpec, String> {
// Allowed forms:
// - "audit" | "explain" | "implement"
// - "name=audit,preset=audit,permission=read-only,model=...,prompt=..."
let raw = s.trim();
if raw.is_empty() {
return Err("empty --agent spec".to_string());
}
if !raw.contains('=') {
let preset = match raw.to_ascii_lowercase().as_str() {
"audit" => Preset::Audit,
"explain" => Preset::Explain,
"implement" | "fix" => Preset::Implement,
other => return Err(format!("unknown agent shorthand: {other}")),
};
return Ok(AgentSpec {
name: raw.to_string(),
preset,
permission: default_permission_for_preset(preset),
model: None,
prompt: None,
});
}
let mut name: Option<String> = None;
let mut preset: Option<Preset> = None;
let mut permission: Option<PermissionMode> = None;
let mut model: Option<String> = None;
let mut prompt: Option<String> = None;
for part in raw.split(',') {
let (k, v) = part
.split_once('=')
.ok_or_else(|| format!("invalid agent spec part {part:?} (expected k=v)"))?;
let k = k.trim().to_ascii_lowercase();
let v = v.trim();
if v.is_empty() {
continue;
}
match k.as_str() {
"name" => name = Some(v.to_string()),
"preset" => {
let p = match v.to_ascii_lowercase().as_str() {
"audit" => Preset::Audit,
"explain" => Preset::Explain,
"implement" | "fix" => Preset::Implement,
"none" => Preset::None,
other => return Err(format!("unknown preset {other:?}")),
};
preset = Some(p);
}
"permission" => {
let pm = match v.to_ascii_lowercase().replace('_', "-").as_str() {
"read-only" | "readonly" => PermissionMode::ReadOnly,
"workspace-write" | "write" => PermissionMode::WorkspaceWrite,
"prompt" => PermissionMode::Prompt,
"danger-full-access" | "danger" => PermissionMode::DangerFullAccess,
"allow" => PermissionMode::Allow,
other => return Err(format!("unknown permission {other:?}")),
};
permission = Some(pm);
}
"model" => model = Some(v.to_string()),
"prompt" => prompt = Some(v.to_string()),
other => return Err(format!("unknown agent spec key {other:?}")),
}
}
let preset = preset.unwrap_or(Preset::Audit);
let permission = permission.unwrap_or_else(|| default_permission_for_preset(preset));
let name = name.unwrap_or_else(|| preset.label().unwrap_or("agent").to_string());
Ok(AgentSpec {
name,
preset,
permission,
model,
prompt,
})
}
#[derive(Debug, Parser)]
pub struct AgentsCli {
/// Workspace root.
#[arg(short = 'w', long, default_value = ".", value_name = "DIR")]
pub workspace: PathBuf,
/// Config path (default: `<workspace>/.claw-analog.toml`).
#[arg(long, value_name = "PATH")]
pub config: Option<PathBuf>,
/// Base session path. If missing, it will be created from the base prompt.
#[arg(long, value_name = "PATH")]
pub base_session: PathBuf,
/// Base prompt. If omitted, reads from stdin.
#[arg(long)]
pub prompt: Option<String>,
/// Repeatable agent specs, e.g. `--agent audit` or `--agent name=fix,preset=implement,permission=workspace-write`.
#[arg(long, required = true)]
pub agent: Vec<String>,
/// If set, each agent writes its own session file next to base session.
#[arg(long, default_value_t = true)]
pub split_sessions: bool,
}
fn load_file_config(path: &Path) -> AnalogFileConfig {
if !path.is_file() {
return AnalogFileConfig::default();
}
load_analog_toml(path).unwrap_or_default()
}
fn config_path(args: &AgentsCli) -> PathBuf {
args.config
.clone()
.unwrap_or_else(|| args.workspace.join(".claw-analog.toml"))
}
fn derive_agent_session_path(base: &Path, agent_name: &str) -> PathBuf {
let base_s = base.to_string_lossy();
PathBuf::from(format!("{base_s}.agent-{agent_name}.json"))
}
fn read_stdin_prompt() -> Result<String, String> {
use std::io::Read;
let mut buf = String::new();
std::io::stdin()
.read_to_string(&mut buf)
.map_err(|e| e.to_string())?;
let t = buf.trim();
if t.is_empty() {
return Err("empty prompt (pass --prompt or stdin)".to_string());
}
Ok(t.to_string())
}
fn ensure_base_session(base_session: &Path, workspace: &Path, prompt: &str) -> Result<(), String> {
if base_session.exists() {
return Ok(());
}
let ws_s = workspace.display().to_string();
let model = "base".to_string();
let messages = if prompt.trim().is_empty() {
Vec::new()
} else {
vec![InputMessage::user_text(prompt.to_string())]
};
claw_analog::session_save(base_session, &ws_s, &model, Preset::None, &messages)?;
Ok(())
}
pub fn run_agents(args: AgentsCli) -> Result<(), String> {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| e.to_string())?;
rt.block_on(async { run_agents_async(args).await })
}
pub async fn run_agents_async(args: AgentsCli) -> Result<(), String> {
run_agents_inner(args, |cfg, out| {
Box::pin(async move {
claw_analog::run(cfg, out)
.await
.map_err(|e| e.to_string())?;
Ok(())
})
})
.await
}
type RunFuture<'a> = std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), String>> + 'a>>;
async fn run_agents_inner<F>(args: AgentsCli, mut run_one: F) -> Result<(), String>
where
for<'a> F: FnMut(AnalogConfig, &'a mut Vec<u8>) -> RunFuture<'a>,
{
let workspace = if args.workspace.is_absolute() {
args.workspace.clone()
} else {
std::env::current_dir()
.map_err(|e| e.to_string())?
.join(&args.workspace)
};
let cfg_path = config_path(&args);
let file_cfg = load_file_config(&cfg_path);
let base_prompt = match args.prompt.clone() {
Some(p) => p,
None => read_stdin_prompt()?,
};
ensure_base_session(&args.base_session, &workspace, base_prompt.as_str())?;
let mut specs = Vec::new();
for a in &args.agent {
specs.push(parse_agent_spec(a)?);
}
println!("claw-analog agents (sequential)\n");
println!(" workspace: {}", workspace.display());
println!(" base_session: {}", args.base_session.display());
println!(" agents: {}", specs.len());
println!();
for (i, spec) in specs.into_iter().enumerate() {
println!(
"== Agent {} / {}: {} ==",
i + 1,
args.agent.len(),
spec.name
);
println!(" preset: {}", spec.preset.label().unwrap_or("none"));
println!(" permission: {}", spec.permission.as_str());
if let Some(m) = &spec.model {
println!(" model: {m}");
}
enforce_non_interactive_permission_rules(spec.permission, false)?;
let agent_session = if args.split_sessions {
derive_agent_session_path(&args.base_session, spec.name.as_str())
} else {
args.base_session.clone()
};
if args.split_sessions {
std::fs::copy(&args.base_session, &agent_session).map_err(|e| e.to_string())?;
}
let overrides = AnalogDoctorOverrides {
model: spec.model.clone(),
permission: Some(spec.permission),
preset: Some(spec.preset),
output_format: Some(OutputFormat::Rich),
stream: StreamOverride::ForceOff,
..Default::default()
};
let resolved = resolve_analog_options(&file_cfg, &overrides);
let profile_path =
resolve_analog_profile_path(&workspace, None, file_cfg.profile.as_deref());
let profile_hint = if let Some(ref p) = profile_path {
claw_analog::load_profile_hint(p).unwrap_or(None)
} else {
None
};
let rag_base_url = resolve_rag_base_url(&file_cfg);
let agent_prompt = spec.prompt.unwrap_or_else(|| {
format!(
"Agent {}: run preset {}",
spec.name,
resolved.preset.label().unwrap_or("none")
)
});
let cfg = AnalogConfig {
model: resolved.model,
workspace: workspace.clone(),
permission_mode: resolved.permission_mode,
accept_danger_non_interactive: false,
use_stream: false,
output_format: resolved.output_format,
use_runtime_enforcer: resolved.use_runtime_enforcer,
max_read_bytes: file_cfg.max_read_bytes.unwrap_or(DEF_MAX_READ),
max_turns: file_cfg.max_turns.unwrap_or(DEF_MAX_TURNS),
max_list_entries: file_cfg.max_list_entries.unwrap_or(DEF_MAX_LIST),
grep_max_lines: file_cfg.grep_max_lines.unwrap_or(DEF_GREP_MAX),
glob_max_paths: file_cfg.glob_max_paths.unwrap_or(DEF_GLOB_PATHS),
glob_max_depth: file_cfg.glob_max_depth.unwrap_or(DEF_GLOB_DEPTH),
preset: resolved.preset,
language: file_cfg
.language
.as_deref()
.and_then(claw_analog::AnalogLanguage::from_toml_str)
.unwrap_or_default(),
session_path: Some(agent_session.clone()),
session_save_path: None,
profile_hint,
prompt: agent_prompt,
rag_base_url,
rag_http_timeout: std::time::Duration::from_secs(
file_cfg.rag_timeout_secs.unwrap_or(DEF_RAG_TIMEOUT_SECS),
),
rag_top_k_max: file_cfg
.rag_top_k_max
.unwrap_or(DEF_RAG_TOP_K_MAX)
.clamp(1, RAG_TOP_K_ABS_CAP),
};
let mut buf: Vec<u8> = Vec::new();
let run_res = run_one(cfg, &mut buf).await;
match run_res {
Ok(()) => {
let text = String::from_utf8_lossy(&buf);
let summary = tail_chars(text.as_ref(), 1600);
println!(" result: OK");
if args.split_sessions {
println!(" session: {}", agent_session.display());
}
println!(" summary_tail:\n{}\n", indent_lines(&summary, 4));
}
Err(e) => {
println!(" result: FAIL — {e}\n");
}
}
}
Ok(())
}
fn tail_chars(s: &str, n: usize) -> String {
let total = s.chars().count();
if total <= n {
return s.to_string();
}
s.chars().skip(total - n).collect()
}
fn indent_lines(s: &str, spaces: usize) -> String {
let pad = " ".repeat(spaces);
s.lines()
.map(|l| format!("{pad}{l}"))
.collect::<Vec<_>>()
.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Mutex, OnceLock};
fn mock_env_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(|e| e.into_inner())
}
#[test]
fn parses_agent_shorthand() {
let a = parse_agent_spec("audit").unwrap();
assert_eq!(a.preset, Preset::Audit);
assert_eq!(a.permission, PermissionMode::ReadOnly);
}
#[test]
fn parses_agent_kv() {
let a = parse_agent_spec("name=fix,preset=implement,permission=workspace-write").unwrap();
assert_eq!(a.name, "fix");
assert_eq!(a.preset, Preset::Implement);
assert_eq!(a.permission, PermissionMode::WorkspaceWrite);
}
#[test]
fn runs_two_agents_sequentially_with_stub_runner() {
let _g = mock_env_lock();
let dir = tempfile::tempdir().unwrap();
let workspace = dir.path().canonicalize().unwrap();
std::fs::write(workspace.join("fixture.txt"), "hello parity fixture\n").unwrap();
let base_session = workspace.join(".claw").join("agents-base.json");
std::fs::create_dir_all(base_session.parent().unwrap()).unwrap();
std::fs::write(
&base_session,
format!(
"{{\n \"version\": 1,\n \"workspace\": \"{}\",\n \"model\": \"base\",\n \"messages\": []\n}}\n",
workspace.display()
),
)
.unwrap();
let args = AgentsCli {
workspace: workspace.clone(),
config: None,
base_session: base_session.clone(),
prompt: Some(String::new()),
agent: vec![
"name=audit,preset=audit,permission=read-only,prompt=check 1".to_string(),
"name=explain,preset=explain,permission=read-only,prompt=check 2".to_string(),
],
split_sessions: true,
};
let called = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let called2 = called.clone();
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(1)
.enable_all()
.build()
.expect("runtime");
rt.block_on(async {
run_agents_inner(args, move |_cfg, out| {
let called3 = called2.clone();
Box::pin(async move {
called3.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
out.extend_from_slice(b"stub ok");
Ok(())
})
})
.await
.expect("agents should run");
});
assert_eq!(called.load(std::sync::atomic::Ordering::Relaxed), 2);
assert!(derive_agent_session_path(&base_session, "audit").is_file());
assert!(derive_agent_session_path(&base_session, "explain").is_file());
}
}

View File

@ -1,144 +0,0 @@
//! `claw-analog config validate` — parse TOML and profile without calling the API.
use std::path::PathBuf;
use clap::Parser;
use claw_analog::{
load_analog_toml, load_profile_hint, resolve_analog_options, resolve_analog_profile_path,
AnalogDoctorOverrides, AnalogFileConfig, AnalogLanguage, OutputFormat,
};
#[derive(Parser, Debug)]
pub struct ValidateCli {
#[arg(short = 'w', long, default_value = ".", value_name = "DIR")]
pub workspace: PathBuf,
#[arg(long, value_name = "PATH")]
pub config: Option<PathBuf>,
/// Require `<workspace>/.claw-analog.toml` (or `--config`) to exist and parse.
#[arg(long, default_value_t = false, action = clap::ArgAction::SetTrue)]
pub strict: bool,
#[arg(long, value_name = "PATH")]
pub profile: Option<PathBuf>,
}
pub fn run_validate(cli: ValidateCli) -> i32 {
let cfg_path = cli
.config
.clone()
.unwrap_or_else(|| cli.workspace.join(".claw-analog.toml"));
let file_cfg = if cfg_path.is_file() {
match load_analog_toml(&cfg_path) {
Ok(c) => {
println!("OK: {} parses", cfg_path.display());
c
}
Err(e) => {
eprintln!("ERROR: {}: {e}", cfg_path.display());
return 1;
}
}
} else if cli.strict {
eprintln!(
"ERROR: --strict: config file missing: {}",
cfg_path.display()
);
return 1;
} else {
println!(
"Note: {} absent — using empty TOML defaults for preview",
cfg_path.display()
);
AnalogFileConfig::default()
};
let prof_path = resolve_analog_profile_path(
&cli.workspace,
cli.profile.clone(),
file_cfg.profile.as_deref(),
);
let mut ok = true;
match &prof_path {
None => println!(
"Profile: (none — no CLI/TOML path and no default ~/.claw-analog/profile.toml)"
),
Some(p) => match load_profile_hint(p) {
Ok(Some(line)) => println!(
"OK: profile {} (line: {} chars)",
p.display(),
line.chars().count()
),
Ok(None) => println!("OK: profile {} (empty `line`)", p.display()),
Err(e) => {
eprintln!("ERROR: profile {}: {e}", p.display());
ok = false;
}
},
}
let lang = file_cfg
.language
.as_deref()
.and_then(AnalogLanguage::from_toml_str)
.unwrap_or_default();
let r = resolve_analog_options(&file_cfg, &AnalogDoctorOverrides::default());
println!("\nMerge preview (TOML + defaults only; main-run CLI flags not applied):");
println!(" language (TOML): {}", lang.as_str());
println!(" model: {}", r.model);
println!(" permission: {}", r.permission_mode.as_str());
println!(" preset: {}", r.preset.label().unwrap_or("none"));
println!(
" output_format: {}",
match r.output_format {
OutputFormat::Rich => "rich",
OutputFormat::Json => "json",
}
);
println!(" stream: {}", r.use_stream);
println!(
" runtime_enforcer: {}",
if r.use_runtime_enforcer { "on" } else { "off" }
);
println!(
" accept_danger_non_interactive: {}",
r.accept_danger_non_interactive
);
println!(" Provenance:");
for line in &r.provenance {
println!(" - {line}");
}
i32::from(!ok)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strict_fails_when_config_missing() {
let dir = tempfile::tempdir().unwrap();
let code = run_validate(ValidateCli {
workspace: dir.path().to_path_buf(),
config: None,
strict: true,
profile: None,
});
assert_eq!(code, 1);
}
#[test]
fn parses_when_config_present() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join(".claw-analog.toml");
std::fs::write(&p, r#"model = "sonnet""#).unwrap();
let code = run_validate(ValidateCli {
workspace: dir.path().to_path_buf(),
config: None,
strict: true,
profile: None,
});
assert_eq!(code, 0);
}
}

View File

@ -1,733 +0,0 @@
//! `claw-analog doctor` — environment and Cargo sanity checks.
use std::net::{TcpStream, ToSocketAddrs};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Duration;
use clap::ValueEnum;
use claw_analog::{
load_analog_toml, load_profile_hint, resolve_analog_options, AnalogDoctorOverrides,
AnalogFileConfig, OutputFormat, PermissionMode, Preset, StreamOverride, NDJSON_FORMAT_VERSION,
NDJSON_SCHEMA,
};
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
const ENV_CHECK: &[&str] = &[
"ANTHROPIC_API_KEY",
"ANTHROPIC_AUTH_TOKEN",
"ANTHROPIC_BASE_URL",
"OPENAI_API_KEY",
"OPENAI_BASE_URL",
"XAI_API_KEY",
"RAG_BASE_URL",
];
#[derive(Copy, Clone, Debug, ValueEnum)]
pub enum DoctorPermissionArg {
ReadOnly,
WorkspaceWrite,
Prompt,
#[value(name = "danger-full-access")]
DangerFullAccess,
Allow,
}
impl From<DoctorPermissionArg> for PermissionMode {
fn from(p: DoctorPermissionArg) -> Self {
match p {
DoctorPermissionArg::ReadOnly => PermissionMode::ReadOnly,
DoctorPermissionArg::WorkspaceWrite => PermissionMode::WorkspaceWrite,
DoctorPermissionArg::Prompt => PermissionMode::Prompt,
DoctorPermissionArg::DangerFullAccess => PermissionMode::DangerFullAccess,
DoctorPermissionArg::Allow => PermissionMode::Allow,
}
}
}
#[derive(Copy, Clone, Debug, ValueEnum)]
pub enum DoctorOutputArg {
Rich,
Json,
}
impl From<DoctorOutputArg> for OutputFormat {
fn from(o: DoctorOutputArg) -> Self {
match o {
DoctorOutputArg::Rich => OutputFormat::Rich,
DoctorOutputArg::Json => OutputFormat::Json,
}
}
}
#[derive(Copy, Clone, Debug, ValueEnum)]
pub enum DoctorPresetCli {
None,
Audit,
Explain,
Implement,
}
impl From<DoctorPresetCli> for Preset {
fn from(p: DoctorPresetCli) -> Self {
match p {
DoctorPresetCli::None => Preset::None,
DoctorPresetCli::Audit => Preset::Audit,
DoctorPresetCli::Explain => Preset::Explain,
DoctorPresetCli::Implement => Preset::Implement,
}
}
}
#[derive(Debug, clap::Args)]
pub struct DoctorCli {
/// Workspace root (same as `claw-analog -w`; config defaults to `<workspace>/.claw-analog.toml`).
#[arg(short = 'w', long, default_value = ".", value_name = "DIR")]
pub workspace: PathBuf,
/// Config path (default: `<workspace>/.claw-analog.toml`).
#[arg(long, value_name = "PATH")]
pub config: Option<PathBuf>,
/// Override model (same precedence as main CLI).
#[arg(long)]
pub model: Option<String>,
#[arg(long, value_enum)]
pub permission: Option<DoctorPermissionArg>,
#[arg(long, value_enum)]
pub preset: Option<DoctorPresetCli>,
#[arg(long, value_enum)]
pub output_format: Option<DoctorOutputArg>,
#[arg(long, default_value_t = false, conflicts_with = "no_stream")]
pub stream: bool,
#[arg(long, default_value_t = false, conflicts_with = "stream")]
pub no_stream: bool,
/// Disable `runtime::PermissionEnforcer` (same as main CLI).
#[arg(
long = "no-runtime-enforcer",
default_value_t = false,
action = clap::ArgAction::SetTrue
)]
pub no_runtime_enforcer: bool,
#[arg(
long = "accept-danger-non-interactive",
default_value_t = false,
action = clap::ArgAction::SetTrue
)]
pub accept_danger_non_interactive: bool,
/// Profile TOML path (optional; if omitted, uses TOML `profile` or default `~/.claw-analog/profile.toml`).
#[arg(long, value_name = "PATH")]
pub profile: Option<PathBuf>,
/// TCP connect to host:port from `ANTHROPIC_BASE_URL` (or default API URL); not a full HTTP check.
#[arg(long, visible_alias = "mock")]
pub tcp_ping: bool,
/// Skip HTTPS/TLS + auth + quota header checks against configured providers.
#[arg(long, default_value_t = false)]
pub no_http_check: bool,
/// Also probe the embeddings endpoint for OpenAI-compatible providers (may incur minimal cost).
#[arg(long, default_value_t = false)]
pub embeddings_check: bool,
/// Skip compile check (`cargo check` / `build --release`).
#[arg(long)]
pub no_build: bool,
/// Run `cargo build --release -p claw-analog` (writes `target/release/…`, safe while `cargo run` holds `target/debug/…` on Windows).
#[arg(long, conflicts_with = "no_build")]
pub release_build: bool,
/// Directory containing the repo workspace `Cargo.toml` (default: search upward from cwd).
#[arg(long, value_name = "DIR")]
pub manifest_dir: Option<PathBuf>,
}
pub fn run_doctor(args: DoctorCli) -> i32 {
println!("claw-analog doctor — environment and build checks\n");
let workspace = args.workspace.clone();
let canon_ws = std::fs::canonicalize(&workspace).unwrap_or_else(|_| workspace.clone());
let cfg_path = args
.config
.clone()
.unwrap_or_else(|| workspace.join(".claw-analog.toml"));
let (file_cfg, cfg_note) = if cfg_path.is_file() {
match load_analog_toml(&cfg_path) {
Ok(c) => (c, "loaded"),
Err(e) => {
eprintln!(
"[claw-analog] doctor: failed to parse {}: {e} (using empty TOML defaults)",
cfg_path.display()
);
(AnalogFileConfig::default(), "parse error (defaults)")
}
}
} else {
(AnalogFileConfig::default(), "file missing (defaults only)")
};
let stream_ov = if args.no_stream {
StreamOverride::ForceOff
} else if args.stream {
StreamOverride::ForceOn
} else {
StreamOverride::FromFile
};
let overrides = AnalogDoctorOverrides {
model: args.model.clone(),
permission: args.permission.map(Into::into),
preset: args.preset.map(Into::into),
output_format: args.output_format.map(Into::into),
stream: stream_ov,
no_runtime_enforcer: args.no_runtime_enforcer,
accept_danger_non_interactive: args.accept_danger_non_interactive,
};
let resolved = resolve_analog_options(&file_cfg, &overrides);
println!("NDJSON contract (for `--output-format json` runs):");
println!(" schema: {NDJSON_SCHEMA}");
println!(" format_version: {NDJSON_FORMAT_VERSION}\n");
println!("Effective config (merge of `.claw-analog.toml` + flags below):");
println!(" workspace: {}", canon_ws.display());
println!(" config: {} ({cfg_note})", cfg_path.display());
println!(" model: {}", resolved.model);
println!(" permission: {}", resolved.permission_mode.as_str());
println!(" preset: {}", resolved.preset.label().unwrap_or("none"));
println!(
" output_format: {}",
match resolved.output_format {
OutputFormat::Rich => "rich",
OutputFormat::Json => "json",
}
);
println!(" stream: {}", resolved.use_stream);
println!(
" runtime_enforcer: {}",
if resolved.use_runtime_enforcer {
"on"
} else {
"off"
}
);
println!(
" accept_danger_non_interactive: {}",
resolved.accept_danger_non_interactive
);
println!(" Provenance (which side won src ← …):");
for line in &resolved.provenance {
println!(" - {line}");
}
println!();
let prof = resolve_profile_path_doctor(
args.profile.as_ref(),
file_cfg.profile.as_deref(),
&workspace,
);
print_profile_hint_section(&prof);
println!();
check_env();
println!();
let build_ok = if args.no_build {
println!("cargo: skipped (--no-build)");
true
} else if args.release_build {
run_cargo_release_build(args.manifest_dir.as_deref())
} else {
run_cargo_check(args.manifest_dir.as_deref())
};
println!();
if args.tcp_ping {
ping_print();
println!();
}
if !args.no_http_check {
http_checks_print(args.embeddings_check);
println!();
}
if build_ok {
0
} else {
1
}
}
fn home_dir() -> Option<PathBuf> {
#[cfg(windows)]
{
std::env::var_os("USERPROFILE").map(PathBuf::from)
}
#[cfg(not(windows))]
{
std::env::var_os("HOME").map(PathBuf::from)
}
}
fn expand_user_path(raw: &str) -> PathBuf {
if let Some(rest) = raw.strip_prefix("~/") {
home_dir()
.map(|h| h.join(rest))
.unwrap_or_else(|| PathBuf::from(raw))
} else {
PathBuf::from(raw)
}
}
fn resolve_profile_path_doctor(
cli: Option<&PathBuf>,
file: Option<&str>,
workspace: &Path,
) -> Option<PathBuf> {
if let Some(p) = cli {
return Some(if p.is_absolute() {
p.clone()
} else {
workspace.join(p)
});
}
if let Some(s) = file {
let p = expand_user_path(s.trim());
return Some(if p.is_absolute() {
p
} else {
workspace.join(p)
});
}
let def = home_dir()?.join(".claw-analog").join("profile.toml");
if def.is_file() {
Some(def)
} else {
None
}
}
fn print_profile_hint_section(path: &Option<PathBuf>) {
println!("Profile (system prompt snippet):");
match path {
None => println!(" (none — no --profile, no `profile` in TOML, default file absent)"),
Some(p) => {
print!(" path: {}", p.display());
match load_profile_hint(p) {
Ok(Some(h)) => println!(" — loaded, {} chars", h.chars().count()),
Ok(None) => println!(" — file ok, empty `line`"),
Err(e) => println!(" — error: {e}"),
}
}
}
}
fn mask_env_line(name: &str) {
match std::env::var(name) {
Ok(v) if !v.trim().is_empty() => {
println!(" {name}: set ({} chars)", v.chars().count());
}
Ok(_) => println!(" {name}: set but empty"),
Err(_) => println!(" {name}: unset"),
}
}
fn check_env() {
println!("Environment (values are not printed):");
for name in ENV_CHECK {
mask_env_line(name);
}
let anthro_ok = std::env::var("ANTHROPIC_API_KEY")
.map(|s| !s.trim().is_empty())
.unwrap_or(false)
|| std::env::var("ANTHROPIC_AUTH_TOKEN")
.map(|s| !s.trim().is_empty())
.unwrap_or(false);
let openai_ok = std::env::var("OPENAI_API_KEY")
.map(|s| !s.trim().is_empty())
.unwrap_or(false);
println!();
if anthro_ok {
println!("Anthropic credentials: OK (API key and/or auth token).");
} else {
println!("Anthropic credentials: not set — needed for default Claude/Anthropic models.");
}
if openai_ok {
println!("OpenAI API key: set — use `openai/...` model prefix for that provider.");
} else {
println!("OpenAI API key: unset — only relevant for `openai/` models.");
}
if !anthro_ok && !openai_ok {
println!("\nNote: neither Anthropic nor OpenAI keys are set; live runs will fail until you export credentials (see USAGE.md).");
}
}
/// Walk upward from `start` for a `Cargo.toml` that defines `[workspace]`.
pub fn discover_cargo_workspace(start: &Path) -> Option<PathBuf> {
let mut dir = start.to_path_buf();
for _ in 0..32 {
let manifest = dir.join("Cargo.toml");
if manifest.is_file() {
if let Ok(txt) = std::fs::read_to_string(&manifest) {
if txt.contains("[workspace]") {
return Some(dir);
}
}
}
dir = dir.parent()?.to_path_buf();
}
None
}
fn workspace_root_or_eprint(manifest_dir: Option<&Path>) -> Option<PathBuf> {
let start = manifest_dir
.map(Path::to_path_buf)
.or_else(|| std::env::current_dir().ok())
.unwrap_or_else(|| PathBuf::from("."));
discover_cargo_workspace(&start).or_else(|| {
eprintln!(
"cargo: could not find a [workspace] Cargo.toml above {}.\n Pass --manifest-dir pointing at the `rust` folder of claw-code.",
start.display()
);
None
})
}
/// `cargo check` does not replace `target/debug/claw-analog.exe`, so `cargo run … doctor` works on Windows.
fn run_cargo_check(manifest_dir: Option<&Path>) -> bool {
let Some(root) = workspace_root_or_eprint(manifest_dir) else {
return false;
};
println!("cargo check -p claw-analog (workspace {})", root.display());
println!(" (compile-only; avoids “access denied” replacing the running debug exe on Windows)");
let status = Command::new("cargo")
.args(["check", "-p", "claw-analog"])
.current_dir(&root)
.status();
match status {
Ok(s) if s.success() => {
println!("cargo check: OK");
true
}
Ok(s) => {
eprintln!("cargo check: failed ({s})");
false
}
Err(e) => {
eprintln!("cargo check: could not run `cargo` ({e}). Is Rust/Cargo on PATH?");
false
}
}
}
fn run_cargo_release_build(manifest_dir: Option<&Path>) -> bool {
let Some(root) = workspace_root_or_eprint(manifest_dir) else {
return false;
};
println!(
"cargo build --release -p claw-analog (workspace {})",
root.display()
);
println!(" (output in target/release/; does not overwrite a running target/debug/ binary)");
let status = Command::new("cargo")
.args(["build", "--release", "-p", "claw-analog"])
.current_dir(&root)
.status();
match status {
Ok(s) if s.success() => {
println!("cargo build --release: OK");
true
}
Ok(s) => {
eprintln!("cargo build --release: failed ({s})");
false
}
Err(e) => {
eprintln!("cargo build --release: could not run `cargo` ({e}). Is Rust/Cargo on PATH?");
false
}
}
}
fn default_anthropic_base() -> String {
std::env::var("ANTHROPIC_BASE_URL").unwrap_or_else(|_| "https://api.anthropic.com".into())
}
fn parse_host_port(url: &str) -> Result<(String, u16), String> {
let url = url.trim().trim_end_matches('/');
let (scheme, rest) = if let Some(r) = url.strip_prefix("https://") {
("https", r)
} else if let Some(r) = url.strip_prefix("http://") {
("http", r)
} else {
return Err("URL must start with http:// or https://".into());
};
let host_part = rest
.split('/')
.next()
.filter(|s| !s.is_empty())
.ok_or_else(|| "missing host".to_string())?;
if let Some((host, port_s)) = host_part.rsplit_once(':') {
if let Ok(p) = port_s.parse::<u16>() {
let host = host.trim_start_matches('[').trim_end_matches(']');
return Ok((host.to_string(), p));
}
}
let default_port = if scheme == "https" { 443 } else { 80 };
Ok((host_part.to_string(), default_port))
}
fn ping_print() {
let url = default_anthropic_base();
println!("TCP check for ANTHROPIC_BASE_URL (default if unset): {url}");
match parse_host_port(&url) {
Ok((host, port)) => match tcp_ping(&host, port) {
Ok(()) => println!(" reachability: OK ({host}:{port})"),
Err(e) => println!(" reachability: FAIL ({host}:{port}) — {e}"),
},
Err(e) => println!(" could not parse URL: {e}"),
}
println!(" (HTTP/TLS application data is not validated; this is connect() only.)");
}
fn tcp_ping(host: &str, port: u16) -> Result<(), String> {
let addr = (host, port)
.to_socket_addrs()
.map_err(|e| e.to_string())?
.next()
.ok_or_else(|| "no resolved addresses".to_string())?;
TcpStream::connect_timeout(&addr, Duration::from_secs(3)).map_err(|e| e.to_string())?;
Ok(())
}
fn http_checks_print(embeddings_check: bool) {
println!("HTTP/TLS checks (auth + TLS validation + quota headers when available):");
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build();
let Ok(rt) = rt else {
println!(" runtime: FAIL (could not build tokio runtime)");
return;
};
rt.block_on(async {
// OpenAI-compatible providers (OPENAI_BASE_URL, OPENAI_API_KEY)
if let Ok(key) = std::env::var("OPENAI_API_KEY") {
if !key.trim().is_empty() {
let base = std::env::var("OPENAI_BASE_URL")
.ok()
.unwrap_or_else(|| "https://api.openai.com/v1".to_string());
let url = openai_models_url(base.as_str());
let mut headers = HeaderMap::new();
if let Ok(v) = HeaderValue::from_str(format!("Bearer {}", key.trim()).as_str()) {
headers.insert(reqwest::header::AUTHORIZATION, v);
}
let _ = http_check_and_print("openai", url.as_str(), headers).await;
if embeddings_check {
let model = std::env::var("OPENAI_EMBEDDING_MODEL")
.ok()
.or_else(|| std::env::var("CLAW_RAG_EMBEDDING_MODEL").ok())
.unwrap_or_else(|| "text-embedding-3-small".to_string());
let eurl = openai_embeddings_url(base.as_str());
let mut eheaders = HeaderMap::new();
if let Ok(v) = HeaderValue::from_str(format!("Bearer {}", key.trim()).as_str())
{
eheaders.insert(reqwest::header::AUTHORIZATION, v);
}
let _ = openai_embeddings_probe(
"openai embeddings",
eurl.as_str(),
&model,
eheaders,
)
.await;
} else {
println!(" openai embeddings: skipped (pass --embeddings-check to enable)");
}
} else {
println!(" openai: skipped (OPENAI_API_KEY empty)");
}
} else {
println!(" openai: skipped (OPENAI_API_KEY unset)");
}
// Anthropic (ANTHROPIC_BASE_URL, ANTHROPIC_API_KEY/AUTH_TOKEN)
let a_key = std::env::var("ANTHROPIC_API_KEY").ok();
let a_tok = std::env::var("ANTHROPIC_AUTH_TOKEN").ok();
let a_base = std::env::var("ANTHROPIC_BASE_URL")
.ok()
.unwrap_or_else(|| "https://api.anthropic.com".to_string());
if a_key.as_deref().is_some_and(|s| !s.trim().is_empty())
|| a_tok.as_deref().is_some_and(|s| !s.trim().is_empty())
{
let url = anthropic_models_url(a_base.as_str());
let mut headers = HeaderMap::new();
headers.insert(
HeaderName::from_static("anthropic-version"),
HeaderValue::from_static("2023-06-01"),
);
if let Some(k) = a_key.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
if let Ok(v) = HeaderValue::from_str(k) {
headers.insert(HeaderName::from_static("x-api-key"), v);
}
} else if let Some(t) = a_tok.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
if let Ok(v) = HeaderValue::from_str(format!("Bearer {t}").as_str()) {
headers.insert(reqwest::header::AUTHORIZATION, v);
}
}
let _ = http_check_and_print("anthropic", url.as_str(), headers).await;
} else {
println!(" anthropic: skipped (no API key/token)");
}
// RAG service (RAG_BASE_URL) — just basic health + stats.
if let Ok(base) = std::env::var("RAG_BASE_URL") {
let base = base.trim().trim_end_matches('/');
if !base.is_empty() {
let headers = HeaderMap::new();
let _ =
http_check_and_print("rag health", &format!("{base}/health"), headers.clone())
.await;
let _ =
http_check_and_print("rag stats", &format!("{base}/v1/stats"), headers).await;
}
}
});
println!(" (TLS validation is performed by the HTTP client; certificate errors surface as request failures.)");
}
fn openai_models_url(base: &str) -> String {
let b = base.trim().trim_end_matches('/');
if b.ends_with("/v1") {
format!("{b}/models")
} else {
format!("{b}/v1/models")
}
}
fn openai_embeddings_url(base: &str) -> String {
let b = base.trim().trim_end_matches('/');
if b.ends_with("/v1") {
format!("{b}/embeddings")
} else {
format!("{b}/v1/embeddings")
}
}
fn anthropic_models_url(base: &str) -> String {
let b = base.trim().trim_end_matches('/');
format!("{b}/v1/models?limit=1")
}
async fn http_check_and_print(label: &str, url: &str, headers: HeaderMap) -> Result<(), ()> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(8))
.build();
let Ok(client) = client else {
println!(" {label}: FAIL (client build)");
return Err(());
};
let resp = client.get(url).headers(headers).send().await;
match resp {
Ok(r) => {
let status = r.status();
println!(" {label}: {status} ({url})");
print_quota_headers(r.headers());
Ok(())
}
Err(e) => {
let msg = e.to_string();
if msg.to_ascii_lowercase().contains("certificate")
|| msg.to_ascii_lowercase().contains("tls")
{
println!(" {label}: FAIL (TLS/cert) ({url}) — {msg}");
} else {
println!(" {label}: FAIL ({url}) — {msg}");
}
Err(())
}
}
}
fn print_quota_headers(headers: &HeaderMap) {
let mut out: Vec<(String, String)> = Vec::new();
for (k, v) in headers.iter() {
let name = k.as_str().to_ascii_lowercase();
if name.contains("ratelimit") || name.contains("quota") {
if let Ok(s) = v.to_str() {
out.push((k.as_str().to_string(), s.to_string()));
}
}
// OpenAI-compatible common headers:
if name.starts_with("x-ratelimit-") {
if let Ok(s) = v.to_str() {
out.push((k.as_str().to_string(), s.to_string()));
}
}
}
out.sort();
out.dedup();
for (k, v) in out {
println!(" {k}: {v}");
}
}
async fn openai_embeddings_probe(
label: &str,
url: &str,
model: &str,
headers: HeaderMap,
) -> Result<(), ()> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(12))
.build();
let Ok(client) = client else {
println!(" {label}: FAIL (client build)");
return Err(());
};
// Minimal request: one short string. We don't parse the embedding content.
let body = serde_json::json!({
"model": model,
"input": ["ping"]
});
let resp = client.post(url).headers(headers).json(&body).send().await;
match resp {
Ok(r) => {
let status = r.status();
println!(" {label}: {status} ({url}) model={model}");
print_quota_headers(r.headers());
if !status.is_success() {
let t = r.text().await.unwrap_or_default();
if !t.trim().is_empty() {
println!(" body: {}", t.chars().take(400).collect::<String>());
}
return Err(());
}
Ok(())
}
Err(e) => {
let msg = e.to_string();
if msg.to_ascii_lowercase().contains("certificate")
|| msg.to_ascii_lowercase().contains("tls")
{
println!(" {label}: FAIL (TLS/cert) ({url}) — {msg}");
} else {
println!(" {label}: FAIL ({url}) — {msg}");
}
Err(())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_base_url_host_port() {
assert_eq!(
parse_host_port("http://127.0.0.1:8080/v1").unwrap(),
("127.0.0.1".into(), 8080)
);
assert_eq!(
parse_host_port("https://api.anthropic.com").unwrap(),
("api.anthropic.com".into(), 443)
);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,522 +0,0 @@
//! Binary wrapper for `claw_analog::run` — see `how_to_run.md` in repo root.
mod agents;
mod config_cmd;
mod doctor;
use std::path::{Path, PathBuf};
use std::time::Duration;
use clap::{CommandFactory, Parser, Subcommand, ValueEnum};
use clap_complete::{generate, Shell};
use claw_analog::{
load_analog_toml, load_profile_hint, permission_mode_from_toml_str, print_tools_dry_run,
resolve_analog_profile_path, resolve_rag_base_url, AnalogConfig, AnalogFileConfig,
AnalogLanguage, OutputFormat, PermissionMode, Preset, ANALOG_DEFAULT_MODEL,
};
#[derive(Copy, Clone, Debug, ValueEnum)]
enum PermissionArg {
ReadOnly,
WorkspaceWrite,
Prompt,
#[value(name = "danger-full-access")]
DangerFullAccess,
/// Same unrestricted posture as danger-full-access for this narrow tool set.
Allow,
}
#[derive(Copy, Clone, Debug, ValueEnum)]
enum OutputFormatArg {
Rich,
Json,
}
#[derive(Copy, Clone, Debug, ValueEnum)]
enum LangArg {
En,
Ru,
}
impl From<LangArg> for AnalogLanguage {
fn from(a: LangArg) -> Self {
match a {
LangArg::En => AnalogLanguage::En,
LangArg::Ru => AnalogLanguage::Ru,
}
}
}
#[derive(Copy, Clone, Debug, ValueEnum)]
enum PresetCli {
None,
/// Automatically infer a preset from the initial prompt.
Auto,
Audit,
Explain,
Implement,
}
impl From<PresetCli> for Preset {
fn from(p: PresetCli) -> Self {
match p {
PresetCli::None => Preset::None,
PresetCli::Auto => Preset::None,
PresetCli::Audit => Preset::Audit,
PresetCli::Explain => Preset::Explain,
PresetCli::Implement => Preset::Implement,
}
}
}
#[derive(Parser, Debug)]
#[command(
name = "claw-analog",
version,
about = "Lean tool-agent loop (read/list/grep/write) on claw-code `api` providers"
)]
#[command(args_conflicts_with_subcommands = true)]
struct RootCli {
#[command(subcommand)]
command: Option<Commands>,
#[command(flatten)]
run: RunCli,
}
#[derive(Subcommand, Debug)]
enum Commands {
/// Verify credentials, `cargo check -p claw-analog` (or `--release-build`), config merge preview, optional `--tcp-ping`.
Doctor(doctor::DoctorCli),
Config {
#[command(subcommand)]
command: ConfigSub,
},
/// Print shell completion script for this binary (redirect to a file or `source` it).
Complete(CompleteCli),
/// Run multiple specialized sub-agents sequentially (shared base session).
Agents(agents::AgentsCli),
}
#[derive(Subcommand, Debug)]
enum ConfigSub {
/// Parse `.claw-analog.toml` and profile; print a merge preview (no API calls).
Validate(config_cmd::ValidateCli),
}
#[derive(Parser, Debug)]
struct CompleteCli {
#[arg(value_enum)]
shell: ShellKind,
}
#[derive(Copy, Clone, Debug, ValueEnum)]
enum ShellKind {
Bash,
Zsh,
Fish,
#[value(name = "powershell", alias = "pwsh")]
Powershell,
}
#[derive(Parser, Debug)]
struct RunCli {
/// Config file (default: `<workspace>/.claw-analog.toml` if that path exists).
#[arg(long, value_name = "PATH")]
config: Option<PathBuf>,
#[arg(short, long)]
model: Option<String>,
#[arg(short = 'w', long, default_value = ".")]
workspace: PathBuf,
#[arg(long, value_enum)]
permission: Option<PermissionArg>,
#[arg(long, value_enum)]
preset: Option<PresetCli>,
/// Reply language hint for the assistant (`en` or `ru` in system prompt; not the API model id).
#[arg(long, value_enum)]
lang: Option<LangArg>,
/// Print effective tools for merged `permission` / enforcer, then exit (no prompt, no API).
#[arg(long, default_value_t = false, action = clap::ArgAction::SetTrue)]
print_tools: bool,
/// Persist message history for resume (JSON). See `how_to_run.md` for risks.
#[arg(long, value_name = "PATH")]
session: Option<PathBuf>,
/// Write session JSON to this path on each snapshot (export without `--session`, or an extra copy).
#[arg(long, value_name = "PATH")]
save_session: Option<PathBuf>,
/// Profile snippet TOML (`line = "..."`). Default: `~/.claw-analog/profile.toml` if it exists.
#[arg(long, value_name = "PATH")]
profile: Option<PathBuf>,
/// Stream assistant text to stdout as tokens arrive (uses `stream_message`).
#[arg(long, default_value_t = false, conflicts_with = "no_stream")]
stream: bool,
/// Turn streaming off (overrides `stream` in config).
#[arg(long, default_value_t = false, conflicts_with = "stream")]
no_stream: bool,
/// Newline-delimited JSON events on stdout (for agents / CI). Diagnostics stay on stderr.
#[arg(long, value_enum)]
output_format: Option<OutputFormatArg>,
/// Disable `runtime::PermissionEnforcer` (paths are still jailed; policy checks are weakened).
#[arg(long = "no-runtime-enforcer", default_value_t = false, action = clap::ArgAction::SetTrue)]
no_runtime_enforcer: bool,
/// Allow `danger-full-access` / `allow` when stdin is not a TTY (CI/automation; use with care).
#[arg(long = "accept-danger-non-interactive", default_value_t = false, action = clap::ArgAction::SetTrue)]
accept_danger_non_interactive: bool,
#[arg(long)]
max_read_bytes: Option<u64>,
#[arg(long)]
max_turns: Option<u32>,
#[arg(long)]
max_list_entries: Option<usize>,
#[arg(long)]
grep_max_lines: Option<usize>,
#[arg(long)]
glob_max_paths: Option<usize>,
#[arg(long)]
glob_max_depth: Option<usize>,
prompt: Option<String>,
}
const DEF_MAX_READ: u64 = 256 * 1024;
const DEF_MAX_TURNS: u32 = 24;
const DEF_MAX_LIST: usize = 500;
const DEF_GREP_MAX: usize = 200;
const DEF_GLOB_PATHS: usize = 2000;
const DEF_GLOB_DEPTH: usize = 32;
const DEF_RAG_TIMEOUT_SECS: u64 = 30;
const DEF_RAG_TOP_K_MAX: u32 = 32;
const RAG_TOP_K_ABS_CAP: u32 = 256;
fn config_file_path(cli: &RunCli) -> PathBuf {
cli.config
.clone()
.unwrap_or_else(|| cli.workspace.join(".claw-analog.toml"))
}
fn load_file_config(path: &Path) -> AnalogFileConfig {
if !path.is_file() {
return AnalogFileConfig::default();
}
match load_analog_toml(path) {
Ok(c) => c,
Err(e) => {
eprintln!(
"[claw-analog] warning: failed to read {}: {e}",
path.display()
);
AnalogFileConfig::default()
}
}
}
fn output_format_from_toml(s: &str) -> Option<OutputFormat> {
match s.to_ascii_lowercase().as_str() {
"json" => Some(OutputFormat::Json),
"rich" => Some(OutputFormat::Rich),
_ => None,
}
}
fn resolve_session_path(
cli: Option<PathBuf>,
file: Option<&str>,
workspace: &Path,
) -> Option<PathBuf> {
let p = cli.or_else(|| file.map(PathBuf::from))?;
Some(if p.is_absolute() {
p
} else {
workspace.join(p)
})
}
fn merge_language(cli: Option<LangArg>, file: Option<&str>) -> AnalogLanguage {
if let Some(l) = cli {
return l.into();
}
file.and_then(AnalogLanguage::from_toml_str)
.unwrap_or_default()
}
fn merge_preset(cli: Option<PresetCli>, file: Option<&str>, prompt: &str) -> Preset {
if let Some(p) = cli {
return match p {
PresetCli::Auto => claw_analog::infer_preset_from_prompt(prompt),
other => Preset::from(other),
};
}
if file.is_some_and(|s| s.trim().eq_ignore_ascii_case("auto")) {
return claw_analog::infer_preset_from_prompt(prompt);
}
if let Some(s) = file.and_then(Preset::from_toml_str) {
return s;
}
claw_analog::infer_preset_from_prompt(prompt)
}
fn merge_permission(
cli: Option<PermissionArg>,
file_perm: Option<String>,
preset: Preset,
) -> PermissionMode {
if let Some(p) = cli {
return match p {
PermissionArg::ReadOnly => PermissionMode::ReadOnly,
PermissionArg::WorkspaceWrite => PermissionMode::WorkspaceWrite,
PermissionArg::Prompt => PermissionMode::Prompt,
PermissionArg::DangerFullAccess => PermissionMode::DangerFullAccess,
PermissionArg::Allow => PermissionMode::Allow,
};
}
if let Some(s) = file_perm.as_deref().and_then(permission_mode_from_toml_str) {
return s;
}
match preset {
Preset::Implement => PermissionMode::WorkspaceWrite,
_ => PermissionMode::ReadOnly,
}
}
fn build_config(
cli: &RunCli,
file: &AnalogFileConfig,
prompt: String,
profile_hint: Option<String>,
session_path: Option<PathBuf>,
preset: Preset,
permission_mode: PermissionMode,
) -> AnalogConfig {
let model = cli
.model
.clone()
.or_else(|| file.model.clone())
.unwrap_or_else(|| ANALOG_DEFAULT_MODEL.into());
let output_format = cli
.output_format
.map(|o| match o {
OutputFormatArg::Rich => OutputFormat::Rich,
OutputFormatArg::Json => OutputFormat::Json,
})
.or_else(|| {
file.output_format
.as_deref()
.and_then(output_format_from_toml)
})
.unwrap_or(OutputFormat::Rich);
let use_stream = if cli.no_stream {
false
} else if cli.stream {
true
} else {
file.stream.unwrap_or(false)
};
let use_runtime_enforcer =
!cli.no_runtime_enforcer && !file.no_runtime_enforcer.unwrap_or(false);
let accept_danger_non_interactive =
cli.accept_danger_non_interactive || file.accept_danger_non_interactive.unwrap_or(false);
let max_read_bytes = cli
.max_read_bytes
.or(file.max_read_bytes)
.unwrap_or(DEF_MAX_READ);
let max_turns = cli.max_turns.or(file.max_turns).unwrap_or(DEF_MAX_TURNS);
let max_list_entries = cli
.max_list_entries
.or(file.max_list_entries)
.unwrap_or(DEF_MAX_LIST);
let grep_max_lines = cli
.grep_max_lines
.or(file.grep_max_lines)
.unwrap_or(DEF_GREP_MAX);
let glob_max_paths = cli
.glob_max_paths
.or(file.glob_max_paths)
.unwrap_or(DEF_GLOB_PATHS);
let glob_max_depth = cli
.glob_max_depth
.or(file.glob_max_depth)
.unwrap_or(DEF_GLOB_DEPTH);
let rag_base_url = resolve_rag_base_url(file);
let rag_http_timeout =
Duration::from_secs(file.rag_timeout_secs.unwrap_or(DEF_RAG_TIMEOUT_SECS).max(1));
let rag_top_k_max = file
.rag_top_k_max
.unwrap_or(DEF_RAG_TOP_K_MAX)
.clamp(1, RAG_TOP_K_ABS_CAP);
let session_save_path = cli.save_session.as_ref().map(|p| {
if p.is_absolute() {
p.clone()
} else {
cli.workspace.join(p)
}
});
let language = merge_language(cli.lang, file.language.as_deref());
AnalogConfig {
model,
workspace: cli.workspace.clone(),
permission_mode,
accept_danger_non_interactive,
use_stream,
output_format,
use_runtime_enforcer,
max_read_bytes,
max_turns,
max_list_entries,
grep_max_lines,
glob_max_paths,
glob_max_depth,
preset,
language,
session_path,
session_save_path,
profile_hint,
prompt,
rag_base_url,
rag_http_timeout,
rag_top_k_max,
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let root = RootCli::parse();
match root.command {
Some(Commands::Doctor(d)) => {
let code = doctor::run_doctor(d);
std::process::exit(code);
}
Some(Commands::Agents(a)) => {
let code = match agents::run_agents(a) {
Ok(()) => 0,
Err(e) => {
eprintln!("agents: {e}");
1
}
};
std::process::exit(code);
}
Some(Commands::Config { command }) => {
let code = match command {
ConfigSub::Validate(v) => config_cmd::run_validate(v),
};
std::process::exit(code);
}
Some(Commands::Complete(co)) => {
let shell = match co.shell {
ShellKind::Bash => Shell::Bash,
ShellKind::Zsh => Shell::Zsh,
ShellKind::Fish => Shell::Fish,
ShellKind::Powershell => Shell::PowerShell,
};
let mut cmd = RootCli::command();
generate(shell, &mut cmd, "claw-analog", &mut std::io::stdout());
return Ok(());
}
None => {}
}
let cli = root.run;
let cfg_path = config_file_path(&cli);
let file_cfg = load_file_config(&cfg_path);
if cli.print_tools {
let preset = merge_preset(
cli.preset,
file_cfg.preset.as_deref(),
&cli.prompt.clone().unwrap_or_default(),
);
let permission_mode = merge_permission(cli.permission, file_cfg.permission.clone(), preset);
let use_runtime_enforcer =
!cli.no_runtime_enforcer && !file_cfg.no_runtime_enforcer.unwrap_or(false);
let rag_url = resolve_rag_base_url(&file_cfg);
print_tools_dry_run(
permission_mode,
use_runtime_enforcer,
rag_url.as_deref(),
&mut std::io::stdout(),
)?;
return Ok(());
}
let pre_output_format = cli
.output_format
.map(|o| match o {
OutputFormatArg::Rich => OutputFormat::Rich,
OutputFormatArg::Json => OutputFormat::Json,
})
.or_else(|| {
file_cfg
.output_format
.as_deref()
.and_then(output_format_from_toml)
})
.unwrap_or(OutputFormat::Rich);
let prompt = if let Some(p) = cli.prompt.clone() {
p
} else {
use std::io::Read;
let mut buf = String::new();
std::io::stdin().read_to_string(&mut buf)?;
if buf.trim().is_empty() {
if matches!(pre_output_format, OutputFormat::Json) {
println!(
"{}",
serde_json::json!({"type": "error", "message": "empty prompt (pass as arg or stdin)"})
);
}
return Err("empty prompt (pass as arg or stdin)".into());
}
buf
};
let preset = merge_preset(cli.preset, file_cfg.preset.as_deref(), &prompt);
let permission_mode = merge_permission(cli.permission, file_cfg.permission.clone(), preset);
let session_path = resolve_session_path(
cli.session.clone(),
file_cfg.session.as_deref(),
&cli.workspace,
);
let profile_path = resolve_analog_profile_path(
&cli.workspace,
cli.profile.clone(),
file_cfg.profile.as_deref(),
);
let profile_hint = if let Some(ref p) = profile_path {
load_profile_hint(p)?
} else {
None
};
let config = build_config(
&cli,
&file_cfg,
prompt,
profile_hint,
session_path,
preset,
permission_mode,
);
let output_format = config.output_format;
let mut out = std::io::stdout();
if let Err(e) = claw_analog::run(config, &mut out).await {
if matches!(output_format, OutputFormat::Json) {
println!(
"{}",
serde_json::json!({"type": "error", "message": e.to_string()})
);
}
return Err(e);
}
Ok(())
}

View File

@ -1,5 +1,5 @@
[package] [package]
name = "rusty-claude-cli" name = "claw-cli"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
@ -12,7 +12,10 @@ path = "src/main.rs"
[dependencies] [dependencies]
api = { path = "../api" } api = { path = "../api" }
commands = { path = "../commands" } commands = { path = "../commands" }
dunce.workspace = true
compat-harness = { path = "../compat-harness" }
crossterm = "0.28" crossterm = "0.28"
unicode-width = "0.2"
pulldown-cmark = "0.13" pulldown-cmark = "0.13"
rustyline = "15" rustyline = "15"
runtime = { path = "../runtime" } runtime = { path = "../runtime" }
@ -22,14 +25,26 @@ serde_json.workspace = true
syntect = "5" syntect = "5"
tokio = { version = "1", features = ["rt-multi-thread", "signal", "time"] } tokio = { version = "1", features = ["rt-multi-thread", "signal", "time"] }
tools = { path = "../tools" } tools = { path = "../tools" }
log = "0.4" mime_guess = "2.0.5"
base64 = "0.22.1"
chardetng = "0.1"
image = "0.25"
sha2 = "0.10"
phf = { version = "0.11", features = ["macros"] }
dialoguer = "0.11"
inquire = "0.9.4"
[build-dependencies]
# 2.x is the API our build.rs uses (compile(path, embed_resource::NONE)).
# 0.3.x was never published; the crate jumped 1.x -> 2.x -> 3.x.
embed-resource = "2.5"
[lints] [lints]
workspace = true workspace = true
[dev-dependencies] [dev-dependencies]
mock-anthropic-service = { path = "../mock-anthropic-service" } mock-anthropic-service = { path = "../mock-anthropic-service" }
runtime = { path = "../runtime" }
serde_json.workspace = true serde_json.workspace = true
tokio = { version = "1", features = ["rt-multi-thread"] } tokio = { version = "1", features = ["rt-multi-thread"] }

Some files were not shown because too many files have changed in this diff Show More